Skip to content

feat: add time-range indexes for trending/leaderboard queries#3740

Open
QuantumExplorer wants to merge 1 commit into
v3.1-devfrom
time-range-indexes
Open

feat: add time-range indexes for trending/leaderboard queries#3740
QuantumExplorer wants to merge 1 commit into
v3.1-devfrom
time-range-indexes

Conversation

@QuantumExplorer
Copy link
Copy Markdown
Member

@QuantumExplorer QuantumExplorer commented May 25, 2026

Issue being fixed or feature implemented

Adds time-range indexes — the foundation for "trending"/leaderboard queries (e.g. trending hashtags). A contract can declare an index whose first property is a timestamp bucketed into fixed-length, regularly-spaced, optionally overlapping ranges. A document is indexed under every range whose window contains its timestamp, so ORDER BY COUNT(*) within the most-recent range becomes a provable, indexed query.

SELECT COUNT(*) FROM posts
WHERE $createdAt IN_TIME_RANGE "oldest"
GROUP BY hashtag ORDER BY count DESC

Index definition (JSON):

{
  "name": "trending",
  "properties": [{ "$createdAt": "asc" }, { "hashtag": "asc" }],
  "timeRange": { "on": "$createdAt", "range": 21600000, "step": 7200000 },
  "countable": true
}

range=window length, step=interval between range starts. With range > step the ranges overlap (here 6h windows every 2h → 3 buckets/doc), so there is always an active range covering a near-full window.

What was done?

  • rs-dpp: TimeRangeTransform on Index/IndexLevel; timeRange:{on,range,step,origin?} parsing + document meta-schema entry; validation (range % step == 0, overlap factor ≤ 256, source must be the first index property and a millisecond timestamp $createdAt/$updatedAt/$transferredAt or a Date property, non-unique + non-contested, cross-index config consistency); protocol v12 gate; update-immutability; bucket math (containing_buckets, newest_active_start, oldest_active_start).
  • rs-drive (storage): insert/delete/update index fan-out — one document produces one index entry per overlapping bucket; updates diff the old vs new bucket sets.
  • rs-drive (query): resolve_time_range_bucket_clause(...) desugars a time-range selection into a concrete bucket-equality clause, so existing index/count proofs apply unchanged.
  • dapi-grpc: new IN_TIME_RANGE operator on the v1 WhereOperator (operand "newest"/"oldest"). The v0 query wire is untouched.
  • drive-abci (v1): resolves IN_TIME_RANGE clauses from the authoritative committed block time before routing (documents + count/sum/avg).
  • rs-sdk / wasm-sdk / js-evo-sdk: query.with_time_range(field, selector) (Rust) and timeRange: [{ field, selector }] (JS/WASM) builders; the proof verifier re-derives the identical bucket from the quorum-signed response metadata time, so light-client proofs verify deterministically.

Note: platform.proto changed (added IN_TIME_RANGE). The dapi-grpc Rust client regenerates at build time; the checked-in Obj-C / JS clients should be regenerated from the updated proto via the repo's codegen scripts as a mechanical follow-up.

How Has This Been Tested?

  • rs-dpp — 11 unit tests: bucket math (overlap, containing buckets, newest/oldest active, origin offset) + timeRange parsing/validation (rejects non-multiple range, overlap > cap, non-first source, zero step).
  • rs-drive — end-to-end integration test: inserting a document with a (timeRange($createdAt, 6h/2h), hashtag) index writes it under all 3 overlapping buckets (queryable by exact bucket start, not under the raw timestamp), and deletion removes every bucket entry.
  • cargo fmt applied; cargo clippy clean on the touched crates; dpp / drive / drive-abci / dash-sdk / wasm-sdk all compile.
  • Not yet covered here: a live-proof e2e of the IN_TIME_RANGE query path (server resolve → verifier re-derive) needs a running node; the resolution logic is shared/deterministic and the underlying proofs are the standard index/count proofs.

Breaking Changes

