Skip to main content

dash_platform_queries/documents/
document_query.rs

1//! Method to query documents from the Drive.
2
3use std::sync::Arc;
4
5use super::composite_document_query::{drive_sub_queries, sub_queries_to_proto, CompositeSubQuery};
6use crate::error::Error;
7use dapi_grpc::platform::v0::get_documents_request::Version::{V0, V1};
8use dapi_grpc::platform::v0::{
9    self as platform_proto,
10    get_documents_request::{
11        document_field_value,
12        get_documents_request_v0::Start,
13        get_documents_request_v1::{select, Select as ProtoSelect, Start as V1Start},
14        having_aggregate, having_clause,
15        integer_range_selection::Grid as ProtoIntegerRangeGrid,
16        order_clause,
17        time_range_selection::{Grid as ProtoTimeRangeGrid, Selector as ProtoTimeRangeSelector},
18        DocumentFieldValue as ProtoDocumentFieldValue, GetDocumentsRequestV0,
19        GetDocumentsRequestV1, HavingAggregate as ProtoHavingAggregate,
20        HavingClause as ProtoHavingClause, IntegerRangeSelection as ProtoIntegerRangeSelection,
21        OrderClause as ProtoOrderClause, TimeRangeSelection as ProtoTimeRangeSelection,
22        WhereClause as ProtoWhereClause, WhereOperator as ProtoWhereOperator,
23    },
24    GetDocumentsRequest, Proof, ResponseMetadata,
25};
26use dash_context_provider::ContextProvider;
27use dpp::dashcore::Network;
28use dpp::version::{PlatformVersion, TryFromPlatformVersioned};
29use dpp::{
30    data_contract::{
31        accessors::v0::DataContractV0Getters,
32        document_type::{accessors::DocumentTypeV0Getters, IndexBucketing},
33    },
34    document::Document,
35    platform_value::{platform_value, Value},
36    prelude::{DataContract, Identifier},
37    InvalidVectorSizeError, ProtocolError,
38};
39use drive::config::DEFAULT_QUERY_LIMIT;
40use drive::query::drive_document_ranked_query::mode_detection::ranked_order_key;
41use drive::query::{
42    resolve_integer_range_bucket_clause, resolve_time_range_bucket_clause,
43    validate_resolved_time_range_clause_shapes, DriveDocumentQuery, HavingAggregate,
44    HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand,
45    IntegerRangeGridSpec, InternalClauses, OrderClause, ResolvedTimeRange, SelectFunction,
46    SelectProjection, TimeRangeGridSpec, TimeRangeSelector, WhereClause, WhereOperator,
47};
48use drive_proof_verifier::{types::Documents, FromProof};
49
50// TODO: remove DocumentQuery once ContextProvider that provides data contracts is merged.
51
52/// One pending `IN_TIME_RANGE` selection: the timestamp field, the
53/// `"newest"` / `"oldest"` selector, and — when the contract buckets the
54/// field with more than one `timeRange` grid — the grid it targets (`None`
55/// means the field's sole grid, and the server rejects the bare form on a
56/// multi-grid field as ambiguous).
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
59pub struct TimeRangeClause {
60    /// The bucketed timestamp field the selection is on.
61    pub field: String,
62    /// Which active window to resolve to.
63    pub selector: TimeRangeSelector,
64    /// The grid targeted, in the contract's declared seconds; `None` when
65    /// the field carries a single grid.
66    #[cfg_attr(feature = "mocks", serde(default))]
67    pub grid: Option<TimeRangeGridSpec>,
68}
69
70/// One pending `IN_INTEGER_RANGE` selection: the integer field, the start
71/// of the window it selects, and — when the contract buckets the field with
72/// more than one `integerRange` grid — the grid it targets (`None` means the
73/// field's sole grid).
74#[derive(Debug, Clone, PartialEq)]
75#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
76pub struct IntegerRangeClause {
77    /// The bucketed integer field the selection is on.
78    pub field: String,
79    /// The selected window's start: an integer on the grid.
80    pub start: Value,
81    /// The grid targeted, as the contract declares it; `None` when the field
82    /// carries a single grid.
83    #[cfg_attr(feature = "mocks", serde(default))]
84    pub grid: Option<IntegerRangeGridSpec>,
85}
86
87/// Request that is used to query documents from the Dash Platform.
88///
89/// This is an abstraction layer built on top of [GetDocumentsRequest] to address issues with missing details
90/// required to correctly verify proofs returned by the Dash Platform.
91///
92/// Conversions are implemented between this type, [GetDocumentsRequest] and [DriveDocumentQuery] using [TryFrom] trait.
93///
94/// Add related document or count queries with [`Self::with_sub_query`].
95/// Fetch these compositions as [`drive_proof_verifier::CompositeDocuments`]
96/// to receive both the page and its verified sub-results.
97#[derive(Debug, Clone, PartialEq, dash_platform_macros::Mockable)]
98#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
99pub struct DocumentQuery {
100    /// SQL-shaped `SELECT` projection — `(function, field)` pair.
101    /// `Documents` returns matched rows; `Count` / `Sum` / `Avg`
102    /// return either a single aggregate (empty `group_by`) or
103    /// per-group entries (non-empty `group_by`). Defaults to
104    /// `SelectProjection::documents()` so callers that don't opt
105    /// into the SQL-shaped surface get plain document-fetch
106    /// semantics.
107    ///
108    /// `#[serde(default)]` here (and on `group_by` / `having`
109    /// below) is wire-format-compat for mock vectors captured
110    /// before the SQL-shaped surface was added: default
111    /// `SelectProjection` is `documents()`, `Vec` defaults to
112    /// empty — together those mean an old fixture without these
113    /// fields deserializes to the documents-fetch shape it was
114    /// originally captured under. New fixtures should serialize
115    /// the fields explicitly.
116    #[cfg_attr(feature = "mocks", serde(default))]
117    pub select: SelectProjection,
118    /// Data contract
119    pub data_contract: Arc<DataContract>,
120    /// Document type for the data contract
121    pub document_type_name: String,
122    /// `where` clauses for the query
123    pub where_clauses: Vec<WhereClause>,
124    /// Time-range (`IN_TIME_RANGE`) selections on a timestamp field covered
125    /// by a `timeRange` index. These are emitted as `IN_TIME_RANGE` clauses
126    /// on the v1 wire and resolved server-side from the current block time;
127    /// the verifier re-derives the same bucket from the quorum-signed
128    /// response metadata time. v1-only (the v0 wire has no `IN_TIME_RANGE`
129    /// operator). See [`Self::with_time_range`] and
130    /// [`Self::with_time_range_grid`].
131    #[cfg_attr(feature = "mocks", serde(default))]
132    pub time_range_clauses: Vec<TimeRangeClause>,
133    /// Integer-range (`IN_INTEGER_RANGE`) selections on an integer field
134    /// covered by an `integerRange` index, each naming one window by its
135    /// start. Emitted as `IN_INTEGER_RANGE` clauses on the v1 wire; the
136    /// server and the verifier resolve the same window from the query
137    /// alone. See [`Self::with_integer_range`] and
138    /// [`Self::with_integer_range_grid`].
139    #[cfg_attr(feature = "mocks", serde(default))]
140    pub integer_range_clauses: Vec<IntegerRangeClause>,
141    /// SQL `GROUP BY` field names, in left-to-right order. Empty =
142    /// no explicit grouping (aggregate count for `select=Count`).
143    /// Only meaningful when `select=Count`; non-empty with
144    /// `select=Documents` is rejected by the server as unsupported.
145    #[cfg_attr(feature = "mocks", serde(default))]
146    pub group_by: Vec<String>,
147    /// SQL `HAVING` clauses — **boolean** aggregate filters that apply
148    /// to the grouped rows produced by `select = Count | Sum | Avg`
149    /// with a non-empty `group_by`. Unlike `where_clauses`, the left
150    /// side is an aggregate (`COUNT(*)`, `SUM(field)`, `AVG(field)`)
151    /// rather than a raw row field. See [`HavingClause`] /
152    /// [`drive::query::HavingAggregate`] /
153    /// [`drive::query::HavingOperator`] for the catalogs. Multiple
154    /// entries combine with implicit `AND`.
155    ///
156    /// **Served from protocol version 14, for exactly one clause
157    /// bounding the selected aggregate** with a contiguous-range
158    /// operator (`=`, `>`, `>=`, `<`, `<=`, `BETWEEN*`) — the
159    /// having-range surface, fetched as
160    /// [`DocumentHavingEntries`](drive_proof_verifier::DocumentHavingEntries)
161    /// and served as a value-bounded range read of the covering ranked
162    /// index's axis secondary (the index must declare the matching
163    /// `rankedCountable` / `rankedSummable` / `rankedAverageable`
164    /// keyword). Everything else — multiple clauses (implicit AND), a
165    /// clause on an aggregate the select does not project, `!=` / `IN`
166    /// — is still rejected with `QuerySyntaxError::Unsupported`, as is
167    /// any non-empty value at protocol version 13 and earlier.
168    ///
169    /// **`having` does not express ranking.** "The n highest-scoring
170    /// groups" is [`Self::order_by_selected_aggregate`] +
171    /// [`Self::with_limit`] — SQL's own `ORDER BY <agg> DESC LIMIT n`
172    /// — which is also served from protocol version 14. The two
173    /// compose only in the one shape the having grammar allows: an
174    /// `ORDER BY` naming the selected aggregate sets the having
175    /// range's walk direction.
176    #[cfg_attr(feature = "mocks", serde(default))]
177    pub having: Vec<HavingClause>,
178    /// `order_by` clauses for the query.
179    ///
180    /// For `select = Documents` these order the matched rows. For the
181    /// **ranked** surface a single clause naming the selected
182    /// aggregate orders the *groups* — see
183    /// [`Self::order_by_selected_aggregate`], which builds it.
184    pub order_by_clauses: Vec<OrderClause>,
185    /// queryset limit. `0` is the sentinel for "unset / default" and
186    /// is translated to `None` on the V1 wire (`optional uint32`).
187    pub limit: u32,
188    /// SQL `OFFSET` — how many result rows to skip before the returned
189    /// page. `None` leaves the field unset on the wire.
190    ///
191    /// Served on exactly one path: the **ranked** surface (protocol
192    /// version 14+), where it skips that many *ranks*, so
193    /// `.order_by_selected_aggregate(Descending).with_limit(1)
194    /// .with_offset(4)` is the 5th-best group. Everywhere else the
195    /// server rejects a set offset with `Unsupported("OFFSET
196    /// pagination is not yet implemented")`.
197    ///
198    /// `#[serde(default)]` for the same mock-vector compatibility
199    /// reason as `select` / `group_by` / `having`: a fixture captured
200    /// before offsets existed deserializes to `None`.
201    #[cfg_attr(feature = "mocks", serde(default))]
202    pub offset: Option<u32>,
203    /// first object to start with
204    pub start: Option<Start>,
205    /// Related document and count queries derived from this page. Empty for
206    /// ordinary document or aggregate queries. Fetch nonempty compositions
207    /// as [`drive_proof_verifier::CompositeDocuments`].
208    #[cfg_attr(feature = "mocks", serde(default))]
209    pub sub_queries: Vec<CompositeSubQuery>,
210}
211
212/// Which end of a ranking a
213/// [`DocumentQuery::order_by_selected_aggregate`] call walks from.
214///
215/// A named pair rather than a bare `ascending: bool`, because the two
216/// readings of a ranked query — "the best n" and "the worst n" — are
217/// what callers actually think in, and `false` meaning "best first" at
218/// a call site is exactly the sort of thing that gets flipped in
219/// review without anyone noticing.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum RankingDirection {
222    /// `ORDER BY <aggregate> DESC` — walk from the largest aggregate
223    /// down. The "top n" reading: entry 0 is the highest-scoring group.
224    Descending,
225    /// `ORDER BY <aggregate> ASC` — walk from the smallest aggregate
226    /// up. The "bottom n" reading: entry 0 is the lowest-scoring group.
227    Ascending,
228}
229
230impl DocumentQuery {
231    /// Create new DocumentQuery for provided contract and document type name.
232    pub fn new<C: Into<Arc<DataContract>>>(
233        contract: C,
234        document_type_name: &str,
235    ) -> Result<Self, Error> {
236        let contract = contract.into();
237        // ensure document type name is correct
238        contract
239            .document_type_for_name(document_type_name)
240            .map_err(ProtocolError::DataContractError)?;
241
242        Ok(Self {
243            select: SelectProjection::documents(),
244            data_contract: Arc::clone(&contract),
245            document_type_name: document_type_name.to_string(),
246            where_clauses: vec![],
247            time_range_clauses: vec![],
248            integer_range_clauses: vec![],
249            group_by: Vec::new(),
250            having: Vec::new(),
251            order_by_clauses: vec![],
252            limit: 0,
253            offset: None,
254            start: None,
255            sub_queries: vec![],
256        })
257    }
258
259    /// Ordinary document and aggregate proof results cannot represent sub-queries.
260    pub(super) fn ensure_no_sub_queries(&self) -> Result<(), drive_proof_verifier::Error> {
261        if !self.sub_queries.is_empty() {
262            return Err(drive_proof_verifier::Error::RequestError {
263                error: "this result type cannot return sub-queries; fetch the query as CompositeDocuments".to_string(),
264            });
265        }
266        Ok(())
267    }
268
269    /// Create new document query based on a [DriveDocumentQuery].
270    ///
271    /// Preserves sub-queries, including their contracts and bindings.
272    ///
273    /// Fails when the drive query carries window resolution provenance
274    /// (`resolved_time_ranges`): the resolved window equality cannot be
275    /// represented without it — see the `TryFrom` impl. Build the query
276    /// with [`Self::with_time_range`] / [`Self::with_time_range_grid`] or
277    /// [`Self::with_integer_range`] / [`Self::with_integer_range_grid`]
278    /// instead for window selections.
279    pub fn new_with_drive_query(d: &DriveDocumentQuery) -> Result<Self, crate::error::Error> {
280        Self::try_from(d)
281    }
282
283    /// Point to a specific document ID.
284    pub fn with_document_id(self, document_id: &Identifier) -> Self {
285        let clause = WhereClause {
286            field: "$id".to_string(),
287            operator: WhereOperator::Equal,
288            value: platform_value!(document_id),
289        };
290
291        self.with_where(clause)
292    }
293
294    /// Add new where clause to the query.
295    ///
296    /// Existing where clauses will be preserved.
297    pub fn with_where(mut self, clause: WhereClause) -> Self {
298        self.where_clauses.push(clause);
299
300        self
301    }
302
303    /// Restrict the query to a single time-range bucket of `field`
304    /// (a timestamp covered by a `timeRange` index). The relative selectors
305    /// ([`TimeRangeSelector::Newest`] / [`TimeRangeSelector::Oldest`]) pick a
306    /// currently active range, resolved server-side from the current block
307    /// time; the proof verifier re-derives the identical bucket from the
308    /// quorum-signed response metadata time. A
309    /// [`TimeRangeSelector::ByStart`] selection names a window absolutely —
310    /// current or historic — by its grid-aligned start, which both sides
311    /// read straight from the query. Emitted as an `IN_TIME_RANGE` clause
312    /// on the v1 wire. Requires protocol version 14+ — the first version
313    /// whose contract grammar hosts `timeRange` indexes.
314    ///
315    /// The bare selector is unambiguous only while exactly one grid buckets
316    /// `field`; when the contract declares several grids over it, use
317    /// [`Self::with_time_range_grid`] to name one.
318    ///
319    /// Existing time-range selections are preserved.
320    pub fn with_time_range(
321        mut self,
322        field: impl Into<String>,
323        selector: TimeRangeSelector,
324    ) -> Self {
325        self.time_range_clauses.push(TimeRangeClause {
326            field: field.into(),
327            selector,
328            grid: None,
329        });
330        self
331    }
332
333    /// [`Self::with_time_range`] naming a specific grid — required when the
334    /// contract buckets `field` with more than one `timeRange` grid. The
335    /// spec's `range` / `step` / `phase` are the contract's own declared
336    /// seconds, verbatim.
337    ///
338    /// Existing time-range selections are preserved.
339    pub fn with_time_range_grid(
340        mut self,
341        field: impl Into<String>,
342        selector: TimeRangeSelector,
343        grid: TimeRangeGridSpec,
344    ) -> Self {
345        self.time_range_clauses.push(TimeRangeClause {
346            field: field.into(),
347            selector,
348            grid: Some(grid),
349        });
350        self
351    }
352
353    /// Restrict the query to one window of `field` (an integer covered by an
354    /// `integerRange` index): the window starting at `start`, which must be
355    /// a window start of the grid (`phase + k * step` within the field's
356    /// integer type, or the type's minimum for a clamped bottom window).
357    /// Emitted as an `IN_INTEGER_RANGE` clause on the v1 wire; the server
358    /// and the proof verifier resolve the same window from the query alone.
359    /// Requires protocol version 14+.
360    ///
361    /// The bare selection is unambiguous only while exactly one grid buckets
362    /// `field`; when the contract declares several, use
363    /// [`Self::with_integer_range_grid`].
364    ///
365    /// Existing integer-range selections are preserved.
366    pub fn with_integer_range(mut self, field: impl Into<String>, start: impl Into<Value>) -> Self {
367        self.integer_range_clauses.push(IntegerRangeClause {
368            field: field.into(),
369            start: start.into(),
370            grid: None,
371        });
372        self
373    }
374
375    /// [`Self::with_integer_range`] naming a specific grid — required when
376    /// the contract buckets `field` with more than one `integerRange` grid.
377    /// The spec's `range` / `step` / `phase` are the contract's own, verbatim.
378    ///
379    /// Existing integer-range selections are preserved.
380    pub fn with_integer_range_grid(
381        mut self,
382        field: impl Into<String>,
383        start: impl Into<Value>,
384        grid: IntegerRangeGridSpec,
385    ) -> Self {
386        self.integer_range_clauses.push(IntegerRangeClause {
387            field: field.into(),
388            start: start.into(),
389            grid: Some(grid),
390        });
391        self
392    }
393
394    /// Add order by clause to the query.
395    ///
396    /// Existing order by clauses will be preserved.
397    pub fn with_order_by(mut self, clause: OrderClause) -> Self {
398        self.order_by_clauses.push(clause);
399
400        self
401    }
402
403    /// Set the SQL-shaped `SELECT` projection.
404    ///
405    /// Construct the [`SelectProjection`] via its helpers:
406    /// [`SelectProjection::documents`] (the default — matched
407    /// rows), [`SelectProjection::count_star`] for `COUNT(*)`,
408    /// [`SelectProjection::count_field`] for `COUNT(field)`,
409    /// [`SelectProjection::sum`] for `SUM(field)`,
410    /// [`SelectProjection::avg`] for `AVG(field)`. Pair the
411    /// count/sum/avg projections with [`DocumentCount::fetch`]
412    /// (single aggregate, empty `group_by`) or
413    /// [`DocumentSplitCounts::fetch`] (per-group entries,
414    /// non-empty `group_by`).
415    ///
416    /// Server capability today: `Documents`, `COUNT(*)`,
417    /// `SUM(<field>)`, and `AVG(<field>)` are evaluated
418    /// end-to-end. `COUNT(<field>)`, `MIN(<field>)`, and
419    /// `MAX(<field>)` are accepted by the SDK but rejected by the
420    /// server with `Unsupported("SELECT … is not yet
421    /// implemented")` — the surface is shipped first and
422    /// execution lands later.
423    pub fn with_select(mut self, select: SelectProjection) -> Self {
424        self.select = select;
425        self
426    }
427
428    /// Set the `GROUP BY` field to a single field name.
429    ///
430    /// Convenience wrapper around [`Self::with_group_by_fields`].
431    /// Replaces any previously set `group_by`. Pair with
432    /// [`Self::with_select`] (e.g.
433    /// `with_select(SelectProjection::count_star())`) for the
434    /// per-group entries shape.
435    pub fn with_group_by<S: Into<String>>(mut self, field: S) -> Self {
436        self.group_by = vec![field.into()];
437        self
438    }
439
440    /// Set the full `GROUP BY` field list (replaces any previously
441    /// set `group_by`).
442    ///
443    /// Multi-field `group_by` is only accepted by the server for
444    /// `(in_field, range_field)` matching a compound `In + range`
445    /// where clause against a `rangeCountable: true` index. Other
446    /// non-empty shapes return `QuerySyntaxError::Unsupported`.
447    pub fn with_group_by_fields<I, S>(mut self, fields: I) -> Self
448    where
449        I: IntoIterator<Item = S>,
450        S: Into<String>,
451    {
452        self.group_by = fields.into_iter().map(Into::into).collect();
453        self
454    }
455
456    /// Set the `HAVING` clauses (replaces any prior value).
457    ///
458    /// From protocol version 14, a grouped aggregate query carrying
459    /// **exactly one** clause that bounds the selected aggregate with
460    /// a contiguous-range operator (`=`, `>`, `>=`, `<`, `<=`, the
461    /// `BETWEEN` variants) is served as a value-bounded range read of
462    /// the covering ranked index's axis secondary — fetch the result
463    /// through `DocumentHavingEntries::fetch`, which verifies the
464    /// proof including its completeness. The server still rejects
465    /// multiple clauses, a clause on a different aggregate than the
466    /// select's, and the non-contiguous operators (`!=`, `IN`);
467    /// protocol version 13 and earlier reject every non-empty
468    /// `having`.
469    ///
470    /// This is **not** how you ask for a ranking — see
471    /// [`Self::order_by_selected_aggregate`].
472    pub fn with_having(mut self, having: Vec<HavingClause>) -> Self {
473        self.having = having;
474        self
475    }
476
477    /// Order the `GROUP BY` groups by the aggregate this query
478    /// selects — the **ranked** surface, `ORDER BY <the selected
479    /// aggregate> [ASC|DESC]` (protocol version 14+).
480    ///
481    /// Replaces any previously set `order_by`, because a ranked query
482    /// takes exactly one ordering clause and a second one is rejected
483    /// rather than combined.
484    ///
485    /// The ordered field name is derived from the current
486    /// [`Self::select`] by rs-drive's own
487    /// [`ranked_order_key`] — `SUM(f)` / `AVG(f)` are named by `f`,
488    /// and `COUNT(*)` by the `$count` sentinel. Calling
489    /// [`Self::with_select`] *after* this method leaves a stale field
490    /// name behind and the server will refuse the request; set the
491    /// select first, which is also how the query reads.
492    ///
493    /// Pair with [`Self::with_limit`] (the ranking's `n`, `1 ..= 100`)
494    /// and optionally [`Self::with_offset`], then fetch with
495    /// [`DocumentRankedEntries`](drive_proof_verifier::DocumentRankedEntries).
496    ///
497    /// # The 5th-best group
498    ///
499    /// ```rust,ignore
500    /// # use dash_sdk::platform::{DataContract, DocumentQuery};
501    /// # use dash_sdk::platform::documents::document_query::RankingDirection;
502    /// # use dash_sdk::drive::query::SelectProjection;
503    /// # fn example(contract: DataContract) -> Result<(), dash_sdk::Error> {
504    /// // SELECT avg(grade) GROUP BY restaurantId
505    /// //   ORDER BY avg(grade) DESC LIMIT 1 OFFSET 4
506    /// let query = DocumentQuery::new(contract, "review")?
507    ///     .with_select(SelectProjection::avg("grade"))
508    ///     .with_group_by("restaurantId")
509    ///     .order_by_selected_aggregate(RankingDirection::Descending)
510    ///     .with_limit(1)
511    ///     .with_offset(4);
512    /// # Ok(())
513    /// # }
514    /// ```
515    pub fn order_by_selected_aggregate(mut self, direction: RankingDirection) -> Self {
516        self.order_by_clauses = vec![OrderClause {
517            field: ranked_order_key(&self.select).to_string(),
518            ascending: matches!(direction, RankingDirection::Ascending),
519        }];
520        self
521    }
522
523    /// Set the SQL `OFFSET` — how many ranks to skip before the
524    /// returned page.
525    ///
526    /// Only the ranked surface honours it (see
527    /// [`Self::order_by_selected_aggregate`]); on every other path the
528    /// server rejects a set offset with `Unsupported`. There is no
529    /// ceiling: grovedb counts the skipped region from the subtree
530    /// aggregates instead of walking it, on both `prove` settings, so
531    /// the cost of a deep offset does not scale with the offset. It is
532    /// not identical to a shallow one — `offset = 0` keeps a sequential
533    /// fast path, a positive offset descends the tree in `O(log n)`,
534    /// and an offset at or past the population is answered from the
535    /// root without descending at all — but nothing here grows with how
536    /// far you page, which is why there is no ceiling. Only a proved
537    /// response additionally attests the count.
538    ///
539    /// An offset past the end of the ranking is a legitimate answer
540    /// rather than an error — the page comes back empty, and on a
541    /// proved fetch its `starting_rank` is the ranking's attested total
542    /// population.
543    pub fn with_offset(mut self, offset: u32) -> Self {
544        self.offset = Some(offset);
545        self
546    }
547
548    /// Set the query limit. `0` means "unset" — translated to
549    /// `None` on the V1 wire (the proto field is `optional uint32`).
550    ///
551    /// On `select=Count` with non-empty `group_by` against the
552    /// prove path, the server validates rather than clamps:
553    /// `limit > max_query_limit` is rejected with
554    /// `InvalidLimit` rather than silently truncated, since
555    /// clamping would invisibly break proof verification.
556    /// Leaving the limit unset (`0`) falls back to
557    /// `drive::config::DEFAULT_QUERY_LIMIT` on the proof verifier
558    /// side, keeping proof bytes deterministic across operators.
559    pub fn with_limit(mut self, limit: u32) -> Self {
560        self.limit = limit;
561        self
562    }
563
564    /// Convert into the wire-format [`GetDocumentsRequest`] using a
565    /// specific [`PlatformVersion`] to pick V0 vs V1. The dispatch
566    /// boundary is the document_query feature-version on the
567    /// platform_version: `0` → V0, `1` → V1.
568    pub fn try_into_request_for_version(
569        self,
570        platform_version: &PlatformVersion,
571    ) -> Result<GetDocumentsRequest, Error> {
572        GetDocumentsRequest::try_from_platform_versioned(self, platform_version)
573    }
574}
575
576impl FromProof<DocumentQuery> for Document {
577    type Request = DocumentQuery;
578    type Response = platform_proto::GetDocumentsResponse;
579    fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
580        request: I,
581        response: O,
582        network: Network,
583        platform_version: &PlatformVersion,
584        provider: &'a dyn ContextProvider,
585    ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
586    where
587        Self: Sized + 'a,
588    {
589        let request: Self::Request = request.into();
590
591        let (documents, metadata, proof): (Option<Documents>, ResponseMetadata, Proof) =
592            <Documents as FromProof<Self::Request>>::maybe_from_proof_with_metadata(
593                request,
594                response,
595                network,
596                platform_version,
597                provider,
598            )?;
599
600        match documents {
601            None => Ok((None, metadata, proof)),
602            Some(docs) => match docs.len() {
603                0 | 1 => Ok((
604                    docs.into_iter().next().and_then(|(_, v)| v),
605                    metadata,
606                    proof,
607                )),
608                n => Err(drive_proof_verifier::Error::ResponseDecodeError {
609                    error: format!("expected 1 element, got {}", n),
610                }),
611            },
612        }
613    }
614}
615
616impl FromProof<DocumentQuery> for drive_proof_verifier::types::Documents {
617    type Request = DocumentQuery;
618    type Response = platform_proto::GetDocumentsResponse;
619    fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
620        request: I,
621        response: O,
622        network: Network,
623        platform_version: &PlatformVersion,
624        provider: &'a dyn ContextProvider,
625    ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
626    where
627        Self: Sized + 'a,
628    {
629        let mut request: Self::Request = request.into();
630        request.ensure_no_sub_queries()?;
631        let response: Self::Response = response.into();
632
633        // A time-range (`IN_TIME_RANGE`) selection is resolved to a concrete
634        // bucket using the **quorum-signed** response metadata time — the same
635        // authoritative block time the server used to resolve it — so the
636        // reconstructed query matches the proof exactly. Resolve (and run the
637        // provenance-vs-shape guard, via the one shared normalization helper
638        // the aggregate verifiers also use) before the `DriveDocumentQuery`
639        // conversion so the engine sees ordinary equality clauses.
640        let mut resolved_time_ranges = Vec::new();
641        if !request.time_range_clauses.is_empty() || !request.integer_range_clauses.is_empty() {
642            // The generated `VersionedGrpcResponse::metadata()` handles both
643            // response envelopes (and any future one), so no hand-written
644            // version match is needed here.
645            use dapi_grpc::platform::VersionedGrpcResponse;
646            let time_ms = response
647                .metadata()
648                .map(|metadata| metadata.time_ms)
649                .map_err(|_| drive_proof_verifier::Error::ResponseDecodeError {
650                    error: "window selection query proof response is missing its metadata"
651                        .to_string(),
652                })?;
653            resolved_time_ranges =
654                normalize_time_range_clauses_with_metadata_time(&mut request, time_ms)?;
655        }
656
657        let mut drive_query: DriveDocumentQuery =
658            (&request)
659                .try_into()
660                .map_err(|e| drive_proof_verifier::Error::RequestError {
661                    error: format!("Failed to convert DocumentQuery to DriveQuery: {}", e),
662                })?;
663        // The conversion cannot recover which equalities came from resolution,
664        // so the provenance is carried across here; index selection reads it
665        // to pin the query to the index that buckets the field.
666        drive_query.resolved_time_ranges = resolved_time_ranges;
667
668        <drive_proof_verifier::types::Documents as FromProof<DriveDocumentQuery>>::maybe_from_proof_with_metadata(
669            drive_query,
670            response,
671            network,
672            platform_version,
673            provider,
674        )
675    }
676}
677
678/// Resolve a request's pending time-range (`IN_TIME_RANGE`) selections into
679/// concrete bucket-equality clauses on `request.where_clauses`, using the
680/// **quorum-signed** response metadata block time — the same authoritative
681/// time the server used — so the reconstructed query matches the proof
682/// exactly.
683///
684/// Every proof-verification path that rebuilds a drive query from a
685/// [`DocumentQuery`] must call this (or perform the identical resolution)
686/// *before* reading `request.where_clauses` for mode detection, covering-index
687/// selection, or query reconstruction: the documents path does it inline in
688/// its `FromProof` impl, and the count / sum / average aggregate helpers call
689/// this before resolving their mode. Skipping it would rebuild the query from
690/// a different shape than the prover used.
691///
692/// Returns the resolution provenance — one [`ResolvedTimeRange`] (field +
693/// exact grid) per selection, whose pushed clause is a bucket equality
694/// rather than a raw-timestamp one. Callers must carry them into index
695/// selection (`DriveDocumentQuery::resolved_time_ranges`, or the
696/// `resolved_time_ranges` argument of the aggregate index pickers):
697/// the pushed clause is an ordinary equality and nothing downstream can
698/// otherwise tell that it must be matched against the resolved grid's
699/// bucket starts.
700/// [`resolve_time_range_clauses_with_metadata_time`] followed immediately by
701/// the provenance-vs-shape guard — the two-step normalization every
702/// proof-verification path must run, in this order, before mode detection,
703/// covering-index selection, or query reconstruction. One definition so a
704/// future verifier path cannot omit either step or run them out of order:
705/// the aggregate paths once omitted the resolution entirely (valid proofs
706/// were rejected), and a path that resolved without the guard would let a
707/// caller-provided `In`/range clause on the resolved field reach the index
708/// pickers as if its raw values were bucket starts.
709pub(super) fn normalize_time_range_clauses_with_metadata_time(
710    request: &mut DocumentQuery,
711    time_ms: u64,
712) -> Result<Vec<ResolvedTimeRange>, drive_proof_verifier::Error> {
713    let resolved_time_ranges = resolve_time_range_clauses_with_metadata_time(request, time_ms)?;
714    validate_resolved_time_range_clause_shapes(&request.where_clauses, &resolved_time_ranges)
715        .map_err(|e| drive_proof_verifier::Error::RequestError {
716            error: format!("invalid window selection query shape: {}", e),
717        })?;
718    Ok(resolved_time_ranges)
719}
720
721pub(super) fn resolve_time_range_clauses_with_metadata_time(
722    request: &mut DocumentQuery,
723    time_ms: u64,
724) -> Result<Vec<ResolvedTimeRange>, drive_proof_verifier::Error> {
725    if request.time_range_clauses.is_empty() && request.integer_range_clauses.is_empty() {
726        return Ok(Vec::new());
727    }
728    let data_contract = Arc::clone(&request.data_contract);
729    let document_type = data_contract
730        .document_type_for_name(&request.document_type_name)
731        .map_err(|e| drive_proof_verifier::Error::RequestError {
732            error: format!("document type not found for window selection query: {}", e),
733        })?;
734    let time_range_clauses = std::mem::take(&mut request.time_range_clauses);
735    let mut resolved_time_ranges = Vec::with_capacity(time_range_clauses.len());
736    for TimeRangeClause {
737        field,
738        selector,
739        grid,
740    } in time_range_clauses
741    {
742        let (clause, resolved) =
743            resolve_time_range_bucket_clause(&field, selector, grid, document_type, time_ms)
744                .map_err(|e| drive_proof_verifier::Error::RequestError {
745                    error: format!("failed to resolve time range clause: {}", e),
746                })?;
747        request.where_clauses.push(clause);
748        resolved_time_ranges.push(resolved);
749    }
750    // Integer-range selections name their window absolutely: resolved
751    // from the query alone, exactly as the server resolved them.
752    let integer_range_clauses = std::mem::take(&mut request.integer_range_clauses);
753    for IntegerRangeClause { field, start, grid } in integer_range_clauses {
754        let (clause, resolved) =
755            resolve_integer_range_bucket_clause(&field, &start, grid, document_type).map_err(
756                |e| drive_proof_verifier::Error::RequestError {
757                    error: format!("failed to resolve integer range clause: {}", e),
758                },
759            )?;
760        request.where_clauses.push(clause);
761        resolved_time_ranges.push(resolved);
762    }
763    Ok(resolved_time_ranges)
764}
765
766/// Version-aware encoder. The dispatch is driven by the
767/// `drive_abci.query.document_query` feature-version on
768/// [`PlatformVersion`]: `0` → V0 wire (used by v3.0 testnet), `1` →
769/// V1 wire (introduced in v3.1).
770///
771/// V0 lacks `selects` / `group_by` / `having` / `offset` and the
772/// optional-limit semantics — callers that set those features get
773/// `Error::Config` with a clear "requires Platform v3.1+" message
774/// rather than a silently-truncated request. Time-range clauses are
775/// additionally gated on the v14 contract grammar — see the `1 =>` arm.
776impl TryFromPlatformVersioned<DocumentQuery> for GetDocumentsRequest {
777    type Error = Error;
778
779    fn try_from_platform_versioned(
780        value: DocumentQuery,
781        platform_version: &PlatformVersion,
782    ) -> Result<Self, Self::Error> {
783        if !value.sub_queries.is_empty() {
784            value.check_composite_shape()?;
785        }
786        let DocumentQuery {
787            select,
788            data_contract,
789            document_type_name,
790            where_clauses,
791            time_range_clauses,
792            integer_range_clauses,
793            group_by,
794            having,
795            order_by_clauses,
796            limit,
797            offset,
798            start,
799            sub_queries,
800        } = value;
801
802        let feature_version = platform_version
803            .drive_abci
804            .query
805            .document_query
806            .default_current_version;
807
808        tracing::debug!(
809            target: "dash_sdk::query_encoder",
810            feature_version,
811            protocol_version = platform_version.protocol_version,
812            "encoding GetDocumentsRequest"
813        );
814
815        match feature_version {
816            0 => {
817                if !sub_queries.is_empty() {
818                    return Err(Error::Config(
819                        "composite document queries require the V1 documents wire (Platform v3.1+)"
820                            .to_string(),
821                    ));
822                }
823                if !time_range_clauses.is_empty() {
824                    return Err(Error::Config(
825                        "time range (IN_TIME_RANGE) queries require protocol version 14+; the \
826                         v0 getDocuments wire has no time-range operator"
827                            .to_string(),
828                    ));
829                }
830                if !integer_range_clauses.is_empty() {
831                    return Err(Error::Config(
832                        "integer range (IN_INTEGER_RANGE) queries require protocol version \
833                         14+; the v0 getDocuments wire has no integer-range operator"
834                            .to_string(),
835                    ));
836                }
837                encode_v0(
838                    data_contract.id().to_vec(),
839                    document_type_name,
840                    where_clauses,
841                    order_by_clauses,
842                    limit,
843                    offset,
844                    start,
845                    &select,
846                    &group_by,
847                    &having,
848                )
849            }
850            1 => {
851                // The v1 wire predates time-range indexes: protocol
852                // versions 12 and 13 also serve it, but their contract
853                // grammar (document meta-schema generations 1 and 2)
854                // cannot host a `timeRange` index. Gate on the grammar
855                // generation — the same table the server's parser reads —
856                // rather than emitting an operator a pre-v14 server
857                // rejects as an unknown discriminant.
858                let grammar_generation = platform_version
859                    .dpp
860                    .contract_versions
861                    .document_type_versions
862                    .schema
863                    .document_type_schema;
864                if !time_range_clauses.is_empty() && grammar_generation < 3 {
865                    return Err(Error::Config(format!(
866                        "time range (IN_TIME_RANGE) queries require protocol version 14+ — the \
867                         first version whose contract grammar hosts `timeRange` indexes; this \
868                         network runs protocol version {}",
869                        platform_version.protocol_version
870                    )));
871                }
872                if !integer_range_clauses.is_empty() && grammar_generation < 3 {
873                    return Err(Error::Config(format!(
874                        "integer range (IN_INTEGER_RANGE) queries require protocol version 14+ \
875                         — the first version whose contract grammar hosts `integerRange` \
876                         indexes; this network runs protocol version {}",
877                        platform_version.protocol_version
878                    )));
879                }
880                encode_v1(
881                    data_contract.id().to_vec(),
882                    document_type_name,
883                    where_clauses,
884                    time_range_clauses,
885                    integer_range_clauses,
886                    order_by_clauses,
887                    limit,
888                    offset,
889                    start,
890                    select,
891                    group_by,
892                    having,
893                    sub_queries_to_proto(sub_queries)?,
894                )
895            }
896            n => Err(Error::Config(format!(
897                "GetDocumentsRequest wire encoder does not support feature_version={n} \
898                 (drive_abci.query.document_query) on PlatformVersion v{}",
899                platform_version.protocol_version
900            ))),
901        }
902    }
903}
904
905#[allow(clippy::too_many_arguments)]
906fn encode_v1(
907    data_contract_id: Vec<u8>,
908    document_type: String,
909    where_clauses: Vec<WhereClause>,
910    time_range_clauses: Vec<TimeRangeClause>,
911    integer_range_clauses: Vec<IntegerRangeClause>,
912    order_by_clauses: Vec<OrderClause>,
913    limit: u32,
914    offset: Option<u32>,
915    start: Option<Start>,
916    select: SelectProjection,
917    group_by: Vec<String>,
918    having: Vec<HavingClause>,
919    sub_queries: Vec<platform_proto::get_documents_request::get_documents_request_v1::SubQuery>,
920) -> Result<GetDocumentsRequest, Error> {
921    let mut where_clauses = where_clauses
922        .into_iter()
923        .map(where_clause_to_proto)
924        .collect::<Result<Vec<_>, _>>()?;
925    // Append time-range selections as `IN_TIME_RANGE` clauses carrying the
926    // typed `time_range` operand (`value` stays unset — the selection is
927    // not a field value). The grid, when named, repeats the contract's
928    // declared seconds verbatim; a zero phase is proto3's default, so a
929    // phaseless grid has exactly one wire spelling by construction. The
930    // server resolves the relative selectors to a concrete bucket from
931    // current block time and the verifier re-derives the same bucket from
932    // the signed response metadata time; a `ByStart` selection carries its
933    // window's start in the query itself, so both sides read it verbatim.
934    for TimeRangeClause {
935        field,
936        selector,
937        grid,
938    } in time_range_clauses
939    {
940        let (proto_selector, start_ms) = match selector {
941            TimeRangeSelector::Newest => (ProtoTimeRangeSelector::Newest, None),
942            TimeRangeSelector::Oldest => (ProtoTimeRangeSelector::Oldest, None),
943            TimeRangeSelector::ByStart { start_ms } => {
944                (ProtoTimeRangeSelector::ByStart, Some(start_ms))
945            }
946        };
947        where_clauses.push(ProtoWhereClause {
948            field,
949            operator: ProtoWhereOperator::InTimeRange as i32,
950            value: None,
951            time_range: Some(ProtoTimeRangeSelection {
952                selector: proto_selector as i32,
953                start_ms,
954                grid: grid.map(|spec| ProtoTimeRangeGrid {
955                    range: spec.range_seconds,
956                    step: spec.step_seconds,
957                    phase: spec.phase_seconds,
958                }),
959            }),
960            integer_range: None,
961        });
962    }
963    // Integer-range selections ride as `IN_INTEGER_RANGE` clauses carrying
964    // the typed `integer_range` operand: the window start (coerced through
965    // the schema on the server like any value) and, when named, the grid.
966    for IntegerRangeClause { field, start, grid } in integer_range_clauses {
967        where_clauses.push(ProtoWhereClause {
968            field,
969            operator: ProtoWhereOperator::InIntegerRange as i32,
970            value: None,
971            time_range: None,
972            integer_range: Some(ProtoIntegerRangeSelection {
973                start: Some(value_to_proto(start)?),
974                grid: grid.map(|spec| ProtoIntegerRangeGrid {
975                    range: spec.range,
976                    step: spec.step,
977                    phase: spec.phase,
978                }),
979            }),
980        });
981    }
982    let order_by = order_by_clauses
983        .into_iter()
984        .map(order_clause_to_proto)
985        .collect();
986    let having = having
987        .into_iter()
988        .map(having_clause_to_proto)
989        .collect::<Result<Vec<_>, _>>()?;
990    // `limit: u32` with `0` sentinel → `optional uint32` on the V1
991    // wire. `None` lets the server apply its own default; explicit
992    // `0` would be a strange "return zero rows" request.
993    let limit = if limit == 0 { None } else { Some(limit) };
994    // V0 and V1 ship separate `Start` enums even though the shape
995    // is identical. Translate at the wire boundary so the
996    // `DocumentQuery.start` field stays stable for callers already
997    // using the V0 type.
998    let start_v1 = start.map(|s| match s {
999        Start::StartAfter(b) => V1Start::StartAfter(b),
1000        Start::StartAt(b) => V1Start::StartAt(b),
1001    });
1002
1003    Ok(GetDocumentsRequest {
1004        version: Some(V1(GetDocumentsRequestV1 {
1005            data_contract_id,
1006            document_type,
1007            where_clauses,
1008            order_by,
1009            limit,
1010            // Document fetch always proves via this conversion.
1011            // Count fetch uses the same wire shape; both paths go
1012            // through the `FromProof` decoders which expect the
1013            // `Proof(...)` response variant. `SdkBuilder::with_proofs(false)`
1014            // is consequently a no-op for both — see the blanket
1015            // `Query<T> for T` impl in `packages/rs-sdk/src/platform/query.rs`
1016            // for the `tracing::warn!` emitted at fetch time when
1017            // proofs are disabled.
1018            prove: true,
1019            start: start_v1,
1020            // `repeated Select selects` on the wire — single
1021            // projection wraps in a one-element vec; the SDK's
1022            // `DocumentQuery` carries a single `SelectProjection`
1023            // because multi-projection is wire-only today.
1024            selects: vec![select_to_proto(select)],
1025            group_by,
1026            having,
1027            // Honoured on the ranked path (it is the `OFFSET` of
1028            // `ORDER BY <agg> DESC LIMIT n OFFSET m`) and rejected by
1029            // the server everywhere else. Passed straight through:
1030            // deciding here which paths may carry an offset would put
1031            // a second copy of that rule in the SDK.
1032            offset,
1033            chained: None,
1034            sub_queries,
1035        })),
1036    })
1037}
1038
1039#[allow(clippy::too_many_arguments)]
1040fn encode_v0(
1041    data_contract_id: Vec<u8>,
1042    document_type: String,
1043    where_clauses: Vec<WhereClause>,
1044    order_by_clauses: Vec<OrderClause>,
1045    limit: u32,
1046    offset: Option<u32>,
1047    start: Option<Start>,
1048    select: &SelectProjection,
1049    group_by: &[String],
1050    having: &[HavingClause],
1051) -> Result<GetDocumentsRequest, Error> {
1052    // V0 only carries plain `getDocuments` semantics — reject the
1053    // v1-only SQL-shaped surfaces with a typed error rather than
1054    // letting the server reject them after a round-trip.
1055    if !matches!(select.function, SelectFunction::Documents) {
1056        return Err(Error::Config(format!(
1057            "select={:?} requires Platform v3.1+ (V1 documents wire); pin/upgrade \
1058             to a v3.1+ network or rebuild the query with SelectProjection::documents()",
1059            select.function
1060        )));
1061    }
1062    if !group_by.is_empty() {
1063        return Err(Error::Config(
1064            "group_by requires Platform v3.1+ (V1 documents wire); not supported on V0".to_string(),
1065        ));
1066    }
1067    if !having.is_empty() {
1068        return Err(Error::Config(
1069            "having clauses require Platform v3.1+ (V1 documents wire); not supported on V0"
1070                .to_string(),
1071        ));
1072    }
1073    if offset.is_some() {
1074        // The V0 request message has no `offset` field at all, so
1075        // silently dropping it would page from rank 0 while the caller
1076        // believed they had skipped ahead — the one failure mode worth
1077        // an extra branch here.
1078        return Err(Error::Config(
1079            "offset requires Platform v3.1+ (V1 documents wire); not supported on V0".to_string(),
1080        ));
1081    }
1082
1083    // V0 carries CBOR-serialized arrays of clause components. The
1084    // server decodes them via `ciborium::de::from_reader` into a
1085    // `Value`, then expects `Value::Array(clauses)` where each
1086    // inner clause is `[field_text, operator_text, value]` (where)
1087    // or `[field_text, "asc"|"desc"]` (order_by). Build the same
1088    // shape via the existing `From<WhereClause> for Value` /
1089    // `From<OrderClause> for Value` impls, then serialize the
1090    // top-level array.
1091    let where_bytes = if where_clauses.is_empty() {
1092        Vec::new()
1093    } else {
1094        let where_value = Value::Array(where_clauses.into_iter().map(Value::from).collect());
1095        where_value.to_cbor_buffer().map_err(|e| {
1096            Error::Protocol(dpp::ProtocolError::EncodingError(format!(
1097                "failed to CBOR-encode v0 where clauses: {e}"
1098            )))
1099        })?
1100    };
1101    let order_by_bytes = if order_by_clauses.is_empty() {
1102        Vec::new()
1103    } else {
1104        let order_value = Value::Array(order_by_clauses.into_iter().map(Value::from).collect());
1105        order_value.to_cbor_buffer().map_err(|e| {
1106            Error::Protocol(dpp::ProtocolError::EncodingError(format!(
1107                "failed to CBOR-encode v0 order_by clauses: {e}"
1108            )))
1109        })?
1110    };
1111
1112    Ok(GetDocumentsRequest {
1113        version: Some(V0(GetDocumentsRequestV0 {
1114            data_contract_id,
1115            document_type,
1116            r#where: where_bytes,
1117            order_by: order_by_bytes,
1118            // V0's `limit` is a plain u32 with 0 = "server default".
1119            // V1's `optional uint32` keeps 0 as a structurally
1120            // meaningless explicit-zero; we translate by clamping
1121            // to 0 only when the caller meant "unset".
1122            limit,
1123            start,
1124            // `prove: true` hardcoded — same rationale as `encode_v1`:
1125            // document fetch always proves; `SdkBuilder::with_proofs(false)`
1126            // is a no-op for this path because the `FromProof` decoder
1127            // expects the `Proof(...)` response variant.
1128            prove: true,
1129        })),
1130    })
1131}
1132
1133impl<'a> TryFrom<&'a DriveDocumentQuery<'a>> for DocumentQuery {
1134    type Error = crate::error::Error;
1135
1136    /// Preserves sub-queries through SDK request construction and proof verification.
1137    ///
1138    /// Fallible by necessity: a drive query carrying `resolved_time_ranges`
1139    /// holds bucket-start equalities whose meaning lives in the provenance,
1140    /// and `DocumentQuery` has no field to carry it — the original
1141    /// `IN_TIME_RANGE` selector cannot be reconstructed from the resolved
1142    /// query. Serializing such a query would silently demote the bucket
1143    /// equality to a raw-timestamp predicate: a transformed-index-only
1144    /// contract then rejects the request, while a contract with a competing
1145    /// plain index returns a different — but validly proven — result.
1146    fn try_from(value: &'a DriveDocumentQuery<'a>) -> Result<Self, Self::Error> {
1147        if let Some(resolved) = value.resolved_time_ranges.first() {
1148            let builders = match resolved.transform {
1149                IndexBucketing::Time(_) => {
1150                    "`with_time_range` / `with_time_range_grid` instead, so the selector is \
1151                     resolved against the signed response metadata"
1152                }
1153                IndexBucketing::Integer(_) => {
1154                    "`with_integer_range` / `with_integer_range_grid` instead, so the window is \
1155                     resolved from the query as the server resolves it"
1156                }
1157            };
1158            return Err(Error::Config(format!(
1159                "a drive query carrying {} resolution provenance cannot be converted to a \
1160                 DocumentQuery: the resolved window equality would be demoted to a raw-value \
1161                 predicate. Build the DocumentQuery with {}",
1162                resolved.kind(),
1163                builders
1164            )));
1165        }
1166        let data_contract = value.contract.clone();
1167        let document_type_name = value.document_type.name();
1168        let where_clauses = value.internal_clauses.clone().into();
1169        let order_by_clauses = value.order_by.iter().map(|(_, v)| v.clone()).collect();
1170        let limit = value.limit.unwrap_or(0) as u32;
1171        let offset = value.offset.map(u32::from);
1172
1173        let start = if let Some(start_at) = value.start_at {
1174            match value.start_at_included {
1175                true => Some(Start::StartAt(start_at.to_vec())),
1176                false => Some(Start::StartAfter(start_at.to_vec())),
1177            }
1178        } else {
1179            None
1180        };
1181
1182        Ok(Self {
1183            // `DriveDocumentQuery` has no SELECT/GROUP BY/HAVING/time-range
1184            // concept — it's a documents-only query. Default to the
1185            // v1 documents shape.
1186            select: SelectProjection::documents(),
1187            data_contract: Arc::new(data_contract),
1188            document_type_name: document_type_name.to_string(),
1189            where_clauses,
1190            time_range_clauses: Vec::new(),
1191            integer_range_clauses: Vec::new(),
1192            group_by: Vec::new(),
1193            having: Vec::new(),
1194            order_by_clauses,
1195            limit,
1196            offset,
1197            start,
1198            sub_queries: value
1199                .sub_queries
1200                .iter()
1201                .map(CompositeSubQuery::from)
1202                .collect(),
1203        })
1204    }
1205}
1206
1207impl<'a> TryFrom<DriveDocumentQuery<'a>> for DocumentQuery {
1208    type Error = crate::error::Error;
1209
1210    /// By-value twin of the by-reference conversion above — same
1211    /// sub-query preservation and provenance rejection, same rationale.
1212    fn try_from(value: DriveDocumentQuery<'a>) -> Result<Self, Self::Error> {
1213        DocumentQuery::try_from(&value)
1214    }
1215}
1216
1217impl<'a> TryFrom<&'a DocumentQuery> for DriveDocumentQuery<'a> {
1218    type Error = crate::error::Error;
1219
1220    fn try_from(request: &'a DocumentQuery) -> Result<Self, Self::Error> {
1221        if !request.sub_queries.is_empty() {
1222            request.check_composite_shape()?;
1223        }
1224        // A pending (unresolved) time-range selection MUST be resolved into a
1225        // concrete bucket-equality clause before a drive query can be built —
1226        // see `resolve_time_range_clauses_with_metadata_time`. Silently
1227        // dropping it here would rebuild (and verify against) a strictly
1228        // broader query than the prover ran, so refuse instead: this makes
1229        // "forgot to resolve" a loud error on every present and future call
1230        // path rather than a silent verification hole.
1231        if !request.time_range_clauses.is_empty() || !request.integer_range_clauses.is_empty() {
1232            return Err(Error::Config(
1233                "the query's window selections (IN_TIME_RANGE / IN_INTEGER_RANGE) have not been \
1234                 resolved into window-start equalities; resolve them (time selections against \
1235                 the response's quorum-signed metadata time) before building a drive query"
1236                    .to_string(),
1237            ));
1238        }
1239
1240        // let data_contract = request.data_contract.clone();
1241        let document_type = request
1242            .data_contract
1243            .document_type_for_name(&request.document_type_name)
1244            .map_err(ProtocolError::DataContractError)?;
1245
1246        // Client-side construction groups under the latest grammar; the
1247        // server and the proof verifier enforce the network's protocol
1248        // version at path-query lowering.
1249        let internal_clauses = InternalClauses::extract_from_clauses(
1250            request.where_clauses.clone(),
1251            PlatformVersion::latest(),
1252        )
1253        .map_err(Error::Drive)?;
1254
1255        // Mirror the limit contract of the server's
1256        // `DriveDocumentQuery::from_typed_clauses` exactly: `0` (this
1257        // struct's "unset" sentinel — V0's `limit: 0`, V1's
1258        // `limit: None`) falls back to the server default, and anything
1259        // above `DEFAULT_QUERY_LIMIT` (the `config.default_query_limit`
1260        // every deployed server runs with) is refused with the server's
1261        // own `QuerySyntaxError::InvalidLimit` rather than truncated or
1262        // passed through. A `u16::try_from` alone would not do: limits
1263        // 101..=65535 fit a `u16` but the server refuses them, so a raw
1264        // `DriveDocumentQuery` carrying one would verify a proof no
1265        // honest server could have produced.
1266        let limit = match request.limit {
1267            0 => Some(DEFAULT_QUERY_LIMIT),
1268            limit if limit > u32::from(DEFAULT_QUERY_LIMIT) => {
1269                return Err(Error::Drive(drive::error::Error::Query(
1270                    drive::error::query::QuerySyntaxError::InvalidLimit(format!(
1271                        "limit {} greater than max limit {}",
1272                        limit, DEFAULT_QUERY_LIMIT
1273                    )),
1274                )));
1275            }
1276            limit => Some(limit as u16),
1277        };
1278
1279        let (start_at, start_at_included) = match request.start.as_ref() {
1280            None => (None, false),
1281            Some(Start::StartAt(at)) => (
1282                Some(at.clone().try_into().map_err(|_| {
1283                    ProtocolError::InvalidVectorSizeError(InvalidVectorSizeError::new(32, at.len()))
1284                })?),
1285                true,
1286            ),
1287            Some(Start::StartAfter(after)) => (
1288                Some(after.clone().try_into().map_err(|_| {
1289                    ProtocolError::InvalidVectorSizeError(InvalidVectorSizeError::new(
1290                        32,
1291                        after.len(),
1292                    ))
1293                })?),
1294                false,
1295            ),
1296        };
1297
1298        // `DriveDocumentQuery`'s offset is a `u16`; the wire's is a
1299        // `u32` because the ranked path takes an unbounded one. A
1300        // documents query that overflows `u16` is refused rather than
1301        // truncated — silently paging from a different rank than the
1302        // caller asked for is the worst available outcome.
1303        let offset = request
1304            .offset
1305            .map(|offset| {
1306                u16::try_from(offset).map_err(|_| {
1307                    Error::Config(format!(
1308                        "offset {offset} does not fit a documents query's u16 offset \
1309                         (max {}); offsets above that are only meaningful on the ranked \
1310                         surface, which does not route through DriveDocumentQuery",
1311                        u16::MAX
1312                    ))
1313                })
1314            })
1315            .transpose()?;
1316
1317        let query = Self {
1318            contract: &request.data_contract,
1319            document_type,
1320            internal_clauses,
1321            offset,
1322            limit,
1323            order_by: request
1324                .order_by_clauses
1325                .clone()
1326                .into_iter()
1327                .map(|v| (v.field.clone(), v))
1328                .collect(),
1329            start_at,
1330            start_at_included,
1331            block_time_ms: None,
1332            // A `DocumentQuery` reaching here carries no unresolved
1333            // time-range selection (rejected above) and cannot tell which of
1334            // its equalities came from resolution. Callers that resolved
1335            // selections assign the fields they resolved onto the returned
1336            // query; everything else is a raw query.
1337            resolved_time_ranges: vec![],
1338            sub_queries: drive_sub_queries(request)?,
1339        };
1340
1341        Ok(query)
1342    }
1343}
1344
1345/// Convert a drive [`WhereClause`] into its wire-format proto
1346/// counterpart. The proto value variant is picked from the
1347/// `dpp::platform_value::Value` variant by primitive type — schema-
1348/// agnostic, matching the inverse direction the rs-drive-abci v1
1349/// handler runs via its `conversions::value_from_proto`.
1350///
1351/// Errors only on `Value` variants that have no wire-format
1352/// counterpart (`Map`, `EnumU8`, `EnumString`) — these aren't
1353/// produced by the SDK's typical WhereClause builders, so a
1354/// rejection here flags an unsupported caller construction at the
1355/// wire boundary rather than silently dropping the value.
1356pub(crate) fn where_clause_to_proto(clause: WhereClause) -> Result<ProtoWhereClause, Error> {
1357    Ok(ProtoWhereClause {
1358        field: clause.field,
1359        operator: where_operator_to_proto(clause.operator) as i32,
1360        value: Some(value_to_proto(clause.value)?),
1361        // The typed IN_TIME_RANGE operand; never set on an ordinary value
1362        // clause (time-range selections are encoded by `encode_v1` itself,
1363        // from `time_range_clauses`).
1364        time_range: None,
1365        // Likewise the typed IN_INTEGER_RANGE operand.
1366        integer_range: None,
1367    })
1368}
1369
1370pub(crate) fn order_clause_to_proto(clause: OrderClause) -> ProtoOrderClause {
1371    // Drive's `OrderClause` carries a plain `field: String` —
1372    // emit the field-target variant of the wire's `target` oneof.
1373    // The aggregate-target variant (`ORDER BY COUNT(*)`) is
1374    // wire-only today; when drive's `OrderClause` gains an
1375    // aggregate target the SDK gets a parallel builder.
1376    ProtoOrderClause {
1377        target: Some(order_clause::Target::Field(clause.field)),
1378        ascending: clause.ascending,
1379    }
1380}
1381
1382/// Convert a drive [`HavingClause`] into its wire-format proto
1383/// counterpart. The inverse of `rs-drive-abci`'s
1384/// `having_clause_from_proto`. Errors only on `Value` variants
1385/// the underlying `value_to_proto` can't represent — every
1386/// `HavingOperator` / `HavingAggregateFunction` discriminant has a
1387/// 1:1 wire counterpart and is always convertible.
1388fn having_clause_to_proto(clause: HavingClause) -> Result<ProtoHavingClause, Error> {
1389    let right = match clause.right {
1390        HavingRightOperand::Value(v) => having_clause::Right::Value(value_to_proto(v)?),
1391    };
1392    Ok(ProtoHavingClause {
1393        aggregate: Some(having_aggregate_to_proto(clause.aggregate)),
1394        operator: having_operator_to_proto(clause.operator) as i32,
1395        right: Some(right),
1396    })
1397}
1398
1399fn having_aggregate_to_proto(aggregate: HavingAggregate) -> ProtoHavingAggregate {
1400    ProtoHavingAggregate {
1401        function: having_function_to_proto(aggregate.function) as i32,
1402        field: aggregate.field,
1403    }
1404}
1405
1406fn having_function_to_proto(function: HavingAggregateFunction) -> having_aggregate::Function {
1407    match function {
1408        HavingAggregateFunction::Count => having_aggregate::Function::Count,
1409        HavingAggregateFunction::Sum => having_aggregate::Function::Sum,
1410        HavingAggregateFunction::Avg => having_aggregate::Function::Avg,
1411    }
1412}
1413
1414/// Convert a drive [`SelectProjection`] into its wire-format
1415/// proto counterpart. Inverse of `rs-drive-abci`'s
1416/// `select_from_proto`. Always succeeds — every
1417/// `SelectFunction` discriminant has a 1:1 wire counterpart.
1418fn select_to_proto(select: SelectProjection) -> ProtoSelect {
1419    ProtoSelect {
1420        function: select_function_to_proto(select.function) as i32,
1421        field: select.field,
1422    }
1423}
1424
1425fn select_function_to_proto(function: SelectFunction) -> select::Function {
1426    match function {
1427        SelectFunction::Documents => select::Function::Documents,
1428        SelectFunction::Count => select::Function::Count,
1429        SelectFunction::Sum => select::Function::Sum,
1430        SelectFunction::Avg => select::Function::Avg,
1431        SelectFunction::Min => select::Function::Min,
1432        SelectFunction::Max => select::Function::Max,
1433    }
1434}
1435
1436fn having_operator_to_proto(op: HavingOperator) -> having_clause::Operator {
1437    match op {
1438        HavingOperator::Equal => having_clause::Operator::Equal,
1439        HavingOperator::NotEqual => having_clause::Operator::NotEqual,
1440        HavingOperator::GreaterThan => having_clause::Operator::GreaterThan,
1441        HavingOperator::GreaterThanOrEquals => having_clause::Operator::GreaterThanOrEquals,
1442        HavingOperator::LessThan => having_clause::Operator::LessThan,
1443        HavingOperator::LessThanOrEquals => having_clause::Operator::LessThanOrEquals,
1444        HavingOperator::Between => having_clause::Operator::Between,
1445        HavingOperator::BetweenExcludeBounds => having_clause::Operator::BetweenExcludeBounds,
1446        HavingOperator::BetweenExcludeLeft => having_clause::Operator::BetweenExcludeLeft,
1447        HavingOperator::BetweenExcludeRight => having_clause::Operator::BetweenExcludeRight,
1448        HavingOperator::In => having_clause::Operator::In,
1449    }
1450}
1451
1452fn where_operator_to_proto(op: WhereOperator) -> ProtoWhereOperator {
1453    match op {
1454        WhereOperator::Equal => ProtoWhereOperator::Equal,
1455        WhereOperator::GreaterThan => ProtoWhereOperator::GreaterThan,
1456        WhereOperator::GreaterThanOrEquals => ProtoWhereOperator::GreaterThanOrEquals,
1457        WhereOperator::LessThan => ProtoWhereOperator::LessThan,
1458        WhereOperator::LessThanOrEquals => ProtoWhereOperator::LessThanOrEquals,
1459        WhereOperator::Between => ProtoWhereOperator::Between,
1460        WhereOperator::BetweenExcludeBounds => ProtoWhereOperator::BetweenExcludeBounds,
1461        WhereOperator::BetweenExcludeLeft => ProtoWhereOperator::BetweenExcludeLeft,
1462        WhereOperator::BetweenExcludeRight => ProtoWhereOperator::BetweenExcludeRight,
1463        WhereOperator::In => ProtoWhereOperator::In,
1464        WhereOperator::StartsWith => ProtoWhereOperator::StartsWith,
1465    }
1466}
1467
1468/// Map `dpp::platform_value::Value` onto the wire-shape
1469/// [`ProtoDocumentFieldValue`]. The schema-driven decode on the
1470/// server side resolves the actual indexed type — this layer just
1471/// names the primitive.
1472///
1473/// Mapping rules:
1474/// - `Bool` → `BoolValue`
1475/// - `I8`/`I16`/`I32`/`I64` → `Int64Value` (widened)
1476/// - `U8`/`U16`/`U32`/`U64` → `Uint64Value` (widened)
1477/// - `Float` → `DoubleValue`
1478/// - `Text` → `Text`
1479/// - `Bytes`/`Bytes20`/`Bytes32`/`Bytes36`/`Identifier` → `BytesValue`
1480/// - `U128`/`I128` → `Text` (decimal string). **Not yet
1481///   round-trippable against `U128`/`I128`-typed indexed fields**:
1482///   the v1 typed-decode path (`v1/conversions.rs::value_from_proto`)
1483///   passes the text through as `Value::Text`, and the
1484///   downstream executor's strict `Value::to_integer()` then
1485///   rejects it. Schema-aware coercion (the
1486///   `DocumentPropertyType::value_from_string` path the v0 SQL
1487///   parser uses) hasn't been threaded through to the typed
1488///   path yet. The encoding is shipped because the proto needs a
1489///   home for 128-bit values; no production system contract
1490///   indexes `U128`/`I128` today. Tracked in the v1 follow-up
1491///   issue.
1492/// - `Array` → `List` (recursive, but only one level deep —
1493///   `value_to_proto` rejects nested arrays with
1494///   `EncodingError("nested DocumentFieldValue.list …")` to
1495///   match the server-side depth cap in
1496///   `v1/conversions.rs::value_from_proto_at_depth`, so wire-
1497///   malformed shapes fail at request-construction time with a
1498///   deterministic local error rather than after a transport
1499///   round-trip.
1500/// - `Null` → `NullValue(true)` (the `bool` payload is a
1501///   placeholder per the proto-side comment; only the variant
1502///   discriminant carries meaning)
1503/// - `Map`/`EnumU8`/`EnumString` → `Error` (no wire-format
1504///   counterpart for these shapes in a WhereClause operand)
1505fn value_to_proto(value: Value) -> Result<ProtoDocumentFieldValue, Error> {
1506    value_to_proto_at_depth(value, 0)
1507}
1508
1509/// Recursion-bounded form of [`value_to_proto`]. Mirrors the
1510/// server-side `value_from_proto_at_depth` contract so encoder
1511/// and decoder agree on the supported `Value` subset: `depth = 0`
1512/// is the clause-level operand; `Array` is legal once (the flat
1513/// list of scalars for `IN` / `BETWEEN*`); any deeper nesting
1514/// rejects locally instead of producing a request the server
1515/// would round-trip just to reject.
1516fn value_to_proto_at_depth(value: Value, depth: u8) -> Result<ProtoDocumentFieldValue, Error> {
1517    let variant = match value {
1518        Value::Null => document_field_value::Variant::NullValue(true),
1519        Value::Bool(b) => document_field_value::Variant::BoolValue(b),
1520        Value::I8(i) => document_field_value::Variant::Int64Value(i as i64),
1521        Value::I16(i) => document_field_value::Variant::Int64Value(i as i64),
1522        Value::I32(i) => document_field_value::Variant::Int64Value(i as i64),
1523        Value::I64(i) => document_field_value::Variant::Int64Value(i),
1524        Value::U8(u) => document_field_value::Variant::Uint64Value(u as u64),
1525        Value::U16(u) => document_field_value::Variant::Uint64Value(u as u64),
1526        Value::U32(u) => document_field_value::Variant::Uint64Value(u as u64),
1527        Value::U64(u) => document_field_value::Variant::Uint64Value(u),
1528        Value::Float(f) => document_field_value::Variant::DoubleValue(f),
1529        Value::Text(s) => document_field_value::Variant::Text(s),
1530        Value::Bytes(b) => document_field_value::Variant::BytesValue(b),
1531        Value::Bytes20(b) => document_field_value::Variant::BytesValue(b.to_vec()),
1532        Value::Bytes32(b) => document_field_value::Variant::BytesValue(b.to_vec()),
1533        Value::Bytes36(b) => document_field_value::Variant::BytesValue(b.to_vec()),
1534        Value::Identifier(b) => document_field_value::Variant::BytesValue(b.to_vec()),
1535        // u128 / i128 don't fit in `int64_value`/`uint64_value`;
1536        // encode as a decimal string. See the function-level
1537        // docstring for the U128/I128 round-trip caveat.
1538        Value::U128(u) => document_field_value::Variant::Text(u.to_string()),
1539        Value::I128(i) => document_field_value::Variant::Text(i.to_string()),
1540        Value::Array(items) => {
1541            if depth >= 1 {
1542                return Err(Error::Protocol(dpp::ProtocolError::EncodingError(
1543                    "nested DocumentFieldValue.list is not supported on the v1 \
1544                     query surface; `IN` / `BETWEEN*` candidate lists are flat \
1545                     scalars only"
1546                        .to_string(),
1547                )));
1548            }
1549            document_field_value::Variant::List(document_field_value::ValueList {
1550                values: items
1551                    .into_iter()
1552                    .map(|v| value_to_proto_at_depth(v, depth + 1))
1553                    .collect::<Result<Vec<_>, _>>()?,
1554            })
1555        }
1556        // Catches both `Value::Map(_)` / `Value::EnumU8(_)` /
1557        // `Value::EnumString(_)` (no wire-format counterpart for
1558        // these shapes in a WhereClause operand) and any
1559        // future-added variant — `dpp::platform_value::Value` is
1560        // `#[non_exhaustive]`, so the SDK fails loudly rather
1561        // than silently dropping data the moment upstream adds a
1562        // variant we don't yet know how to encode.
1563        _ => {
1564            return Err(Error::Protocol(dpp::ProtocolError::EncodingError(format!(
1565                "Value variant has no `DocumentFieldValue` wire-format counterpart: {value:?}"
1566            ))));
1567        }
1568    };
1569    Ok(ProtoDocumentFieldValue {
1570        variant: Some(variant),
1571    })
1572}
1573
1574#[cfg(test)]
1575mod encode_version_gate_tests {
1576    //! The `IN_TIME_RANGE` operator is emitted only for protocol versions
1577    //! whose contract grammar hosts `timeRange` indexes (document
1578    //! meta-schema generation 3, first pinned by protocol version 14).
1579    //! Protocol versions 12 and 13 serve the same v1 getDocuments wire but
1580    //! predate the grammar — encoding for them must refuse up front
1581    //! instead of sending an operator the server rejects as an unknown
1582    //! discriminant.
1583
1584    use super::*;
1585    use dpp::data_contract::DataContractFactory;
1586    use dpp::platform_value::platform_value;
1587    use dpp::prelude::{DataContract, Identifier};
1588    use drive::query::TimeRangeSelector;
1589
1590    fn post_contract() -> Arc<DataContract> {
1591        let schemas = platform_value!({
1592            "post": {
1593                "type": "object",
1594                "properties": {
1595                    "hashtag": { "type": "string", "maxLength": 63, "position": 0 },
1596                },
1597                "required": ["hashtag"],
1598                "additionalProperties": false,
1599            }
1600        });
1601        let contract = DataContractFactory::new(PlatformVersion::latest().protocol_version)
1602            .expect("expected a factory")
1603            .create_with_value_config(Identifier::new([7u8; 32]), 0, schemas, None, None)
1604            .expect("the post contract is well-formed")
1605            .data_contract_owned();
1606        Arc::new(contract)
1607    }
1608
1609    fn newest_time_range_query() -> DocumentQuery {
1610        DocumentQuery::new(post_contract(), "post")
1611            .expect("the fixture has this document type")
1612            .with_time_range("$createdAt", TimeRangeSelector::Newest)
1613    }
1614
1615    #[test]
1616    fn a_time_range_query_refuses_to_encode_for_protocol_version_13() {
1617        let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists");
1618        let error = GetDocumentsRequest::try_from_platform_versioned(
1619            newest_time_range_query(),
1620            platform_version,
1621        )
1622        .expect_err("protocol version 13's contract grammar has no timeRange indexes");
1623        let message = match error {
1624            Error::Config(message) => message,
1625            other => panic!("expected Error::Config, got {other:?}"),
1626        };
1627        assert!(
1628            message.contains("protocol version 14"),
1629            "the refusal must name the real version floor, got: {message}"
1630        );
1631    }
1632
1633    #[test]
1634    fn a_time_range_query_encodes_the_typed_operand_for_protocol_version_14() {
1635        let platform_version = PlatformVersion::get(14).expect("protocol version 14 exists");
1636        let request = GetDocumentsRequest::try_from_platform_versioned(
1637            newest_time_range_query(),
1638            platform_version,
1639        )
1640        .expect("protocol version 14 hosts timeRange indexes");
1641        let Some(V1(v1)) = request.version else {
1642            panic!("protocol version 14 encodes on the v1 wire");
1643        };
1644        let [clause] = v1.where_clauses.as_slice() else {
1645            panic!("the pending selector must ride as exactly one IN_TIME_RANGE clause");
1646        };
1647        assert_eq!(clause.operator, ProtoWhereOperator::InTimeRange as i32);
1648        assert_eq!(
1649            clause.value, None,
1650            "the selection is not a field value; the generic operand stays unset"
1651        );
1652        let selection = clause
1653            .time_range
1654            .as_ref()
1655            .expect("the typed operand carries the selection");
1656        assert_eq!(selection.selector, ProtoTimeRangeSelector::Newest as i32);
1657        assert_eq!(selection.start_ms, None);
1658        assert_eq!(selection.grid, None, "a grid-less selection names no grid");
1659    }
1660
1661    #[test]
1662    fn a_by_start_selection_encodes_its_window_start_and_grid() {
1663        let platform_version = PlatformVersion::get(14).expect("protocol version 14 exists");
1664        let query = DocumentQuery::new(post_contract(), "post")
1665            .expect("the fixture has this document type")
1666            .with_time_range_grid(
1667                "$createdAt",
1668                TimeRangeSelector::ByStart {
1669                    start_ms: 1_756_684_800_000,
1670                },
1671                TimeRangeGridSpec {
1672                    range_seconds: 86_400,
1673                    step_seconds: 86_400,
1674                    phase_seconds: 0,
1675                },
1676            );
1677        let request = GetDocumentsRequest::try_from_platform_versioned(query, platform_version)
1678            .expect("protocol version 14 hosts timeRange indexes");
1679        let Some(V1(v1)) = request.version else {
1680            panic!("protocol version 14 encodes on the v1 wire");
1681        };
1682        let [clause] = v1.where_clauses.as_slice() else {
1683            panic!("the selection must ride as exactly one IN_TIME_RANGE clause");
1684        };
1685        let selection = clause
1686            .time_range
1687            .as_ref()
1688            .expect("the typed operand carries the selection");
1689        assert_eq!(selection.selector, ProtoTimeRangeSelector::ByStart as i32);
1690        assert_eq!(
1691            selection.start_ms,
1692            Some(1_756_684_800_000),
1693            "the absolute window start rides in the query itself"
1694        );
1695        assert_eq!(
1696            selection.grid,
1697            Some(ProtoTimeRangeGrid {
1698                range: 86_400,
1699                step: 86_400,
1700                phase: 0,
1701            }),
1702            "the grid repeats the contract's declared seconds verbatim"
1703        );
1704    }
1705
1706    #[test]
1707    fn a_query_without_time_range_clauses_still_encodes_for_protocol_version_13() {
1708        let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists");
1709        let query = DocumentQuery::new(post_contract(), "post")
1710            .expect("the fixture has this document type");
1711        GetDocumentsRequest::try_from_platform_versioned(query, platform_version)
1712            .expect("the gate only refuses queries that carry a time-range selection");
1713    }
1714}