None. The feature is gated to the unreleased protocol v12, and the v0 query wire is unchanged — hence feat: rather than feat!:.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added time-range filtering for document queries, enabling efficient bucketing and selection of documents based on configurable time ranges using "newest" or "oldest" options.
    • Time-range indexes now optimize timestamp-based queries for documents with date properties.
    • Available for protocol version 12 and later.

Review Change Stack

Introduces a `timeRange` index transform that buckets a timestamp index
property (e.g. $createdAt) into fixed-length, regularly-spaced, optionally
overlapping ranges. A document is indexed under every range whose window
contains its timestamp, enabling provable "trending"/leaderboard queries
(ORDER BY COUNT(*) within the most-recent range).

- rs-dpp: TimeRangeTransform on Index/IndexLevel, JSON parsing + meta-schema,
  validation (range % step, overlap cap, first-property, timestamp source,
  non-unique/non-contested), protocol v12 gate, update-immutability, bucket math
- rs-drive: insert/delete/update index fan-out (one document -> N overlapping
  bucket entries); time-range query resolution to a concrete bucket equality
- dapi-grpc: new v1 IN_TIME_RANGE where operator (v0 wire unchanged)
- drive-abci: v1 handler resolves IN_TIME_RANGE from authoritative block time
- rs-sdk/wasm-sdk: with_time_range / timeRange query builders; proof verifier
  re-derives the bucket from the quorum-signed response metadata time

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer requested a review from shumkov as a code owner May 25, 2026 17:49
@github-actions github-actions Bot added this to the v3.1.0 milestone May 25, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

📝 Walkthrough

Walkthrough

This PR introduces time-range index bucketing for Dash Platform v1 document queries. It adds a TimeRangeTransform data structure that buckets fixed-width timestamp windows into regularly-spaced overlapping ranges, extends the data contract schema and Rust index models to support optional time-range configuration, implements document insert/update/delete logic that fans out documents across multiple overlapping bucket keys, and adds query resolution to convert client newest/oldest selectors into concrete bucket-equality clauses using the committed block time.

Changes

Time-Range Index Bucketing for Document Queries

Layer / File(s) Summary
Schema, Data Types, and TimeRangeTransform
packages/dapi-grpc/protos/platform/v0/platform.proto, packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json, packages/rs-dpp/src/data_contract/document_type/index/time_range.rs, packages/rs-dpp/src/data_contract/document_type/index/mod.rs, packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs
Adds IN_TIME_RANGE wire operator and timeRange schema config. Defines TimeRangeTransform struct with bucket math methods (overlap_factor, most_recent_start, containing_buckets, newest_active_start, oldest_active_start). Extends Index and IndexLevel with optional time_range field.
Index Parsing and Validation
packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs, packages/rs-dpp/src/data_contract/document_type/index/mod.rs, packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs, packages/rs-dpp/src/data_contract/document_type/index_level/find_first_change.rs
Parses timeRange maps into TimeRangeTransform, validates structural constraints (step/range non-zero, exact multiples, overlap bounds, first-property requirement, no time-range on unique/contested indexes). Validates property types and source presence. Enforces protocol v12 minimum and cross-index time-range consistency. Detects time-range changes in contract updates.
Document Indexing: Insert, Update, and Delete
packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v1/mod.rs, packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v0/mod.rs, packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v1/mod.rs, packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs
Expands timestamp values into multiple bucket-start keys during insert; deletes from all affected buckets during delete; computes old vs new bucket sets on update and performs bucket-level set diffs. Includes e2e test verifying overlap semantics and cleanup. Test fixtures updated to initialize time_range: None.
Query Resolution
packages/rs-drive/src/query/mod.rs, packages/rs-drive-abci/src/query/document_query/v1/conversions.rs, packages/rs-drive-abci/src/query/document_query/v1/mod.rs
Introduces TimeRangeSelector enum and resolve_time_range_bucket_clause function to locate time-range transforms and derive bucket starts from block time. Partitions incoming IN_TIME_RANGE clauses, decodes selector text, resolves each to a concrete equality clause, and appends to final query.
SDK Client-Side Integration
packages/rs-sdk/src/platform/documents/document_query.rs, packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs, packages/rs-sdk/src/platform/dpns_usernames/mod.rs, packages/rs-sdk/src/platform/dpns_usernames/queries.rs, packages/wasm-sdk/src/queries/document.rs, packages/wasm-sdk/src/dpns.rs
Adds time_range_clauses field to DocumentQuery and with_time_range builder method. V0 wire encoding rejects non-empty time-range clauses; V1 encodes each as IN_TIME_RANGE where clause with newest/oldest text operand. Proof decoder resolves clauses using response metadata time. WASM and application query builders initialize and propagate time-range clauses.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • dashpay/platform#3654: Adds the initial typed v1 GetDocumentsRequest where-clause structure and WhereOperator enum that this PR extends with IN_TIME_RANGE.
  • dashpay/platform#3711: Refactors the v0/v1 wire-shape dispatch and SDK request encoding that this PR builds on for version-aware time_range_clauses handling.
  • dashpay/platform#3633: Updates the v1 query_documents_v1 handler's select/group_by/having validation path that this PR extends with time-range clause partitioning and resolution.

Suggested labels

ready for final review

Suggested reviewers

  • shumkov

Poem

🐰 Time bucketing hops through overlapping ranges,
Each timestamp fans to many bucket starts,
Query selectors newest, oldest ever stranger,
Block time resolves each temporal heart,
Proofs dance through windows—math that's true! 📊

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main feature addition: time-range indexes for trending and leaderboard queries, which is the primary focus of this comprehensive change across multiple packages.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch time-range-indexes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 25, 2026

Codecov Report

❌ Patch coverage is 86.68122% with 61 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.15%. Comparing base (31e8af2) to head (7425185).

Files with missing lines Patch % Lines
...ument_type/class_methods/try_from_schema/v1/mod.rs 79.48% 16 Missing ⚠️
...s-dpp/src/data_contract/document_type/index/mod.rs 90.68% 15 Missing ⚠️
...s/rs-drive-abci/src/query/document_query/v1/mod.rs 77.35% 12 Missing ⚠️
...ve-abci/src/query/document_query/v1/conversions.rs 65.62% 11 Missing ⚠️
.../update_document_for_contract_operations/v0/mod.rs 84.21% 3 Missing ⚠️
..._top_index_level_for_contract_operations/v1/mod.rs 92.30% 2 Missing ⚠️
...src/data_contract/document_type/index_level/mod.rs 97.43% 1 Missing ⚠️
..._top_index_level_for_contract_operations/v1/mod.rs 95.23% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           v3.1-dev    #3740      +/-   ##
============================================
- Coverage     87.16%   87.15%   -0.01%     
============================================
  Files          2607     2606       -1     
  Lines        319420   319221     -199     
============================================
- Hits         278413   278216     -197     
+ Misses        41007    41005       -2     
Components Coverage Δ
dpp 87.67% <88.48%> (ø)
drive 85.95% <93.68%> (ø)
drive-abci 89.60% <72.94%> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.17% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.16% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-dpp/src/data_contract/document_type/index/time_range.rs`:
- Around line 85-97: The method containing_buckets currently uses
most_recent_start which saturates to origin_ms, causing t < origin_ms to
incorrectly return origin_ms; to fix, add an early check at the start of
containing_buckets: if t < self.origin_ms return an empty Vec, then proceed as
before (using overlap_factor, most_recent_start, step_ms) so only timestamps >=
origin_ms are considered for bucket computation; reference functions/fields:
containing_buckets, most_recent_start, origin_ms, overlap_factor, step_ms,
range_ms.

In `@packages/rs-drive-abci/src/query/document_query/v1/mod.rs`:
- Around line 468-536: The test helper validate_and_route_for_tests() no longer
mirrors query_documents_v1() because it decodes proto where-clauses without
stripping/resolving IN_TIME_RANGE clauses; update validate_and_route_for_tests()
to partition proto_where_clauses with conversions::is_time_range_clause, decode
normal_proto via conversions::where_clauses_from_proto into where_clauses, and
then handle time_range_proto the same way as query_documents_v1(): obtain
block_time_ms from platform_state.last_committed_block_time_ms(), resolve
contract_id and contract_fetch_info, get doc_type, and for each proto_wc call
conversions::time_range_clause_from_proto and
drive::query::resolve_time_range_bucket_clause to push resolved clauses into
where_clauses (or extract this shared logic into a helper used by both
query_documents_v1() and validate_and_route_for_tests()).

In
`@packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v0/mod.rs`:
- Around line 775-806: The current encode_buckets closure maps None to an empty
Vec, losing the single empty-index-key convention used by insert/delete paths;
change the logic so that when the raw decoded timestamp is None (or the raw
encoded bytes represent an empty key) encode_buckets returns a Vec containing
one empty Vec (i.e., vec![vec![]]) so old_buckets/new_buckets preserve the
empty-key; update the use sites around get_raw_for_document_type,
DocumentPropertyType::decode_date_timestamp, transform.containing_buckets and
DocumentPropertyType::encode_date_timestamp accordingly and add a regression
test covering null ↔ non-null updates to ensure delete/insert of the empty-key
behaves correctly.

In `@packages/rs-drive/src/query/mod.rs`:
- Around line 539-571: resolve_time_range_bucket_clause currently returns only a
synthetic WhereClause (field == bucket_start) which loses the fact that this
came from a time-range index; change it to return the matched index/transform as
well (e.g. return a tuple or new struct like (WhereClause, IndexRef) or
(WhereClause, TimeRangeTransform)) so downstream index pickers can see the
original time-range index and avoid choosing a non-time-range index;
specifically, in resolve_time_range_bucket_clause locate the matched
index/transform (the variable transform found via
DocumentTypeRef::indexes().values().find_map), include that transform or the
index identifier in the function return value, and update all callers to accept
and thread that hint into the query planner so index selection uses the provided
time-range index rather than falling back to coverage-based selection.

In `@packages/rs-sdk/src/platform/documents/document_query.rs`:
- Around line 77-84: The conversion impl TryFrom<&DocumentQuery> for
DriveDocumentQuery currently ignores DocumentQuery::time_range_clauses; update
the impl(s) that build DriveDocumentQuery from a DocumentQuery (the
TryFrom<&DocumentQuery> for DriveDocumentQuery and the analogous conversion used
elsewhere) to detect non-empty time_range_clauses and return an Err immediately
instead of silently dropping them. Specifically, check
DocumentQuery::time_range_clauses at the start of the conversion, and if not
empty return a clear error (e.g., UnsupportedTimeRangeInDriveQuery) referencing
that the caller must use Self::with_time_range or a block-time-aware conversion
path; do this for every conversion path that currently only uses where_clauses
so the time-range filters are not lost.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0585dcb0-39b1-41af-af09-9ff3e81814c8

📥 Commits

Reviewing files that changed from the base of the PR and between 31e8af2 and 7425185.

📒 Files selected for processing (23)
  • packages/dapi-grpc/protos/platform/v0/platform.proto
  • packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json
  • packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs
  • packages/rs-dpp/src/data_contract/document_type/index/mod.rs
  • packages/rs-dpp/src/data_contract/document_type/index/random_index.rs
  • packages/rs-dpp/src/data_contract/document_type/index/time_range.rs
  • packages/rs-dpp/src/data_contract/document_type/index_level/find_first_change.rs
  • packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs
  • packages/rs-drive-abci/src/query/document_query/v1/conversions.rs
  • packages/rs-drive-abci/src/query/document_query/v1/mod.rs
  • packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v1/mod.rs
  • packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs
  • packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v1/mod.rs
  • packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v0/mod.rs
  • packages/rs-drive/src/query/drive_document_count_query/tests.rs
  • packages/rs-drive/src/query/drive_document_sum_query/tests.rs
  • packages/rs-drive/src/query/mod.rs
  • packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs
  • packages/rs-sdk/src/platform/documents/document_query.rs
  • packages/rs-sdk/src/platform/dpns_usernames/mod.rs
  • packages/rs-sdk/src/platform/dpns_usernames/queries.rs
  • packages/wasm-sdk/src/dpns.rs
  • packages/wasm-sdk/src/queries/document.rs

Comment on lines +85 to +97
pub fn containing_buckets(&self, t: u64) -> Vec<u64> {
let overlap = self.overlap_factor();
if overlap == 0 {
return Vec::new();
}
let newest = self.most_recent_start(t);
(0..overlap)
.filter_map(|j| {
let offset = j.checked_mul(self.step_ms)?;
newest.checked_sub(offset)
})
.filter(|start| *start >= self.origin_ms)
.collect()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return no buckets before origin_ms.

For t < origin_ms, most_recent_start() saturates to origin_ms, so this method returns [origin_ms] even though the interval [origin_ms, origin_ms + range_ms) does not contain t. A contract with a positive origin will therefore index pre-origin documents into a bucket they do not belong to.

Proposed fix
 pub fn containing_buckets(&self, t: u64) -> Vec<u64> {
+    if t < self.origin_ms {
+        return Vec::new();
+    }
     let overlap = self.overlap_factor();
     if overlap == 0 {
         return Vec::new();
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn containing_buckets(&self, t: u64) -> Vec<u64> {
let overlap = self.overlap_factor();
if overlap == 0 {
return Vec::new();
}
let newest = self.most_recent_start(t);
(0..overlap)
.filter_map(|j| {
let offset = j.checked_mul(self.step_ms)?;
newest.checked_sub(offset)
})
.filter(|start| *start >= self.origin_ms)
.collect()
pub fn containing_buckets(&self, t: u64) -> Vec<u64> {
if t < self.origin_ms {
return Vec::new();
}
let overlap = self.overlap_factor();
if overlap == 0 {
return Vec::new();
}
let newest = self.most_recent_start(t);
(0..overlap)
.filter_map(|j| {
let offset = j.checked_mul(self.step_ms)?;
newest.checked_sub(offset)
})
.filter(|start| *start >= self.origin_ms)
.collect()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-dpp/src/data_contract/document_type/index/time_range.rs` around
lines 85 - 97, The method containing_buckets currently uses most_recent_start
which saturates to origin_ms, causing t < origin_ms to incorrectly return
origin_ms; to fix, add an early check at the start of containing_buckets: if t <
self.origin_ms return an empty Vec, then proceed as before (using
overlap_factor, most_recent_start, step_ms) so only timestamps >= origin_ms are
considered for bucket computation; reference functions/fields:
containing_buckets, most_recent_start, origin_ms, overlap_factor, step_ms,
range_ms.

Comment on lines +468 to +536
// Partition out time-range (IN_TIME_RANGE) clauses. They are resolved
// to concrete equality clauses on the bucketed source field using the
// authoritative committed block time, so the rest of the v1 pipeline
// (routing, executors, proofs) treats them as ordinary equality
// lookups. The verifier re-derives the same bucket from the
// quorum-signed response metadata time, so the proof matches.
let (time_range_proto, normal_proto): (Vec<_>, Vec<_>) = proto_where_clauses
.into_iter()
.partition(conversions::is_time_range_clause);

let mut where_clauses = match conversions::where_clauses_from_proto(normal_proto) {
Ok(c) => c,
Err(e) => return Ok(QueryValidationResult::new_with_error(e)),
};

if !time_range_proto.is_empty() {
let block_time_ms =
match platform_state.last_committed_block_time_ms() {
Some(t) => t,
None => return Ok(QueryValidationResult::new_with_error(QueryError::Query(
QuerySyntaxError::Unsupported(
"a time range (IN_TIME_RANGE) query requires a committed block time"
.to_string(),
),
))),
};
let contract_id: Identifier = check_validation_result_with_data!(data_contract_id
.clone()
.try_into()
.map_err(|_| QueryError::InvalidArgument(
"id must be a valid identifier (32 bytes long)".to_string()
)));
let (_, contract_fetch_info) = self.drive.get_contract_with_fetch_info_and_fee(
contract_id.to_buffer(),
None,
true,
None,
platform_version,
)?;
let contract_fetch_info = check_validation_result_with_data!(contract_fetch_info
.ok_or(QueryError::Query(QuerySyntaxError::DataContractNotFound(
"contract not found when resolving a time range query",
))));
let contract_ref = &contract_fetch_info.contract;
let doc_type = check_validation_result_with_data!(contract_ref
.document_type_for_name(document_type.as_str())
.map_err(|_| QueryError::InvalidArgument(format!(
"document type {} not found for contract {}",
document_type, contract_id
))));
for proto_wc in time_range_proto {
let (field, selector) = match conversions::time_range_clause_from_proto(proto_wc) {
Ok(parsed) => parsed,
Err(e) => return Ok(QueryValidationResult::new_with_error(e)),
};
match drive::query::resolve_time_range_bucket_clause(
&field,
selector,
doc_type,
block_time_ms,
) {
Ok(resolved) => where_clauses.push(resolved),
Err(drive::error::Error::Query(qe)) => {
return Ok(QueryValidationResult::new_with_error(QueryError::Query(qe)))
}
Err(e) => return Err(e.into()),
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep the test-only routing mirror aligned with the new IN_TIME_RANGE flow.

query_documents_v1() now strips and resolves IN_TIME_RANGE clauses before normal where-clause decoding, but validate_and_route_for_tests() still decodes the full proto where-clause list directly. Any test request that includes a time-range clause will now fail in the helper with InvalidArgument even though the real handler accepts and resolves it, so the “same gate ordering” contract in this file is no longer true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-drive-abci/src/query/document_query/v1/mod.rs` around lines 468 -
536, The test helper validate_and_route_for_tests() no longer mirrors
query_documents_v1() because it decodes proto where-clauses without
stripping/resolving IN_TIME_RANGE clauses; update validate_and_route_for_tests()
to partition proto_where_clauses with conversions::is_time_range_clause, decode
normal_proto via conversions::where_clauses_from_proto into where_clauses, and
then handle time_range_proto the same way as query_documents_v1(): obtain
block_time_ms from platform_state.last_committed_block_time_ms(), resolve
contract_id and contract_fetch_info, get doc_type, and for each proto_wc call
conversions::time_range_clause_from_proto and
drive::query::resolve_time_range_bucket_clause to push resolved clauses into
where_clauses (or extract this shared logic into a helper used by both
query_documents_v1() and validate_and_route_for_tests()).

Comment on lines +775 to +806
let new_ts = document
.get_raw_for_document_type(
&transform.source,
document_type,
owner_id,
platform_version,
)?
.and_then(|bytes| DocumentPropertyType::decode_date_timestamp(&bytes));
let old_ts = match old_document_info.get_raw_for_document_type(
&transform.source,
document_type,
None,
None,
platform_version,
)? {
Some(DriveKeyInfo::Key(k)) => DocumentPropertyType::decode_date_timestamp(&k),
Some(DriveKeyInfo::KeyRef(k)) => DocumentPropertyType::decode_date_timestamp(k),
_ => None,
};

let encode_buckets = |ts: Option<u64>| -> Vec<Vec<u8>> {
ts.map(|t| {
transform
.containing_buckets(t)
.into_iter()
.map(DocumentPropertyType::encode_date_timestamp)
.collect()
})
.unwrap_or_default()
};
let new_buckets = encode_buckets(new_ts);
let old_buckets = encode_buckets(old_ts);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the empty-key case when diffing time-range buckets.

Insert/delete treat a missing time-range source as a single empty index key, but this helper turns None into []. That means an optional Date/$updatedAt moving null → timestamp never deletes the old empty-key entry, and timestamp → null never inserts it back. Please derive the old/new key sets from the raw encoded value using the same empty-value convention as the insert/delete paths, and add a regression for null ↔ non-null updates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v0/mod.rs`
around lines 775 - 806, The current encode_buckets closure maps None to an empty
Vec, losing the single empty-index-key convention used by insert/delete paths;
change the logic so that when the raw decoded timestamp is None (or the raw
encoded bytes represent an empty key) encode_buckets returns a Vec containing
one empty Vec (i.e., vec![vec![]]) so old_buckets/new_buckets preserve the
empty-key; update the use sites around get_raw_for_document_type,
DocumentPropertyType::decode_date_timestamp, transform.containing_buckets and
DocumentPropertyType::encode_date_timestamp accordingly and add a regression
test covering null ↔ non-null updates to ensure delete/insert of the empty-key
behaves correctly.

Comment on lines +539 to +571
pub fn resolve_time_range_bucket_clause(
field: &str,
selector: TimeRangeSelector,
document_type: DocumentTypeRef,
block_time_ms: u64,
) -> Result<WhereClause, Error> {
let transform = document_type
.indexes()
.values()
.find_map(|index| {
index
.time_range
.as_ref()
.filter(|transform| transform.source == field)
})
.ok_or(Error::Query(
QuerySyntaxError::WhereClauseOnNonIndexedProperty(format!(
"no time-range index is defined on field \"{}\"",
field
)),
))?;

let bucket_start = match selector {
TimeRangeSelector::Newest => transform.newest_active_start(block_time_ms),
TimeRangeSelector::Oldest => transform.oldest_active_start(block_time_ms),
};

Ok(WhereClause {
field: field.to_string(),
operator: WhereOperator::Equal,
value: Value::U64(bucket_start),
})
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preserve the matched time-range index, not just the bucketed clause.

This helper collapses the resolution down to field == bucket_start, but later planning in this module still chooses an index by field coverage alone. That means a contract with both a normal index and a time-range index on the same first property can desugar into an ordinary equality query and hit the wrong tree, returning incorrect results/proofs. Please carry the matched index (or an equivalent hard hint like index name/transform) alongside the synthetic clause so downstream pickers cannot fall back to a non-time-range index.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-drive/src/query/mod.rs` around lines 539 - 571,
resolve_time_range_bucket_clause currently returns only a synthetic WhereClause
(field == bucket_start) which loses the fact that this came from a time-range
index; change it to return the matched index/transform as well (e.g. return a
tuple or new struct like (WhereClause, IndexRef) or (WhereClause,
TimeRangeTransform)) so downstream index pickers can see the original time-range
index and avoid choosing a non-time-range index; specifically, in
resolve_time_range_bucket_clause locate the matched index/transform (the
variable transform found via DocumentTypeRef::indexes().values().find_map),
include that transform or the index identifier in the function return value, and
update all callers to accept and thread that hint into the query planner so
index selection uses the provided time-range index rather than falling back to
coverage-based selection.

Comment on lines +77 to +84
/// Time-range (`IN_TIME_RANGE`) selections — `(field, selector)` pairs on
/// a timestamp field covered by a `timeRange` index. These are emitted as
/// `IN_TIME_RANGE` clauses on the v1 wire and resolved server-side from
/// the current block time; the verifier re-derives the same bucket from
/// the quorum-signed response metadata time. v1-only (the v0 wire has no
/// `IN_TIME_RANGE` operator). See [`Self::with_time_range`].
#[cfg_attr(feature = "mocks", serde(default))]
pub time_range_clauses: Vec<(String, TimeRangeSelector)>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't let DriveDocumentQuery conversion drop time-range filters.

DocumentQuery now exposes time_range_clauses, but TryFrom<&DocumentQuery> for DriveDocumentQuery still builds from where_clauses only. Any internal caller that converts directly to a drive query will silently run the broader unbucketed query instead of the requested time-range query. Please make that conversion fail fast on non-empty time_range_clauses (or add a block-time-aware conversion path) so the filter can't be lost.

Also applies to: 196-203

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-sdk/src/platform/documents/document_query.rs` around lines 77 -
84, The conversion impl TryFrom<&DocumentQuery> for DriveDocumentQuery currently
ignores DocumentQuery::time_range_clauses; update the impl(s) that build
DriveDocumentQuery from a DocumentQuery (the TryFrom<&DocumentQuery> for
DriveDocumentQuery and the analogous conversion used elsewhere) to detect
non-empty time_range_clauses and return an Err immediately instead of silently
dropping them. Specifically, check DocumentQuery::time_range_clauses at the
start of the conversion, and if not empty return a clear error (e.g.,
UnsupportedTimeRangeInDriveQuery) referencing that the caller must use
Self::with_time_range or a block-time-aware conversion path; do this for every
conversion path that currently only uses where_clauses so the time-range filters
are not lost.

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

I verified the reported issues against the checked-out SHA and confirmed four blocking problems. The time-range indexing and proof-verification changes are not internally consistent yet, and the protobuf wire addition was not propagated into the shipped generated platform clients.

🔴 4 blocking

4 finding(s)

blocking: Time-range updates drop null-key index entries instead of preserving the existing layout

packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v0/mod.rs (line 795)

update_time_range_index_for_contract_operations_v0() turns a missing/undecodable source timestamp into Vec::new() for both new_buckets and old_buckets. That is inconsistent with the insert and delete implementations, which explicitly keep a single ordinary key when the first indexed timestamp is null (None => vec![document_top_field.clone()] in add_indices_for_top_index_level_for_contract_operations_v1 and remove_indices_for_top_index_level_for_contract_operations_v1). Because the caller always routes time-range indexes into this helper, updates from null -> value, value -> null, or null -> null with later suffix changes skip the delete/reinsert work needed to maintain that null entry. The result is stale index state for valid documents with nullable timestamp fields.

blocking: Pre-origin timestamps are assigned to buckets that do not contain them

packages/rs-dpp/src/data_contract/document_type/index/time_range.rs (line 68)

most_recent_start() saturates t < origin_ms to origin_ms, and containing_buckets() then emits that start as long as it is >= origin_ms. For any contract with a nonzero origin_ms, a document timestamp earlier than the origin is therefore indexed into the origin_ms bucket even though the documented bucket interval is [start, start + range_ms) and does not contain that timestamp. The same saturation also makes newest_active_start() and oldest_active_start() report an active bucket before any range has actually started. Contract validation does not reject nonzero origins, so this is a reachable correctness bug for valid contracts.

blocking: Aggregate proof verification never resolves time-range selectors before mode and index selection

packages/rs-sdk/src/platform/documents/count_proof_helpers.rs (line 140)

verify_count_query() reads request.where_clauses directly when it computes the count mode and picks the covering index, but DocumentQuery::with_time_range() stores its selector in request.time_range_clauses until verification time. The document proof path already resolves those selectors into concrete equality clauses using the quorum-signed metadata time before rebuilding the drive query, but this helper does not do that, and the same omission is duplicated in sum_proof_helpers.rs and average_proof_helpers.rs. As a result, aggregate COUNT/SUM/AVG proof verification for with_time_range(...) queries is rebuilt from a different query shape than the prover used, so valid proofs can be rejected or verified against the wrong path/query layout.

blocking: The new `IN_TIME_RANGE` enum value was not regenerated into shipped platform clients

packages/dapi-grpc/protos/platform/v0/platform.proto (line 601)

The proto adds IN_TIME_RANGE = 11, and the Rust SDK/server code already uses it, but the checked-in generated platform clients still stop at STARTS_WITH = 10. This is visible in packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js:19601-19614, packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js:24583-24595, packages/dapi-grpc/clients/platform/v0/web/platform_pb.js:24583-24594, and packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h:381-393. packages/dapi-grpc/node.js exports those generated bindings, so consumers of the published JS/web/Objective-C platform clients cannot construct or recognize the new enum through the typed API at this SHA.

Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants