From 452b66255308600739fc74adb09c84fd7b65499b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 22 Aug 2026 04:21:11 +0700 Subject: [PATCH 1/7] feat: expose protocol v14 ranked queries and document references to JavaScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol v14 shipped two client-visible features that stopped at the Rust SDK: ranked aggregate indexes (provable top-K) and `refersTo` document references. Neither had any JavaScript surface — `js-evo-sdk` had exactly one change between v4.1.0 and v4.2-dev, a version bump. Ranked and having-range queries (wasm-sdk, js-evo-sdk) Adds `getDocumentsRanked` / `getDocumentsHaving` and their `WithProofInfo` twins, wrapped as `documents.ranked()` / `documents.having()` in evo-sdk. The grammar is not reimplemented. `detect_ranked_mode` / `detect_having_mode` are `pub` under rs-drive's `verify` feature, which wasm-sdk already enables, and they are pure and contract-free — so the binding runs the same versioned classifier the server's query table and the proof verifier run. A malformed query fails locally with rs-drive's own message and cannot drift from what the network enforces. Dedicated `DocumentsRankedQuery` / `DocumentsHavingQuery` interfaces rather than widening `DocumentsQuery`, which feeds four entry points that reject an offset. Replacing `orderBy` with `direction: 'asc' | 'desc'` keeps the `$count` sentinel out of the public surface and structurally prevents the documented ordering trap: the parser owns the `with_select`-before- `order_by_selected_aggregate` sequence, so a caller cannot invert it. Results are objects rather than the `Map` the count/sum/average surfaces return — `startingRank` has nowhere to live in a Map, and without it `{ limit: 1, offset: 4 }` has no meaning. Entries carry both `groupKeyHex`, which correlates with the aggregate maps for the same grouping, and a decoded `groupValue`; decoding is best effort and never fails the query. Averages come back as exact fixed point alongside the scale that divides them, since that constant has already moved once. Document references (wasm-dpp2) `DataContract.documentTypeReferences(name)` and `.documentReferences` report what a contract's `refersTo` declarations point at. This lives in wasm-dpp2 because it is parsed-contract metadata with no async or network, and it reaches wasm-sdk and evo-sdk through the existing re-export. It walks `flattened_properties()`, matching what both consensus validators walk, so a declaration's `path` is the same string the reference errors report. An omitted `contractId` resolves to the declaring contract exactly as consensus resolves it. The consensus codes 40120-40125 already survive to `WasmSdkError.code` on the broadcast path, so this only names them: a `DocumentReferenceErrorCode` enum and a `ConsensusError.code` getter make them branchable without a message regex. Verification is offline: 32 Rust unit tests over the query builders and result shaping, 13 wasm-dpp2 specs including a pre-v14 gate regression, and 6 stubbed evo-sdk facade specs. Closes #4402 Co-Authored-By: Claude Opus 5 --- packages/js-evo-sdk/README.md | 59 +- packages/js-evo-sdk/src/documents/facade.ts | 33 + .../tests/unit/facades/documents.spec.ts | 146 ++ .../document_type/property/mod.rs | 41 + packages/wasm-dpp2/src/consensus_error.rs | 127 ++ .../data_contract/document_type_reference.rs | 182 ++ packages/wasm-dpp2/src/data_contract/mod.rs | 4 + packages/wasm-dpp2/src/data_contract/model.rs | 53 + packages/wasm-dpp2/src/lib.rs | 3 +- .../unit/DocumentPropertyReference.spec.ts | 262 +++ packages/wasm-sdk/src/queries/document.rs | 12 +- .../wasm-sdk/src/queries/document_ranked.rs | 1763 +++++++++++++++++ packages/wasm-sdk/src/queries/mod.rs | 1 + .../wasm-sdk/tests/unit/data-contract.spec.ts | 26 + 14 files changed, 2708 insertions(+), 4 deletions(-) create mode 100644 packages/wasm-dpp2/src/data_contract/document_type_reference.rs create mode 100644 packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts create mode 100644 packages/wasm-sdk/src/queries/document_ranked.rs diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index b01b03f2c5f..478b5146a58 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -82,7 +82,7 @@ The SDK organises its API into domain-specific facades, each accessible as a pro |--------|-------------| | [`sdk.addresses`](src/addresses/facade.ts) | Query balances, transfer credits, withdraw to L1 | | [`sdk.identities`](src/identities/facade.ts) | Fetch, create, update, and top up identities | -| [`sdk.documents`](src/documents/facade.ts) | Query, create, replace, delete, and transfer documents; aggregate `count` / `sum` / `average` over indexed fields | +| [`sdk.documents`](src/documents/facade.ts) | Query, create, replace, delete, and transfer documents; aggregate `count` / `sum` / `average` over indexed fields; `ranked` top-K and `having` range queries over ranked indexes | | [`sdk.contracts`](src/contracts/facade.ts) | Fetch, publish, and update data contracts | | [`sdk.tokens`](src/tokens/facade.ts) | Mint, burn, transfer, freeze tokens and query balances | | [`sdk.dpns`](src/dpns/facade.ts) | Register and resolve Dash Platform names | @@ -96,6 +96,63 @@ The SDK organises its API into domain-specific facades, each accessible as a pro A `wallet` namespace is also exported with utilities for BIP39 mnemonic generation and validation, BIP44/DIP9/DIP13 key derivation (path helpers included), extended-key conversion (`xprvToXpub`, `deriveChildPublicKey`), key-pair generation and import (`generateKeyPair`, `keyPairFromWif`, `keyPairFromHex`), public-key-to-address conversion, address validation, message signing, and Dashpay contact-key derivation. See [`src/wallet/functions.ts`](src/wallet/functions.ts) for the full list. +## Ranked queries + +From protocol version 14, a contract index can declare `rankedCountable`, `rankedSummable` or `rankedAverageable`. Against such an index the SDK can answer "which groups score highest?" with a proof, in `O(log n + k)`, without walking every group: + +```ts +// The three best restaurants by average grade. +const page = await sdk.documents.ranked({ + dataContractId: RESTAURANTS, + documentTypeName: 'review', + groupBy: 'restaurantId', + aggregate: { type: 'avg', property: 'grade' }, + limit: 3, +}); + +for (const entry of page.entries) { + // `value` is exact fixed point for the avg axis — divide by `page.valueScale`, + // never by a hardcoded constant. `valueAsNumber` is a lossy display helper. + console.log(entry.rank, entry.groupValue, Number(entry.value) / Number(page.valueScale)); +} +``` + +`limit` is required and capped at `EvoSDK`'s `maxRankedLimit()` (a hard reject, not a clamp). `offset` skips ranks — `{ limit: 1, offset: 4 }` is "the 5th best" — and has no ceiling, because the skipped region is attested rather than walked. + +`sdk.documents.having()` bounds the same axis by value instead of by position (`{ operator: '>', value: 100 }`), and `rankedWithProof` / `havingWithProof` return the proof and block metadata alongside the result. + +## Document references (`refersTo`) + +Also from protocol version 14, an identifier property can declare what it points at. This is a write-time consensus constraint — nothing resolves a reference for a reader — but a fetched contract can be asked what it declares: + +```ts +const contract = await sdk.contracts.fetch(contractId); + +for (const ref of contract.documentTypeReferences('note')) { + // { path: 'author', type: 'identityPublicKey', keyIdProperty: 'authorKeyId' } + console.log(ref.path, ref.type); +} + +// Every document type that declares at least one reference. +contract.documentReferences; +``` + +Declarations are only parsed from protocol version 14 onward; a contract deserialized against an earlier version reports none even when its raw schema carries the keyword. + +When a write is rejected because a reference does not resolve, the consensus code reaches JS as `error.code`: + +```ts +import { DocumentReferenceErrorCode } from '@dashevo/evo-sdk'; + +try { + await sdk.documents.create({ document, identityKey, signer }); +} catch (e) { + if (e.code === DocumentReferenceErrorCode.ReferencedIdentityKeyDisabled) { + // the referenced key exists but was disabled + } +} +``` + ## Contributing Feel free to dive in! [Open an issue](https://github.com/dashpay/platform/issues/new/choose) or submit PRs. diff --git a/packages/js-evo-sdk/src/documents/facade.ts b/packages/js-evo-sdk/src/documents/facade.ts index d7045505c38..53ee8a5faf4 100644 --- a/packages/js-evo-sdk/src/documents/facade.ts +++ b/packages/js-evo-sdk/src/documents/facade.ts @@ -123,4 +123,37 @@ export class DocumentsFacade { const w = await this.sdk.getWasmSdkConnected(); return w.getDocumentsAverageWithProofInfo(query, averageProperty); } + + /** + * Rank groups by an aggregate and return the top (or bottom) `limit` of + * them. Requires protocol version 14 and a contract index declaring the + * matching ranked keyword. + */ + async ranked(query: wasm.DocumentsRankedQuery): Promise { + const w = await this.sdk.getWasmSdkConnected(); + return w.getDocumentsRanked(query); + } + + async rankedWithProof( + query: wasm.DocumentsRankedQuery, + ): Promise> { + const w = await this.sdk.getWasmSdkConnected(); + return w.getDocumentsRankedWithProofInfo(query); + } + + /** + * Return the groups whose aggregate falls inside a bound. Same ranked + * indexes as {@link ranked}, bounded by value rather than by position. + */ + async having(query: wasm.DocumentsHavingQuery): Promise { + const w = await this.sdk.getWasmSdkConnected(); + return w.getDocumentsHaving(query); + } + + async havingWithProof( + query: wasm.DocumentsHavingQuery, + ): Promise> { + const w = await this.sdk.getWasmSdkConnected(); + return w.getDocumentsHavingWithProofInfo(query); + } } diff --git a/packages/js-evo-sdk/tests/unit/facades/documents.spec.ts b/packages/js-evo-sdk/tests/unit/facades/documents.spec.ts index e036b6cb566..f8ba44ca385 100644 --- a/packages/js-evo-sdk/tests/unit/facades/documents.spec.ts +++ b/packages/js-evo-sdk/tests/unit/facades/documents.spec.ts @@ -35,6 +35,24 @@ describe('DocumentsFacade', () => { let getDocumentsSumWithProofInfoStub: SinonStub; let getDocumentsAverageStub: SinonStub; let getDocumentsAverageWithProofInfoStub: SinonStub; + let getDocumentsRankedStub: SinonStub; + let getDocumentsRankedWithProofInfoStub: SinonStub; + let getDocumentsHavingStub: SinonStub; + let getDocumentsHavingWithProofInfoStub: SinonStub; + + const emptyRankedResult = { + startingRank: BigInt(0), + entries: [], + aggregate: 'avg', + groupBy: 'restaurantId', + valueScale: BigInt(1), + }; + const emptyHavingResult = { + entries: [], + aggregate: 'count', + groupBy: 'hashtag', + valueScale: BigInt(1), + }; beforeEach(async function setup() { await init(); @@ -97,6 +115,20 @@ describe('DocumentsFacade', () => { proof: {}, metadata: {}, }); + + // Stub ranked / having-range query methods + getDocumentsRankedStub = this.sinon.stub(wasmSdk, 'getDocumentsRanked').resolves(emptyRankedResult); + getDocumentsRankedWithProofInfoStub = this.sinon.stub(wasmSdk, 'getDocumentsRankedWithProofInfo').resolves({ + data: emptyRankedResult, + proof: {}, + metadata: {}, + }); + getDocumentsHavingStub = this.sinon.stub(wasmSdk, 'getDocumentsHaving').resolves(emptyHavingResult); + getDocumentsHavingWithProofInfoStub = this.sinon.stub(wasmSdk, 'getDocumentsHavingWithProofInfo').resolves({ + data: emptyHavingResult, + proof: {}, + metadata: {}, + }); }); describe('query()', () => { @@ -386,4 +418,118 @@ describe('DocumentsFacade', () => { expect(getDocumentsAverageWithProofInfoStub).to.be.calledOnceWithExactly(query, averageProperty); }); }); + + describe('ranked()', () => { + it('should rank groups by an aggregate', async () => { + const query = { + dataContractId: 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec', + documentTypeName: 'review', + groupBy: 'restaurantId', + aggregate: { type: 'avg', property: 'grade' }, + limit: 3, + }; + + await client.documents.ranked(query); + + expect(getDocumentsRankedStub).to.be.calledOnceWithExactly(query); + }); + + it('should pass through the offset that selects a single rank', async () => { + // "The 5th best": skip the four above it, take one. + const query = { + dataContractId: 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec', + documentTypeName: 'review', + groupBy: 'restaurantId', + aggregate: { type: 'avg', property: 'grade' }, + limit: 1, + offset: 4, + }; + + await client.documents.ranked(query); + + expect(getDocumentsRankedStub).to.be.calledOnceWithExactly(query); + }); + + it('should pass through the equality pins of a compound ranked index', async () => { + const query = { + dataContractId: 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec', + documentTypeName: 'grade', + groupBy: 'class', + aggregate: { type: 'count' }, + where: [['country', '==', 'DE']], + direction: 'asc', + limit: 10, + }; + + await client.documents.ranked(query); + + expect(getDocumentsRankedStub).to.be.calledOnceWithExactly(query); + }); + }); + + describe('rankedWithProof()', () => { + it('should rank groups with proof metadata', async () => { + const query = { + dataContractId: 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec', + documentTypeName: 'review', + groupBy: 'restaurantId', + aggregate: { type: 'count' }, + limit: 5, + }; + + await client.documents.rankedWithProof(query); + + expect(getDocumentsRankedWithProofInfoStub).to.be.calledOnceWithExactly(query); + }); + }); + + describe('having()', () => { + it('should bound groups by their aggregate', async () => { + const query = { + dataContractId: 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec', + documentTypeName: 'post', + groupBy: 'hashtag', + aggregate: { type: 'count' }, + having: { operator: '>', value: 100 }, + direction: 'desc', + limit: 100, + }; + + await client.documents.having(query); + + expect(getDocumentsHavingStub).to.be.calledOnceWithExactly(query); + }); + + it('should pass through a two-operand between bound', async () => { + const query = { + dataContractId: 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec', + documentTypeName: 'tip', + groupBy: 'recipientId', + aggregate: { type: 'sum', property: 'amount' }, + having: { operator: 'between', value: [1000, 5000] }, + limit: 25, + }; + + await client.documents.having(query); + + expect(getDocumentsHavingStub).to.be.calledOnceWithExactly(query); + }); + }); + + describe('havingWithProof()', () => { + it('should bound groups with proof metadata', async () => { + const query = { + dataContractId: 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec', + documentTypeName: 'post', + groupBy: 'hashtag', + aggregate: { type: 'count' }, + having: { operator: '>=', value: BigInt(1) }, + limit: 10, + }; + + await client.documents.havingWithProof(query); + + expect(getDocumentsHavingWithProofInfoStub).to.be.calledOnceWithExactly(query); + }); + }); }); diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index 7b1dc041afa..c0b3834b1b7 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -7207,4 +7207,45 @@ mod tests { "permanent document (own contract, document type note)" ); } + + /// A compile-time guard, not a behavioural test. + /// + /// `DocumentPropertyReferenceTarget` is mirrored outside this crate — + /// notably by wasm-dpp2's `DocumentPropertyReference` TypeScript union + /// and the conversion that builds it. Those live behind a `match` that + /// a new variant would not break, because they can fall back to a + /// catch-all. This exhaustive `match` has no catch-all, so adding a + /// sixth variant fails to compile *here*, in the crate that owns the + /// enum, where whoever adds it will see it. + #[test] + fn reference_targets_are_exhaustively_mirrored() { + let targets = [ + DocumentPropertyReferenceTarget::Identity, + DocumentPropertyReferenceTarget::Contract, + DocumentPropertyReferenceTarget::Token, + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "note".to_string(), + }, + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "signerKeyId".to_string(), + }, + ]; + + for target in &targets { + // No `_ =>` arm: a new variant is a compile error. + let json_tag = match target { + DocumentPropertyReferenceTarget::Identity => "identity", + DocumentPropertyReferenceTarget::Contract => "contract", + DocumentPropertyReferenceTarget::Token => "token", + DocumentPropertyReferenceTarget::PermanentDocument { .. } => "permanentDocument", + DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", + }; + + // The tag is the `refersTo` schema keyword's own `type` value, + // which is what the JS surface reports verbatim. + assert!(!json_tag.is_empty()); + assert!(!target.to_string().is_empty()); + } + } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 4b82fbfde86..e245e95a206 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -1,9 +1,67 @@ use crate::error::WasmDppResult; use crate::impl_wasm_type_info; use dpp::consensus::ConsensusError; +use dpp::consensus::codes::ErrorWithCode; use dpp::serialization::PlatformDeserializable; use wasm_bindgen::prelude::wasm_bindgen; +/// Consensus error codes emitted by `refersTo` reference validation, which +/// runs from protocol version 14 onward. +/// +/// Branch on an error's `code` against these instead of matching its +/// message. Both directions work — `DocumentReferenceErrorCode[40123]` is +/// `"ReferencedIdentityKeyNotFound"`. +/// +/// These reach JS on the state-transition broadcast path, where the +/// consensus code is carried through to `WasmSdkError.code`: +/// +/// ```js +/// try { +/// await sdk.documents.create({ document, identityKey, signer }); +/// } catch (e) { +/// if (e.code === DocumentReferenceErrorCode.ReferencedIdentityKeyDisabled) { +/// // the referenced key exists but was disabled +/// } +/// } +/// ``` +#[wasm_bindgen(js_name = "DocumentReferenceErrorCode")] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DocumentReferenceErrorCodeWasm { + /// The referenced identity, contract, token or permanent document does + /// not exist. + ReferencedEntityNotFound = 40120, + /// A `permanentDocument` reference names a document type the referenced + /// contract does not define, or the contract itself is missing. + ReferencedDocumentTypeNotFound = 40121, + /// The referenced document type allows deletion. Only types declaring + /// `canBeDeleted: false` may be the target of a `permanentDocument` + /// reference — otherwise the reference could be left dangling. + ReferencedDocumentTypeDeletable = 40122, + /// The referenced identity public key does not exist. + ReferencedIdentityKeyNotFound = 40123, + /// The referenced identity public key exists but is disabled. + ReferencedIdentityKeyDisabled = 40124, + /// The declaration's `keyIdProperty` is missing from the document type, + /// or names a property that is not an integer. + ReferencedKeyIdPropertyInvalid = 40125, +} + +impl DocumentReferenceErrorCodeWasm { + /// The reference-validation error a code names, or `None` when the code + /// is outside the 40120-40125 range. + fn from_code(code: u32) -> Option { + match code { + 40120 => Some(Self::ReferencedEntityNotFound), + 40121 => Some(Self::ReferencedDocumentTypeNotFound), + 40122 => Some(Self::ReferencedDocumentTypeDeletable), + 40123 => Some(Self::ReferencedIdentityKeyNotFound), + 40124 => Some(Self::ReferencedIdentityKeyDisabled), + 40125 => Some(Self::ReferencedKeyIdPropertyInvalid), + _ => None, + } + } +} + #[wasm_bindgen(js_name = "ConsensusError")] pub struct ConsensusErrorWasm(ConsensusError); @@ -20,6 +78,75 @@ impl ConsensusErrorWasm { pub fn message(&self) -> String { self.0.to_string() } + + /// The consensus error code. + /// + /// This is the same number that reaches JS as `WasmSdkError.code` when + /// a state transition is rejected. See [`DocumentReferenceErrorCodeWasm`] + /// for the reference-validation range. + #[wasm_bindgen(getter = "code")] + pub fn code(&self) -> u32 { + self.0.code() + } + + /// The reference-validation error this is, or `undefined` when it is + /// not one of codes 40120-40125. + #[wasm_bindgen(getter = "documentReferenceErrorCode")] + pub fn document_reference_error_code(&self) -> Option { + DocumentReferenceErrorCodeWasm::from_code(self.0.code()) + } } impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); + +#[cfg(test)] +mod tests { + use super::*; + + /// The enum's discriminants are the contract with JS. Anything that + /// re-numbers them silently breaks every caller's `switch`. + #[test] + fn reference_error_codes_round_trip_through_their_discriminants() { + let cases = [ + ( + 40120, + DocumentReferenceErrorCodeWasm::ReferencedEntityNotFound, + ), + ( + 40121, + DocumentReferenceErrorCodeWasm::ReferencedDocumentTypeNotFound, + ), + ( + 40122, + DocumentReferenceErrorCodeWasm::ReferencedDocumentTypeDeletable, + ), + ( + 40123, + DocumentReferenceErrorCodeWasm::ReferencedIdentityKeyNotFound, + ), + ( + 40124, + DocumentReferenceErrorCodeWasm::ReferencedIdentityKeyDisabled, + ), + ( + 40125, + DocumentReferenceErrorCodeWasm::ReferencedKeyIdPropertyInvalid, + ), + ]; + + for (code, expected) in cases { + assert_eq!( + DocumentReferenceErrorCodeWasm::from_code(code), + Some(expected) + ); + assert_eq!(expected as u32, code); + } + } + + #[test] + fn codes_outside_the_reference_range_are_not_claimed() { + for code in [40119, 40126, 0, 40200] { + assert_eq!(DocumentReferenceErrorCodeWasm::from_code(code), None); + } + } +} diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs new file mode 100644 index 00000000000..f02c0ce69e1 --- /dev/null +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -0,0 +1,182 @@ +//! `refersTo` declarations — the document-reference metadata a contract +//! carries from protocol version 14 onward. +//! +//! `refersTo` annotates an identifier property with what it points at, and +//! consensus enforces that the target exists whenever a document carrying +//! it is written. It is a **write-time constraint only**: nothing anywhere +//! in the stack resolves a reference for a reader. What this module adds is +//! the ability to *discover* the declarations — "which properties of this +//! document type are references, and to what?" — without hand-parsing the +//! contract's raw JSON schema. + +use crate::error::{WasmDppError, WasmDppResult}; +use crate::identifier::IdentifierWasm; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRef, +}; +use dpp::prelude::Identifier; +use js_sys::{Array, Object, Reflect}; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const DOCUMENT_PROPERTY_REFERENCE_TS: &'static str = r#" +/** + * What a `refersTo` declaration points at. + * + * Mirrors the `refersTo` keyword of the v3 document meta-schema, which is + * active from protocol version 14. The field names are the schema keyword's + * own, so what `contract.toJSON()` shows under `refersTo` and what these + * accessors return line up key for key. + */ +export type DocumentPropertyReferenceTarget = + | { type: 'identity' } + | { type: 'contract' } + | { type: 'token' } + | { + type: 'permanentDocument'; + /** + * The contract the referenced document type lives in. + * + * Always present. When the schema omits `contractId` the declaration + * targets the declaring contract itself, and this field reports the + * declaring contract's own id — consensus resolves the two cases + * identically, so a caller never has to special-case an absent value. + * `ref.contractId.equals(contract.id)` is the self-reference test. + */ + contractId: Identifier; + /** + * Name of the referenced document type. It must declare + * `canBeDeleted: false`, which is what makes the reference + * permanent — a target that could be deleted would leave the + * reference dangling. + */ + documentType: string; + } + | { + type: 'identityPublicKey'; + /** + * Property of the same document type whose value carries the + * referenced key id. The declaring property's own value carries the + * identity id. A dotted path when the property is nested. + */ + keyIdProperty: string; + }; + +/** + * A single `refersTo` declaration on a document type. + */ +export type DocumentPropertyReference = { + /** + * Dotted path of the declaring property within the document type — for + * example `"author"`, or `"meta.parentId"` for a nested one. + * + * This is the same string consensus reports in the `path` field of the + * document-write reference errors (codes 40120-40125). Note that contract + * *registration* errors prefix it with the document type name + * (`"."`) while document *write* errors do not. + */ + path: string; +} & DocumentPropertyReferenceTarget; +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Array")] + pub type DocumentPropertyReferenceArrayJs; + + #[wasm_bindgen(typescript_type = "Map>")] + pub type DocumentPropertyReferenceMapJs; +} + +/// `Reflect::set` with the collection-getter error convention the `tokens` +/// and `groups` getters on `DataContract` already use. +fn set_field(target: &Object, key: &str, value: &JsValue, path: &str) -> WasmDppResult<()> { + Reflect::set(target, &JsValue::from_str(key), value).map_err(|_| { + WasmDppError::generic(format!( + "unable to serialize the `{key}` field of the reference declared at '{path}'" + )) + })?; + Ok(()) +} + +/// Build the flat, internally-tagged JS object for one declaration. +/// +/// `declaring_contract_id` resolves the `PermanentDocument` variant's +/// absent `contract_id`, which consensus reads as "the declaring contract" +/// — it computes `contract_id.unwrap_or(contract.id())` and treats an +/// explicit self-id identically, so collapsing the two here loses nothing. +fn reference_to_js( + path: &str, + target: &DocumentPropertyReferenceTarget, + declaring_contract_id: Identifier, +) -> WasmDppResult { + let object = Object::new(); + set_field(&object, "path", &JsValue::from_str(path), path)?; + + let kind = match target { + DocumentPropertyReferenceTarget::Identity => "identity", + DocumentPropertyReferenceTarget::Contract => "contract", + DocumentPropertyReferenceTarget::Token => "token", + DocumentPropertyReferenceTarget::PermanentDocument { .. } => "permanentDocument", + DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", + }; + set_field(&object, "type", &JsValue::from_str(kind), path)?; + + match target { + DocumentPropertyReferenceTarget::Identity + | DocumentPropertyReferenceTarget::Contract + | DocumentPropertyReferenceTarget::Token => {} + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id, + document_type_name, + } => { + let effective = contract_id.unwrap_or(declaring_contract_id); + set_field( + &object, + "contractId", + &JsValue::from(IdentifierWasm::from(effective)), + path, + )?; + set_field( + &object, + "documentType", + &JsValue::from_str(document_type_name), + path, + )?; + } + DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { + set_field( + &object, + "keyIdProperty", + &JsValue::from_str(key_id_property), + path, + )?; + } + } + + Ok(object.into()) +} + +/// Collect every reference declaration of one document type, in schema +/// property order. +/// +/// Walks `flattened_properties` rather than `properties` because that is +/// what both consensus validators walk, and because their error `path` is +/// built from its dotted key. Using the nested map would produce paths that +/// no consensus error matches, and would miss nested declarations entirely. +pub(crate) fn references_for_document_type( + document_type: DocumentTypeRef<'_>, + declaring_contract_id: Identifier, +) -> WasmDppResult { + let references = Array::new(); + + for (path, property) in document_type.flattened_properties() { + if let DocumentPropertyType::IdentifierWithReference(target) = &property.property_type { + references.push(&reference_to_js(path, target, declaring_contract_id)?); + } + } + + Ok(references) +} diff --git a/packages/wasm-dpp2/src/data_contract/mod.rs b/packages/wasm-dpp2/src/data_contract/mod.rs index 48b998f1111..a3050527a67 100644 --- a/packages/wasm-dpp2/src/data_contract/mod.rs +++ b/packages/wasm-dpp2/src/data_contract/mod.rs @@ -1,10 +1,14 @@ pub mod contract_bounds; pub mod document; +pub mod document_type_reference; pub mod model; pub mod transitions; pub use contract_bounds::ContractBoundsWasm; pub use document::DocumentWasm; +pub use document_type_reference::{ + DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, +}; pub use model::{ DataContractJSONJs, DataContractObjectJs, DataContractWasm, tokens_configuration_from_js_value, }; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 8249d7d29f7..d421e6c33c9 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -1,3 +1,6 @@ +use crate::data_contract::document_type_reference::{ + DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, references_for_document_type, +}; use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; use crate::impl_try_from_js_value; @@ -610,6 +613,56 @@ impl DataContractWasm { let owner_id: Identifier = owner_id.try_into()?; Ok(DataContract::generate_data_contract_id_v0(owner_id.to_buffer(), identity_nonce).into()) } + + /// All `refersTo` declarations of one document type, in schema property + /// order. + /// + /// Returns an empty array when the document type declares none. Throws + /// when the contract has no document type by that name — an empty array + /// would conflate "no such type" with "no references". + /// + /// Reference declarations are only parsed from protocol version 14 + /// onward. A contract deserialized against an earlier platform version + /// reports none, which is exactly what consensus enforced at that + /// version — but note the trap: `DataContract.fromBytes(bytes, false, 1)` + /// yields `[]` even for a contract whose raw schema does carry + /// `refersTo`, and `toJSON()` still shows the raw keyword either way. + #[wasm_bindgen(js_name = "documentTypeReferences")] + pub fn document_type_references( + &self, + #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, + ) -> WasmDppResult { + let document_type = self + .0 + .document_type_optional_for_name(document_type_name.as_str()) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + + let references = references_for_document_type(document_type, self.0.id())?; + Ok(JsValue::from(references).into()) + } + + /// Every document type that declares at least one reference, keyed by + /// document type name. + /// + /// Document types with no declarations are omitted, so an empty `Map` + /// means "this contract declares no references at all". + #[wasm_bindgen(getter = "documentReferences")] + pub fn document_references(&self) -> WasmDppResult { + let map = js_sys::Map::new(); + + for (name, document_type) in self.0.document_types() { + let references = references_for_document_type(document_type.as_ref(), self.0.id())?; + if references.length() > 0 { + map.set(&JsValue::from_str(name), &references.into()); + } + } + + Ok(JsValue::from(map).into()) + } } impl DataContractWasm { diff --git a/packages/wasm-dpp2/src/lib.rs b/packages/wasm-dpp2/src/lib.rs index d687c7c5445..fa7a7772367 100644 --- a/packages/wasm-dpp2/src/lib.rs +++ b/packages/wasm-dpp2/src/lib.rs @@ -42,7 +42,8 @@ pub use identity::transitions::pooling::PoolingWasm; pub use data_contract::{ ContractBoundsWasm, DataContractCreateTransitionWasm, DataContractUpdateTransitionWasm, - DataContractWasm, DocumentWasm, tokens_configuration_from_js_value, + DataContractWasm, DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, + DocumentWasm, tokens_configuration_from_js_value, }; pub use epoch::*; pub use group::*; diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts new file mode 100644 index 00000000000..a63fbee87b5 --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -0,0 +1,262 @@ +/** + * Verifies the `refersTo` document-reference metadata surface introduced + * with protocol version 14. + * + * `refersTo` is a write-time consensus constraint: it declares what an + * identifier property points at, and consensus checks the target exists + * whenever a document carrying it is written. Nothing resolves a reference + * for a reader, so what the JS layer offers is *discovery* — which + * properties are references, and to what — plus branchable error codes for + * when a write is rejected. + */ +import { expect } from './helpers/chai.ts'; +import { initWasm, wasm } from '../../dist/dpp.compressed.js'; + +let PlatformVersion: typeof wasm.PlatformVersion; + +before(async () => { + await initWasm(); + ({ PlatformVersion } = wasm); +}); + +const ownerId = '11111111111111111111111111111111'; +const foreignContractId = '4fJLR2GYTPFdomuTVvNy3VRrvWgvkKPzqehEBpNf2nk6'; + +/** + * `refersTo` is only allowed on properties with exactly this shape — a + * 32-byte identifier. The meta-schema rejects it anywhere else. + */ +function identifierProperty(position: number, refersTo: object): object { + return { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + position, + refersTo, + }; +} + +/** + * One document type covering every reference target, plus a nested + * declaration to exercise dotted paths, and a second type declaring none. + */ +const schemas = { + note: { + type: 'object', + // A `permanentDocument` target must not be deletable, and `note` + // references itself below. + canBeDeleted: false, + properties: { + author: identifierProperty(0, { type: 'identity' }), + sourceContract: identifierProperty(1, { type: 'contract' }), + paidWith: identifierProperty(2, { type: 'token' }), + // `contractId` omitted: targets the declaring contract itself. + parentNoteId: identifierProperty(3, { + type: 'permanentDocument', + documentType: 'note', + }), + otherDoc: identifierProperty(4, { + type: 'permanentDocument', + contractId: foreignContractId, + documentType: 'thing', + }), + signerKey: identifierProperty(5, { + type: 'identityPublicKey', + keyIdProperty: 'signerKeyId', + }), + signerKeyId: { type: 'integer', position: 6, minimum: 0 }, + meta: { + type: 'object', + position: 7, + properties: { + ownerRef: identifierProperty(0, { type: 'identity' }), + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + plain: { + type: 'object', + properties: { + message: { type: 'string', position: 0, maxLength: 64 }, + }, + additionalProperties: false, + }, +}; + +function buildContract(platformVersion: number, fullValidation = true) { + return new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas, + definitions: null, + fullValidation, + platformVersion: new PlatformVersion(platformVersion), + }); +} + +type Reference = { + path: string; + type: string; + contractId?: { toBase58(): string }; + documentType?: string; + keyIdProperty?: string; +}; + +describe('DataContract — refersTo declarations (v14)', () => { + describe('documentTypeReferences()', () => { + it('should report every reference declaration in schema property order', () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + + expect(references.map((reference) => reference.path)).to.deep.equal([ + 'author', + 'sourceContract', + 'paidWith', + 'parentNoteId', + 'otherDoc', + 'signerKey', + 'meta.ownerRef', + ]); + }); + + it('should tag each declaration with its target kind', () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + const byPath = new Map(references.map((reference) => [reference.path, reference])); + + expect(byPath.get('author')!.type).to.equal('identity'); + expect(byPath.get('sourceContract')!.type).to.equal('contract'); + expect(byPath.get('paidWith')!.type).to.equal('token'); + expect(byPath.get('parentNoteId')!.type).to.equal('permanentDocument'); + expect(byPath.get('otherDoc')!.type).to.equal('permanentDocument'); + expect(byPath.get('signerKey')!.type).to.equal('identityPublicKey'); + expect(byPath.get('meta.ownerRef')!.type).to.equal('identity'); + }); + + it('should carry no target fields for the bare kinds', () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + const author = references.find((reference) => reference.path === 'author')!; + + expect(author).to.deep.equal({ path: 'author', type: 'identity' }); + }); + + /** + * An omitted `contractId` means "the declaring contract". Consensus + * computes `contract_id.unwrap_or(contract.id())` and treats an + * explicit self-id identically, so the accessor resolves it rather + * than handing JS a null to re-derive. + */ + it('should resolve an omitted contractId to the declaring contract', () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + const parent = references.find((reference) => reference.path === 'parentNoteId')!; + + expect(parent.contractId!.toBase58()).to.equal(contract.id.toBase58()); + expect(parent.documentType).to.equal('note'); + }); + + it('should keep an explicit foreign contractId', () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + const other = references.find((reference) => reference.path === 'otherDoc')!; + + expect(other.contractId!.toBase58()).to.equal(foreignContractId); + expect(other.documentType).to.equal('thing'); + }); + + it('should carry keyIdProperty for an identityPublicKey reference', () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + const signerKey = references.find((reference) => reference.path === 'signerKey')!; + + expect(signerKey.keyIdProperty).to.equal('signerKeyId'); + }); + + it('should return an empty array for a document type declaring none', () => { + const contract = buildContract(14); + + expect(contract.documentTypeReferences('plain')).to.deep.equal([]); + }); + + /** + * An empty array would conflate "no such type" with "no references", + * which is a difference a caller acting on the result needs. + */ + it('should throw for an unknown document type', () => { + const contract = buildContract(14); + + expect(() => contract.documentTypeReferences('doesNotExist')).to.throw(/not found/); + }); + + /** + * The version gate, and the trap that comes with it: `refersTo` is only + * parsed from protocol version 14 onward, so a contract deserialized + * against an earlier version reports no references even though its raw + * schema still carries the keyword. + */ + it('should report no references on a pre-v14 contract, while the raw schema keeps the keyword', () => { + const contract = buildContract(13, false); + + expect(contract.documentTypeReferences('note')).to.deep.equal([]); + + const rawSchemas = contract.schemas as Record< + string, + { properties: Record } + >; + expect(rawSchemas.note.properties.author.refersTo).to.deep.equal({ type: 'identity' }); + }); + }); + + describe('documentReferences', () => { + it('should key declarations by document type and omit types with none', () => { + const contract = buildContract(14); + const map = contract.documentReferences as Map; + + expect([...map.keys()]).to.deep.equal(['note']); + expect(map.get('note')!.map((reference) => reference.path)).to.deep.equal( + (contract.documentTypeReferences('note') as Reference[]).map((r) => r.path), + ); + }); + + it('should be empty for a contract declaring no references at all', () => { + const contract = new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: { plain: schemas.plain }, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + + expect((contract.documentReferences as Map).size).to.equal(0); + }); + }); + + describe('DocumentReferenceErrorCode', () => { + /** + * These are the numbers a caller compares `WasmSdkError.code` against + * after a rejected write. Renumbering any of them silently breaks every + * `switch` in the wild. + */ + it('should map each reference-validation error to its consensus code', () => { + expect(wasm.DocumentReferenceErrorCode.ReferencedEntityNotFound).to.equal(40120); + expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentTypeNotFound).to.equal(40121); + expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentTypeDeletable).to.equal(40122); + expect(wasm.DocumentReferenceErrorCode.ReferencedIdentityKeyNotFound).to.equal(40123); + expect(wasm.DocumentReferenceErrorCode.ReferencedIdentityKeyDisabled).to.equal(40124); + expect(wasm.DocumentReferenceErrorCode.ReferencedKeyIdPropertyInvalid).to.equal(40125); + }); + + it('should resolve a code back to its name', () => { + const codes = wasm.DocumentReferenceErrorCode as unknown as Record; + + expect(codes[40123]).to.equal('ReferencedIdentityKeyNotFound'); + expect(codes[40125]).to.equal('ReferencedKeyIdPropertyInvalid'); + }); + }); +}); diff --git a/packages/wasm-sdk/src/queries/document.rs b/packages/wasm-sdk/src/queries/document.rs index 55f4c06f92e..2b91065f584 100644 --- a/packages/wasm-sdk/src/queries/document.rs +++ b/packages/wasm-sdk/src/queries/document.rs @@ -406,7 +406,12 @@ async fn parse_documents_average_query( } /// Parse JSON where clause into WhereClause -fn parse_where_clause(json_clause: &JsonValue) -> Result { +/// +/// `pub(super)` so the ranked / having-range surface in +/// [`super::document_ranked`] can reuse the same `[field, operator, value]` +/// spelling a caller already learned here, rather than growing a second +/// where-clause dialect. +pub(super) fn parse_where_clause(json_clause: &JsonValue) -> Result { let clause_array = json_clause .as_array() .ok_or_else(|| WasmSdkError::invalid_argument("where clause must be an array"))?; @@ -491,7 +496,10 @@ fn parse_order_clause(json_clause: &JsonValue) -> Result Result { +/// +/// `pub(super)` for [`super::document_ranked`], which needs the same +/// conversion for a HAVING clause's right-hand operand. +pub(super) fn json_to_platform_value(json_val: &JsonValue) -> Result { match json_val { JsonValue::Null => Ok(Value::Null), JsonValue::Bool(b) => Ok(Value::Bool(*b)), diff --git a/packages/wasm-sdk/src/queries/document_ranked.rs b/packages/wasm-sdk/src/queries/document_ranked.rs new file mode 100644 index 00000000000..5abc801f8d8 --- /dev/null +++ b/packages/wasm-sdk/src/queries/document_ranked.rs @@ -0,0 +1,1763 @@ +//! Ranked (top-K) and having-range document queries — the protocol +//! version 14 aggregate-ordering surface. +//! +//! Both modes ride the same `getDocuments` RPC as the count / sum / +//! average surface in [`super::document`], and both return the same +//! per-group entry shape. They differ in what bounds the page: +//! +//! - **ranked** — `ORDER BY LIMIT k [OFFSET m]`. "Which k +//! groups score highest (or lowest)?" Position is the answer, so the +//! result carries a `startingRank`. +//! - **having-range** — `HAVING LIMIT k`. +//! "Which groups' aggregate falls in this range?" Value is the answer, +//! so there is no rank and no offset. +//! +//! The grammar for both is not restated here. [`detect_ranked_mode`] and +//! [`detect_having_mode`] are rs-drive's own versioned classifiers — the +//! same functions the server's query table and the proof verifier call — +//! and they are `pub` under the `verify` feature this crate already +//! enables. Calling them client-side means a malformed query fails +//! locally with rs-drive's own message and cannot drift from what the +//! network enforces. + +use crate::error::WasmSdkError; +use crate::queries::document::{json_to_platform_value, parse_where_clause}; +use crate::queries::utils::deserialize_required_query; +use crate::queries::ProofMetadataResponseWasm; +use crate::sdk::WasmSdk; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::documents::document_query::{DocumentQuery, RankingDirection}; +use dash_sdk::platform::{DataContract, Fetch}; +use drive::query::drive_document_having_query::mode_detection::detect_having_mode; +use drive::query::drive_document_ranked_query::mode_detection::detect_ranked_mode; +use drive::query::{ + HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand, + RankedAxis, RankedEntry, RankedEntryValue, RankedPaginationInputs, SelectFunction, + SelectProjection, RANKED_AVG_SCALE, +}; +use drive_proof_verifier::{DocumentHavingEntries, DocumentRankedEntries}; +use js_sys::{Array, Object, Reflect}; +use serde::Deserialize; +use serde_json::Value as JsonValue; +use std::sync::Arc; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsValue; +use wasm_dpp2::identifier::IdentifierWasm; +use wasm_dpp2::serialization::conversions::platform_value_to_json; + +#[wasm_bindgen(typescript_custom_section)] +const DOCUMENTS_RANKED_QUERY_TS: &'static str = r#" +/** + * The per-group aggregate a ranked / having-range query ranks and + * filters on. + * + * `count` is `COUNT(*)` and takes no property — the count axis counts + * documents per group. `COUNT()` is not a ranked axis and is + * rejected. `sum` / `avg` name the covering index's `summable` property. + * + * The covering index must opt in with the matching contract keyword + * (document meta-schema v3, protocol version 14+): `rankedCountable`, + * `rankedSummable` or `rankedAverageable`. Without it the node refuses + * the query and names the keyword the contract has to add. + */ +export type DocumentsAggregateSelect = + | { type: 'count' } + | { type: 'sum'; property: string } + | { type: 'avg'; property: string }; + +/** + * One equality pin on a compound ranked index's leading property, + * spelled like a `DocumentsQuery` where clause: `[property, '==', value]`. + * + * A compound ranked index keeps one ordered secondary per prefix value, + * with no global ordering across prefixes — so a ranked read has to name + * exactly one prefix, and only `==` names a single value tree. That + * means: one pin per leading index property, each on a distinct + * property, and none on the `groupBy` property itself. A single-property + * ranked index takes no pins at all. + * + * A `null` value is legal and addresses the subtree the write path + * creates for an *absent* optional value. + * + * `>` / `<` / `between` / `in` / `startsWith` are all rejected: a range + * cannot pin one prefix, and `in` would need one secondary walk per + * element. + */ +export type DocumentsIndexPin = [string, '==' | '=', unknown]; + +/** How a ranked / having-range walk runs along the aggregate axis. */ +export type DocumentsRankDirection = 'asc' | 'desc'; + +/** + * `SELECT GROUP BY ORDER BY LIMIT n [OFFSET m]` + * — the ranked (top-K) surface, protocol version 14+. + * + * Answers "which n groups score highest (or lowest) on an aggregate?" + * with a proof, by reading a pre-sorted per-axis secondary rather than + * walking every group's value tree. + * + * There is deliberately no `orderBy` here: a ranked query takes exactly + * one ordering clause and it is always "the selected aggregate", so + * `direction` is the only free choice. There is no `startAt` / + * `startAfter` either — a document id does not appear anywhere in a + * keyspace sorted by aggregate, so a cursor is rejected rather than + * ignored. + * + * @example + * // The three best restaurants by average grade. + * const page = await sdk.getDocumentsRanked({ + * dataContractId: RESTAURANTS, + * documentTypeName: 'review', + * groupBy: 'restaurantId', + * aggregate: { type: 'avg', property: 'grade' }, + * limit: 3, + * }); + * + * @example + * // The 5th-best restaurant: skip the four above it, take one. + * const fifth = await sdk.getDocumentsRanked({ + * dataContractId: RESTAURANTS, + * documentTypeName: 'review', + * groupBy: 'restaurantId', + * aggregate: { type: 'avg', property: 'grade' }, + * limit: 1, + * offset: 4, + * }); + */ +export interface DocumentsRankedQuery { + /** Data contract identifier. */ + dataContractId: IdentifierLike; + + /** Document type name. */ + documentTypeName: string; + + /** + * The single `GROUP BY` property — the covering ranked index's + * *trailing* property, whose distinct values are the ranking's group + * keys. A compound ranked index ranks each prefix separately: pin + * every leading property through `where`, and group by the trailing + * one. + */ + groupBy: string; + + /** Which aggregate the groups are ranked by. */ + aggregate: DocumentsAggregateSelect; + + /** + * The ranking's `n`. Required, and `1 <= limit <= 100`. + * + * This is a hard ceiling, not a clamp: the limit is echoed inside the + * proof envelope and re-checked when the client reconstructs the page, + * so an oversized request is rejected rather than truncated. + */ + limit: number; + + /** + * `'desc'` walks from the largest aggregate down — the "top n" + * reading, where entry 0 is the highest-scoring group. `'asc'` is the + * "bottom n" reading. + * @default 'desc' + */ + direction?: DocumentsRankDirection; + + /** + * How many ranks to skip before the returned page. There is + * deliberately no ceiling: the skipped region is attested from counted + * subtree commitments rather than walked, so a deep offset costs what + * a shallow one does. + * + * An offset past the end of the ranking is a legitimate answer rather + * than an error — the page comes back empty and `startingRank` is the + * ranking's whole attested population. + * @default undefined + */ + offset?: number; + + /** + * Equality pins on the covering compound index's leading properties. + * Omit entirely for a single-property ranked index. + * @default [] + */ + where?: DocumentsIndexPin[]; +} + +/** + * The `HAVING` bound: one contiguous range over the selected aggregate. + * + * The aggregate is not restated here. The grammar requires a having + * clause to bound the same aggregate the query selects, so it is derived + * from `aggregate` and there is no way to write the mismatch the server + * would reject. + * + * `!=` and `in` describe non-contiguous ranges and are not expressible: + * a having-range query *is* one contiguous slice of one axis secondary. + * + * Operand types follow the axis — `count` bounds are non-negative + * integers, `sum` bounds are integers in `i64` range, `avg` bounds are + * numbers in the natural (unscaled) domain of the averaged property. + * Pass a `bigint` for magnitudes past `Number.MAX_SAFE_INTEGER`. + * + * A bound that resolves to an empty range (above the axis maximum, or a + * `between` whose lower bound exceeds its upper) is rejected rather than + * silently proving an empty page. + */ +export type DocumentsHavingBound = + | { operator: '==' | '=' | '>' | '>=' | '<' | '<='; value: number | bigint } + | { + operator: + | 'Between' + | 'between' + | 'BetweenExcludeBounds' + | 'BetweenExcludeLeft' + | 'BetweenExcludeRight'; + value: [number | bigint, number | bigint]; + }; + +/** + * `SELECT GROUP BY HAVING LIMIT n` + * — the having-range surface, protocol version 14+. + * + * Answers "which groups' aggregate falls in this range?", served as a + * value-bounded range read of the same axis secondary the ranked surface + * reads. Verification covers completeness: an in-range group the node + * omitted fails the proof. + * + * Pagination caveat — there is no offset and no cursor. Continuing a + * page means re-issuing with a tightened bound, which advances past + * *distinct* aggregate values only, so a page cut inside a tie cannot be + * continued. Size `limit` above the widest tie you expect. + * + * @example + * // Hashtags with more than 100 posts, biggest first. + * const hot = await sdk.getDocumentsHaving({ + * dataContractId: SOCIAL, + * documentTypeName: 'post', + * groupBy: 'hashtag', + * aggregate: { type: 'count' }, + * having: { operator: '>', value: 100 }, + * direction: 'desc', + * limit: 100, + * }); + */ +export interface DocumentsHavingQuery { + /** Data contract identifier. */ + dataContractId: IdentifierLike; + + /** Document type name. */ + documentTypeName: string; + + /** The single `GROUP BY` property. Same contract as `DocumentsRankedQuery`. */ + groupBy: string; + + /** Which aggregate the bound applies to. */ + aggregate: DocumentsAggregateSelect; + + /** The one bound. Exactly one clause — multi-clause `AND` is rejected. */ + having: DocumentsHavingBound; + + /** Required, `1 <= limit <= 100`. Same hard-ceiling semantics as ranked. */ + limit: number; + + /** + * Walk direction along the axis inside the bound. Optional here, + * unlike ranked, where the ordering *is* the query. + * @default 'asc' + */ + direction?: DocumentsRankDirection; + + /** + * Equality pins on the covering compound index's leading properties. + * @default [] + */ + where?: DocumentsIndexPin[]; +} + +/** Which axis a returned aggregate value came from. */ +export type DocumentsAggregateKind = 'count' | 'sum' | 'avg'; + +/** One group in a ranked / having-range result. */ +export interface DocumentsGroupEntry { + /** + * Hex-encoded raw index-key bytes of the group's value — byte for byte + * the same key `getDocumentsCount` / `getDocumentsSum` / + * `getDocumentsAverage` use for the same grouping, so results + * correlate across the surfaces. Always present, even when + * `groupValue` could not be produced. + */ + groupKeyHex: string; + + /** + * The group key decoded back to its typed value using the contract's + * document type. Identifiers arrive base58-encoded and byte + * properties base64-encoded, matching the document JSON convention + * used elsewhere in this SDK. + * + * `null` when the index key is empty — how the write path stores an + * *absent* optional group-by value. `undefined` when the bytes exist + * but do not decode as the group-by property's type. `groupKeyHex` is + * always the lossless fallback. + */ + groupValue: unknown; + + /** + * The group's aggregate as an exact integer. + * + * - `count` — the document count. + * - `sum` — the running sum, signed. + * - `avg` — the *fixed-point* average. Divide by `valueScale` on the + * enclosing result; never by a hardcoded literal, because the scale + * is a build-time constant that has already changed once. + * + * A `bigint` because none of the three fit a JS `number` in general + * and the average's fixed point is 128-bit. On a proved fetch this is + * exactly the integer the proof commits to — keep it for comparing + * groups, reproducing a ranking, or storing. On an unproved fetch the + * average is reconstructed from the wire's `double`, so digits past + * f64's ~15 significant decimals are noise. Ranking *order* is exact + * either way. + */ + value: bigint; + + /** + * `value` rendered as a `number`, with the average axis already + * divided by the scale. A display helper: lossy past 2^53 for counts + * and sums, and for every average. Two groups whose exact aggregates + * differ can round to the same `number`, so never compare with this. + */ + valueAsNumber: number; +} + +/** One group in a ranked result, pinned to its absolute position. */ +export interface DocumentsRankedEntry extends DocumentsGroupEntry { + /** + * The group's 0-based absolute rank, `startingRank + index`. This is + * what makes `limit: 1, offset: 4` mean "the 5th best" rather than + * "some entry". + */ + rank: bigint; +} + +/** Result of a ranked (top-K) query. */ +export interface DocumentsRankedResult { + /** + * The 0-based rank of `entries[0]` — the query's offset as actually + * honoured. On the proved path this is re-derived from the proof's + * counted subtree commitments rather than trusted from the node. + * + * When `entries` is empty this is a proof that the ranking holds + * exactly this many groups in total. + */ + startingRank: bigint; + + /** The groups on this page, in ranking order. Do not re-sort. */ + entries: DocumentsRankedEntry[]; + + /** Which axis `entry.value` came from; echoes the request's `aggregate.type`. */ + aggregate: DocumentsAggregateKind; + + /** The request's `groupBy` property, echoed so a result is self-describing. */ + groupBy: string; + + /** + * Fixed-point divisor for `entry.value`: `1n` for `count` and `sum`, + * and the build's average scale for `avg`. Returned rather than + * documented precisely so callers never hardcode it. + * `Number(e.value) / Number(scale)` is `e.valueAsNumber`; use a + * decimal library on the two bigints when you need better. + */ + valueScale: bigint; +} + +/** Result of a having-range query. */ +export interface DocumentsHavingResult { + /** + * The matching groups, in axis order along `direction`. Do not + * re-sort. There is no rank: a having-range read bounds values, it + * does not count positions. + */ + entries: DocumentsGroupEntry[]; + + /** Which axis `entry.value` came from. */ + aggregate: DocumentsAggregateKind; + + /** The request's `groupBy` property. */ + groupBy: string; + + /** Same contract as `DocumentsRankedResult.valueScale`. */ + valueScale: bigint; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "DocumentsRankedQuery")] + pub type DocumentsRankedQueryJs; + + #[wasm_bindgen(typescript_type = "DocumentsHavingQuery")] + pub type DocumentsHavingQueryJs; +} + +/// The aggregate select, as the JS discriminated union arrives. +/// +/// A plain struct rather than an internally-tagged serde enum: the JS +/// object crosses through `platform_value::from_value`, and the +/// buffering-based enum representations are the one serde feature whose +/// behaviour there is not worth betting on. TypeScript still gives +/// callers the compile-time narrowing; this validates the same rules at +/// runtime with a message naming the JS field. +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AggregateSelectInput { + #[serde(rename = "type")] + kind: String, + #[serde(default)] + property: Option, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct HavingBoundInput { + operator: String, + value: JsonValue, +} + +/// `deny_unknown_fields` on both query inputs is deliberate, and is a +/// departure from [`super::document`]'s permissive `DocumentsQueryInput`. +/// +/// The expected mistake is a caller copying a `DocumentsQuery` object +/// into a ranked call and dragging `orderBy` / `startAfter` along. +/// Permissive serde would silently drop them and *still run the query* +/// under the default direction — answering a different question without +/// saying so. An error is the only honest outcome. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DocumentsRankedQueryInput { + data_contract_id: IdentifierWasm, + document_type_name: String, + group_by: String, + aggregate: AggregateSelectInput, + limit: u32, + #[serde(default)] + direction: Option, + #[serde(default)] + offset: Option, + #[serde(rename = "where", default)] + where_clauses: Option>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DocumentsHavingQueryInput { + data_contract_id: IdentifierWasm, + document_type_name: String, + group_by: String, + aggregate: AggregateSelectInput, + having: HavingBoundInput, + limit: u32, + #[serde(default)] + direction: Option, + #[serde(rename = "where", default)] + where_clauses: Option>, +} + +/// Translate the JS aggregate union into rs-drive's `SELECT` projection. +fn select_from_input(aggregate: &AggregateSelectInput) -> Result { + match (aggregate.kind.as_str(), aggregate.property.as_deref()) { + ("count", None) | ("count", Some("")) => Ok(SelectProjection::count_star()), + ("count", Some(_)) => Err(WasmSdkError::invalid_argument( + "aggregate { type: 'count' } takes no `property`: the count axis counts documents \ + per group, which is what COUNT(*) means. COUNT() is not a ranked axis. \ + Drop `property`, or use { type: 'sum' | 'avg', property } to aggregate a value.", + )), + ("sum", Some(property)) if !property.is_empty() => Ok(SelectProjection::sum(property)), + ("avg", Some(property)) if !property.is_empty() => Ok(SelectProjection::avg(property)), + (kind @ ("sum" | "avg"), _) => Err(WasmSdkError::invalid_argument(format!( + "aggregate {{ type: '{kind}' }} requires a non-empty `property` naming the covering \ + ranked index's `summable` property" + ))), + (other, _) => Err(WasmSdkError::invalid_argument(format!( + "unknown aggregate type `{other}`; expected 'count', 'sum' or 'avg'" + ))), + } +} + +/// `'asc'` / `'desc'` into the named direction pair, or `default` when +/// the caller left the knob unset. +fn ranking_direction( + direction: Option<&str>, + default: RankingDirection, +) -> Result { + match direction { + None => Ok(default), + Some("desc") => Ok(RankingDirection::Descending), + Some("asc") => Ok(RankingDirection::Ascending), + Some(other) => Err(WasmSdkError::invalid_argument(format!( + "direction must be 'asc' or 'desc'; got `{other}`" + ))), + } +} + +/// Having operators, spelled the way `DocumentWhereOperator` spells the +/// same comparisons so a caller who learned `'>='` for `where` writes +/// `'>='` here too. +/// +/// `!=` and `in` are absent on purpose rather than mapped and then +/// rejected downstream: neither describes one contiguous range, and a +/// having-range read is exactly one contiguous slice of one axis. +fn having_operator_from_str(operator: &str) -> Result { + match operator { + "==" | "=" => Ok(HavingOperator::Equal), + ">" => Ok(HavingOperator::GreaterThan), + ">=" => Ok(HavingOperator::GreaterThanOrEquals), + "<" => Ok(HavingOperator::LessThan), + "<=" => Ok(HavingOperator::LessThanOrEquals), + "Between" | "between" => Ok(HavingOperator::Between), + "BetweenExcludeBounds" => Ok(HavingOperator::BetweenExcludeBounds), + "BetweenExcludeLeft" => Ok(HavingOperator::BetweenExcludeLeft), + "BetweenExcludeRight" => Ok(HavingOperator::BetweenExcludeRight), + other => Err(WasmSdkError::invalid_argument(format!( + "unsupported having operator `{other}`; expected one of '==', '>', '>=', '<', '<=', \ + 'Between', 'BetweenExcludeBounds', 'BetweenExcludeLeft', 'BetweenExcludeRight'. \ + '!=' and 'in' describe non-contiguous ranges and cannot be served as a having-range \ + read." + ))), + } +} + +/// True for the operators whose right-hand operand is a `[lower, upper]` +/// pair rather than a scalar. +fn is_between_operator(operator: HavingOperator) -> bool { + matches!( + operator, + HavingOperator::Between + | HavingOperator::BetweenExcludeBounds + | HavingOperator::BetweenExcludeLeft + | HavingOperator::BetweenExcludeRight + ) +} + +/// Build the one having clause from the JS bound. +/// +/// The clause's aggregate is *derived* from the select, never supplied +/// by the caller: the grammar requires the two to be equal, so letting +/// JS restate it would only create a failure mode with no upside. +fn having_clause_from_input( + select: &SelectProjection, + bound: &HavingBoundInput, +) -> Result { + let function = match select.function { + SelectFunction::Count => HavingAggregateFunction::Count, + SelectFunction::Sum => HavingAggregateFunction::Sum, + SelectFunction::Avg => HavingAggregateFunction::Avg, + other => { + return Err(WasmSdkError::invalid_argument(format!( + "{other:?} is not a having-range aggregate; expected count, sum or avg" + ))) + } + }; + + let operator = having_operator_from_str(&bound.operator)?; + + // rs-drive rejects a wrong operand shape too, but naming the JS + // field is a better message than a bounds-translation failure. + if is_between_operator(operator) + && !bound + .value + .as_array() + .map(|operands| operands.len() == 2) + .unwrap_or(false) + { + return Err(WasmSdkError::invalid_argument(format!( + "having operator `{}` needs `value: [lower, upper]` (exactly two operands)", + bound.operator + ))); + } + + Ok(HavingClause { + aggregate: HavingAggregate { + function, + field: select.field.clone(), + }, + operator, + right: HavingRightOperand::Value(json_to_platform_value(&bound.value)?), + }) +} + +/// Which ranked axis a projection reads. +fn axis_from_select(select: &SelectProjection) -> Result { + match select.function { + SelectFunction::Count => Ok(RankedAxis::Count), + SelectFunction::Sum => Ok(RankedAxis::Sum), + SelectFunction::Avg => Ok(RankedAxis::Avg), + other => Err(WasmSdkError::invalid_argument(format!( + "{other:?} is not a ranked axis; expected count, sum or avg" + ))), + } +} + +/// The `DocumentsAggregateKind` string for an axis. +fn axis_kind_str(axis: RankedAxis) -> &'static str { + match axis { + RankedAxis::Count => "count", + RankedAxis::Sum => "sum", + RankedAxis::Avg => "avg", + } +} + +/// Fixed-point divisor for an axis's entry values. +/// +/// Read from rs-drive's re-export of grovedb's constant, never written +/// as a literal: the average scale moved by four orders of magnitude +/// before release, and a JS caller dividing by a stale literal would get +/// plausible-looking wrong numbers rather than an error. +fn value_scale(axis: RankedAxis) -> i128 { + match axis { + RankedAxis::Avg => RANKED_AVG_SCALE, + RankedAxis::Count | RankedAxis::Sum => 1, + } +} + +/// The pagination triple both classifiers take, read off a built query. +fn pagination_inputs(query: &DocumentQuery) -> RankedPaginationInputs { + RankedPaginationInputs { + // `DocumentQuery::limit` uses `0` as the "unset" sentinel. + limit: (query.limit != 0).then_some(query.limit), + offset: query.offset, + has_start_at: query.start.is_some(), + } +} + +/// Re-run rs-drive's own versioned ranked grammar client-side. +/// +/// This is the same function the server's query table and the proof +/// verifier call — not a copy — so it cannot drift from either, and it +/// is versioned by `platform_version`, so an SDK built against one +/// protocol version cannot quietly accept a shape that version rejects. +fn assert_ranked_shape( + query: &DocumentQuery, + platform_version: &PlatformVersion, +) -> Result<(), WasmSdkError> { + detect_ranked_mode( + &query.select, + &query.group_by, + &query.having, + &query.order_by_clauses, + &query.where_clauses, + pagination_inputs(query), + platform_version, + ) + .map(|_| ()) + .map_err(|e| { + WasmSdkError::invalid_argument(format!( + "not a well-formed ranked query: {e}. A ranked query is \ + {{ groupBy, aggregate, limit }} plus optional {{ direction, offset, where }}; \ + `where` entries must be `==` pins on the covering compound index's leading \ + properties." + )) + }) +} + +/// Having-range counterpart of [`assert_ranked_shape`]. +fn assert_having_shape( + query: &DocumentQuery, + platform_version: &PlatformVersion, +) -> Result<(), WasmSdkError> { + detect_having_mode( + &query.select, + &query.group_by, + &query.having, + &query.order_by_clauses, + &query.where_clauses, + pagination_inputs(query), + platform_version, + ) + .map(|_| ()) + .map_err(|e| { + WasmSdkError::invalid_argument(format!( + "not a well-formed having-range query: {e}. A having-range query is \ + {{ groupBy, aggregate, having, limit }} plus optional {{ direction, where }}; \ + the bound must describe one contiguous range over the selected aggregate." + )) + }) +} + +/// Turn a `where` list into pins on an already-built query. +fn apply_index_pins( + mut query: DocumentQuery, + where_clauses: Option<&[JsonValue]>, +) -> Result { + for clause in where_clauses.unwrap_or(&[]) { + query = query.with_where(parse_where_clause(clause)?); + } + Ok(query) +} + +/// Everything about a ranked query except fetching the contract. +/// +/// Split out from the async parser so the whole surface is testable on +/// the host target — the async wrapper is a contract fetch plus +/// `DocumentQuery::new` and holds no decisions of its own. +fn apply_ranked_shape( + base: DocumentQuery, + input: &DocumentsRankedQueryInput, + platform_version: &PlatformVersion, +) -> Result { + let group_by = input.group_by.as_str(); + if group_by.is_empty() { + return Err(WasmSdkError::invalid_argument( + "groupBy must name the covering ranked index's trailing property", + )); + } + + let select = select_from_input(&input.aggregate)?; + let direction = ranking_direction(input.direction.as_deref(), RankingDirection::Descending)?; + + // Order matters. `order_by_selected_aggregate` derives the ordered + // field from the *current* select (the `$count` sentinel for + // COUNT(*), the field itself for SUM / AVG), so calling + // `with_select` after it would leave a stale field name and the + // server would refuse the request. Owning that sequence here is + // exactly why the JS surface has no `orderBy` and never mentions the + // sentinel. + let mut query = base + .with_select(select) + .with_group_by(group_by) + .order_by_selected_aggregate(direction) + .with_limit(input.limit); + + if let Some(offset) = input.offset { + query = query.with_offset(offset); + } + + let query = apply_index_pins(query, input.where_clauses.as_deref())?; + + assert_ranked_shape(&query, platform_version)?; + Ok(query) +} + +/// Having-range counterpart of [`apply_ranked_shape`]. +/// +/// The ordering clause is emitted only when the caller asked for a +/// direction: the having grammar makes `ORDER BY` optional and defaults +/// to ascending, and emitting a redundant clause would be one more thing +/// that has to agree with the select. +fn apply_having_shape( + base: DocumentQuery, + input: &DocumentsHavingQueryInput, + platform_version: &PlatformVersion, +) -> Result { + let group_by = input.group_by.as_str(); + if group_by.is_empty() { + return Err(WasmSdkError::invalid_argument( + "groupBy must name the covering ranked index's trailing property", + )); + } + + let select = select_from_input(&input.aggregate)?; + let having = having_clause_from_input(&select, &input.having)?; + + let mut query = base + .with_select(select) + .with_group_by(group_by) + .with_having(vec![having]) + .with_limit(input.limit); + + if input.direction.is_some() { + let direction = ranking_direction(input.direction.as_deref(), RankingDirection::Ascending)?; + query = query.order_by_selected_aggregate(direction); + } + + let query = apply_index_pins(query, input.where_clauses.as_deref())?; + + assert_having_shape(&query, platform_version)?; + Ok(query) +} + +async fn parse_documents_ranked_query( + sdk: &WasmSdk, + query: DocumentsRankedQueryJs, +) -> Result { + let input: DocumentsRankedQueryInput = + deserialize_required_query(query, "Query object is required", "documents ranked query")?; + + let contract = sdk + .get_or_fetch_contract(input.data_contract_id.into()) + .await?; + let base = DocumentQuery::new(contract, &input.document_type_name)?; + + apply_ranked_shape(base, &input, sdk.inner_sdk().version()) +} + +async fn parse_documents_having_query( + sdk: &WasmSdk, + query: DocumentsHavingQueryJs, +) -> Result { + let input: DocumentsHavingQueryInput = deserialize_required_query( + query, + "Query object is required", + "documents having-range query", + )?; + + let contract = sdk + .get_or_fetch_contract(input.data_contract_id.into()) + .await?; + let base = DocumentQuery::new(contract, &input.document_type_name)?; + + apply_having_shape(base, &input, sdk.inner_sdk().version()) +} + +/// One entry decoded as far as it can be without touching a JS type, so +/// that every decision in the result path is host-testable. +#[derive(Debug, Clone, PartialEq)] +struct GroupEntryParts { + /// Hex of the raw index-key bytes. Lossless, always produced. + key_hex: String, + /// The key decoded through the document type, when that succeeded. + decoded: Option, + /// Whether the key was empty — the write path's marker for an absent + /// optional group-by value, which is a different thing from a key + /// that failed to decode. + key_absent: bool, + /// The group's aggregate, straight off the verified page. + value: RankedEntryValue, +} + +/// Decode a verified page's entries. +/// +/// Key decoding is best effort by design: an index key that this +/// contract's decoder cannot read must not fail the whole query, because +/// `key_hex` still answers "which group" for anyone holding the same +/// grouping from the count / sum / average surfaces. +fn group_entry_parts( + entries: &[RankedEntry], + document_type: DocumentTypeRef, + group_by: &str, + platform_version: &PlatformVersion, +) -> Vec { + entries + .iter() + .map(|entry| { + let key_absent = entry.key.is_empty(); + let decoded = if key_absent { + None + } else { + document_type + .deserialize_value_for_key(group_by, entry.key.as_slice(), platform_version) + .ok() + }; + + GroupEntryParts { + key_hex: hex::encode(&entry.key), + decoded, + key_absent, + value: entry.value, + } + }) + .collect() +} + +/// `Reflect::set` on a freshly constructed object, which only fails on a +/// frozen target — the same `expect` convention the aggregate map +/// helpers in [`super::document`] use. +fn set_field(target: &Object, key: &str, value: &JsValue) { + Reflect::set(target, &JsValue::from_str(key), value) + .unwrap_or_else(|_| panic!("set {key} on fresh Object")); +} + +/// The aggregate as an exact `bigint`, whichever axis it came from. +fn entry_value_to_js(value: RankedEntryValue) -> JsValue { + match value { + RankedEntryValue::Count(count) => JsValue::from(count), + RankedEntryValue::Sum(sum) => JsValue::from(sum), + RankedEntryValue::AvgFixedPoint(avg) => JsValue::from(avg), + } +} + +/// Build one `DocumentsGroupEntry`, optionally carrying an absolute rank. +fn group_entry_to_js(parts: &GroupEntryParts, rank: Option) -> Result { + let entry = Object::new(); + + set_field(&entry, "groupKeyHex", &JsValue::from_str(&parts.key_hex)); + + let group_value = match (&parts.decoded, parts.key_absent) { + (Some(value), _) => platform_value_to_json(value).map_err(WasmSdkError::from)?, + // Empty key: the group-by value was absent, which is a real + // group with a known meaning, not a decode failure. + (None, true) => JsValue::NULL, + (None, false) => JsValue::UNDEFINED, + }; + set_field(&entry, "groupValue", &group_value); + + set_field(&entry, "value", &entry_value_to_js(parts.value)); + set_field( + &entry, + "valueAsNumber", + &JsValue::from_f64(parts.value.as_f64()), + ); + + if let Some(rank) = rank { + set_field(&entry, "rank", &JsValue::from(rank)); + } + + Ok(entry.into()) +} + +/// Assemble a `DocumentsRankedResult`. +fn ranked_result_to_js( + parts: &[GroupEntryParts], + starting_rank: u64, + axis: RankedAxis, + group_by: &str, +) -> Result { + let entries = Array::new(); + for (offset, entry) in parts.iter().enumerate() { + // `saturating_add` rather than `+`: a caller may legitimately ask + // for an offset near u32::MAX, and a rank that saturates is a + // better answer than a debug-build panic. + let rank = starting_rank.saturating_add(offset as u64); + entries.push(&group_entry_to_js(entry, Some(rank))?); + } + + let result = Object::new(); + set_field(&result, "startingRank", &JsValue::from(starting_rank)); + set_field(&result, "entries", &entries.into()); + set_field( + &result, + "aggregate", + &JsValue::from_str(axis_kind_str(axis)), + ); + set_field(&result, "groupBy", &JsValue::from_str(group_by)); + set_field(&result, "valueScale", &JsValue::from(value_scale(axis))); + + Ok(result) +} + +/// Assemble a `DocumentsHavingResult`. No rank: a value-bounded page has +/// no rank base to count from. +fn having_result_to_js( + parts: &[GroupEntryParts], + axis: RankedAxis, + group_by: &str, +) -> Result { + let entries = Array::new(); + for entry in parts { + entries.push(&group_entry_to_js(entry, None)?); + } + + let result = Object::new(); + set_field(&result, "entries", &entries.into()); + set_field( + &result, + "aggregate", + &JsValue::from_str(axis_kind_str(axis)), + ); + set_field(&result, "groupBy", &JsValue::from_str(group_by)); + set_field(&result, "valueScale", &JsValue::from(value_scale(axis))); + + Ok(result) +} + +/// What shaping the result needs, read off the query before `fetch` +/// consumes it. +struct ResultContext { + axis: RankedAxis, + group_by: String, + document_type_name: String, + data_contract: Arc, +} + +impl ResultContext { + fn from_query(query: &DocumentQuery) -> Result { + Ok(Self { + axis: axis_from_select(&query.select)?, + // Shape assertion already established there is exactly one. + group_by: query + .group_by + .first() + .cloned() + .ok_or_else(|| WasmSdkError::invalid_argument("groupBy is required"))?, + document_type_name: query.document_type_name.clone(), + data_contract: query.data_contract.clone(), + }) + } + + fn decode( + &self, + entries: &[RankedEntry], + platform_version: &PlatformVersion, + ) -> Result, WasmSdkError> { + let document_type = self + .data_contract + .document_type_for_name(&self.document_type_name) + .map_err(|e| WasmSdkError::not_found(format!("Document type not found: {e}")))?; + + Ok(group_entry_parts( + entries, + document_type, + &self.group_by, + platform_version, + )) + } +} + +#[wasm_bindgen] +impl WasmSdk { + /// The fixed-point divisor the `avg` axis sorts by. + /// + /// Exposed so a caller who persisted a `DocumentsGroupEntry.value` + /// can re-render it later without holding on to the result object + /// that produced it. Never hardcode the number — it is a build-time + /// constant that has already changed once. + #[wasm_bindgen(js_name = "rankedAverageScale", unchecked_return_type = "bigint")] + pub fn ranked_average_scale() -> JsValue { + JsValue::from(RANKED_AVG_SCALE) + } + + /// Hard ceiling on a ranked / having-range `limit`. A request above + /// it is rejected, not truncated. + #[wasm_bindgen(js_name = "maxRankedLimit")] + pub fn max_ranked_limit() -> u32 { + drive::query::MAX_RANKED_LIMIT as u32 + } + + /// Rank groups by an aggregate and return the top (or bottom) `n`. + /// + /// `SELECT GROUP BY ORDER BY + /// LIMIT n [OFFSET m]`, served from protocol version 14 against a + /// contract index that declares the matching `rankedCountable` / + /// `rankedSummable` / `rankedAverageable` keyword. A node on an + /// earlier protocol version, or a contract whose index does not opt + /// in, rejects the query and names what is missing. + /// + /// Entries come back in ranking order and must not be re-sorted; + /// `startingRank` plus the entry's position is its absolute rank. + #[wasm_bindgen( + js_name = "getDocumentsRanked", + unchecked_return_type = "DocumentsRankedResult" + )] + pub async fn get_documents_ranked( + &self, + query: DocumentsRankedQueryJs, + ) -> Result { + let query = parse_documents_ranked_query(self, query).await?; + let context = ResultContext::from_query(&query)?; + + // An empty ranking proves, so `None` is an empty page rather + // than an absent answer. + let page = DocumentRankedEntries::fetch(self.as_ref(), query) + .await? + .unwrap_or_default(); + + let parts = context.decode(&page.entries, self.inner_sdk().version())?; + ranked_result_to_js(&parts, page.starting_rank, context.axis, &context.group_by) + } + + /// [`Self::get_documents_ranked`] with the proof and block metadata + /// the answer was verified against. + #[wasm_bindgen( + js_name = "getDocumentsRankedWithProofInfo", + unchecked_return_type = "ProofMetadataResponseTyped" + )] + pub async fn get_documents_ranked_with_proof_info( + &self, + query: DocumentsRankedQueryJs, + ) -> Result { + let query = parse_documents_ranked_query(self, query).await?; + let context = ResultContext::from_query(&query)?; + + let (page, metadata, proof) = + DocumentRankedEntries::fetch_with_metadata_and_proof(self.as_ref(), query, None) + .await?; + let page = page.unwrap_or_default(); + + let parts = context.decode(&page.entries, self.inner_sdk().version())?; + let result = + ranked_result_to_js(&parts, page.starting_rank, context.axis, &context.group_by)?; + + Ok(ProofMetadataResponseWasm::from_sdk_parts( + result, metadata, proof, + )) + } + + /// Return the groups whose aggregate falls inside a bound. + /// + /// `SELECT GROUP BY HAVING + /// LIMIT n`, served from protocol version 14 against + /// the same ranked indexes [`Self::get_documents_ranked`] reads. + /// Verification covers completeness — an in-range group the node + /// omitted fails the proof. + /// + /// There is no offset and no cursor: continue a page by tightening + /// the bound, and size `limit` above the widest expected tie, since + /// a page cut inside a tie cannot be continued. + #[wasm_bindgen( + js_name = "getDocumentsHaving", + unchecked_return_type = "DocumentsHavingResult" + )] + pub async fn get_documents_having( + &self, + query: DocumentsHavingQueryJs, + ) -> Result { + let query = parse_documents_having_query(self, query).await?; + let context = ResultContext::from_query(&query)?; + + let page = DocumentHavingEntries::fetch(self.as_ref(), query) + .await? + .unwrap_or_default(); + + let parts = context.decode(&page.entries, self.inner_sdk().version())?; + having_result_to_js(&parts, context.axis, &context.group_by) + } + + /// [`Self::get_documents_having`] with the proof and block metadata + /// the answer was verified against. + #[wasm_bindgen( + js_name = "getDocumentsHavingWithProofInfo", + unchecked_return_type = "ProofMetadataResponseTyped" + )] + pub async fn get_documents_having_with_proof_info( + &self, + query: DocumentsHavingQueryJs, + ) -> Result { + let query = parse_documents_having_query(self, query).await?; + let context = ResultContext::from_query(&query)?; + + let (page, metadata, proof) = + DocumentHavingEntries::fetch_with_metadata_and_proof(self.as_ref(), query, None) + .await?; + let page = page.unwrap_or_default(); + + let parts = context.decode(&page.entries, self.inner_sdk().version())?; + let result = having_result_to_js(&parts, context.axis, &context.group_by)?; + + Ok(ProofMetadataResponseWasm::from_sdk_parts( + result, metadata, proof, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::prelude::DataContract; + use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use drive::query::{MAX_HAVING_LIMIT, MAX_RANKED_LIMIT, RANKED_COUNT_ORDER_KEY}; + use serde_json::json; + + /// DPNS stands in for any contract here. Every function under test is + /// shape-only — `detect_ranked_mode` / `detect_having_mode` read no + /// indexes — so the fixture only has to supply a real document type + /// with real property names. + const DOC_TYPE: &str = "domain"; + const GROUP_BY: &str = "normalizedLabel"; + + fn platform_version() -> &'static PlatformVersion { + PlatformVersion::latest() + } + + fn contract() -> DataContract { + load_system_data_contract(SystemDataContract::DPNS, platform_version()) + .expect("DPNS contract fixture should load") + } + + fn base_query() -> DocumentQuery { + DocumentQuery::new(contract(), DOC_TYPE).expect("DPNS declares a `domain` document type") + } + + fn count() -> AggregateSelectInput { + AggregateSelectInput { + kind: "count".to_string(), + property: None, + } + } + + fn avg(property: &str) -> AggregateSelectInput { + AggregateSelectInput { + kind: "avg".to_string(), + property: Some(property.to_string()), + } + } + + fn bound(operator: &str, value: JsonValue) -> HavingBoundInput { + HavingBoundInput { + operator: operator.to_string(), + value, + } + } + + /// The contract id is irrelevant to every shape rule under test — + /// `base_query()` already carries the real fixture contract. + fn any_contract_id() -> IdentifierWasm { + IdentifierWasm::from([1u8; 32]) + } + + fn ranked_input( + group_by: &str, + aggregate: AggregateSelectInput, + limit: u32, + direction: Option<&str>, + offset: Option, + where_clauses: Option>, + ) -> DocumentsRankedQueryInput { + DocumentsRankedQueryInput { + data_contract_id: any_contract_id(), + document_type_name: DOC_TYPE.to_string(), + group_by: group_by.to_string(), + aggregate, + limit, + direction: direction.map(str::to_string), + offset, + where_clauses, + } + } + + fn having_input( + aggregate: AggregateSelectInput, + bound: HavingBoundInput, + limit: u32, + direction: Option<&str>, + ) -> DocumentsHavingQueryInput { + DocumentsHavingQueryInput { + data_contract_id: any_contract_id(), + document_type_name: DOC_TYPE.to_string(), + group_by: GROUP_BY.to_string(), + aggregate, + having: bound, + limit, + direction: direction.map(str::to_string), + where_clauses: None, + } + } + + fn build_ranked(input: DocumentsRankedQueryInput) -> Result { + apply_ranked_shape(base_query(), &input, platform_version()) + } + + fn build_having(input: DocumentsHavingQueryInput) -> Result { + apply_having_shape(base_query(), &input, platform_version()) + } + + /// A ranked query with everything optional left unset. + fn ranked(aggregate: AggregateSelectInput, limit: u32) -> Result { + build_ranked(ranked_input(GROUP_BY, aggregate, limit, None, None, None)) + } + + /// A having-range query with everything optional left unset. + fn having( + aggregate: AggregateSelectInput, + bound: HavingBoundInput, + limit: u32, + ) -> Result { + build_having(having_input(aggregate, bound, limit, None)) + } + + // ---- ranked builder shape ------------------------------------------ + + /// The regression test for the ordering trap: because + /// `order_by_selected_aggregate` reads the *current* select, the + /// builder has to apply the select first or the ordered field name is + /// stale. A JS caller cannot get this wrong because they never spell + /// the ordering at all — this pins that the builder holds the sequence. + #[test] + fn ranked_avg_orders_by_the_aggregate_field() { + let query = ranked(avg("grade"), 3).expect("a well-formed avg ranking"); + + assert_eq!(query.order_by_clauses.len(), 1); + assert_eq!(query.order_by_clauses[0].field, "grade"); + assert!(!query.order_by_clauses[0].ascending); + } + + /// `COUNT(*)` has no field to order by, so the ranking orders by a + /// reserved sentinel. The sentinel is deliberately absent from the JS + /// surface, so this asserts against rs-drive's constant rather than a + /// literal. + #[test] + fn ranked_count_orders_by_the_count_sentinel() { + let query = ranked(count(), 3).expect("a well-formed count ranking"); + + assert_eq!(query.order_by_clauses.len(), 1); + assert_eq!(query.order_by_clauses[0].field, RANKED_COUNT_ORDER_KEY); + } + + #[test] + fn ranked_direction_defaults_to_descending() { + let query = ranked(count(), 3).expect("a well-formed count ranking"); + assert!(!query.order_by_clauses[0].ascending, "default is top-n"); + } + + #[test] + fn ranked_direction_asc_is_the_bottom_n_reading() { + let query = build_ranked(ranked_input(GROUP_BY, count(), 3, Some("asc"), None, None)) + .expect("ascending is a legitimate ranking"); + + assert!(query.order_by_clauses[0].ascending); + } + + #[test] + fn ranked_direction_garbage_is_rejected() { + let error = build_ranked(ranked_input( + GROUP_BY, + count(), + 3, + Some("descending"), + None, + None, + )) + .expect_err("only 'asc' and 'desc' are directions"); + + assert!(error.to_string().contains("'asc' or 'desc'")); + } + + #[test] + fn ranked_offset_is_carried_and_defaults_to_unset() { + let fifth_best = build_ranked(ranked_input(GROUP_BY, avg("grade"), 1, None, Some(4), None)) + .expect("limit 1 offset 4 is the fifth-best group"); + + assert_eq!(fifth_best.offset, Some(4)); + assert_eq!(fifth_best.limit, 1); + + assert_eq!(ranked(count(), 3).expect("no offset").offset, None); + } + + /// The limit ceiling is a hard reject rather than a clamp, and the + /// bound belongs to rs-drive — writing `100` here would let the two + /// drift apart silently. + #[test] + fn ranked_limit_is_bounded_by_max_ranked_limit() { + assert!( + ranked(count(), 0).is_err(), + "a ranking with no limit has no k" + ); + assert!( + ranked(count(), MAX_RANKED_LIMIT as u32).is_ok(), + "the ceiling itself is accepted" + ); + assert!( + ranked(count(), MAX_RANKED_LIMIT as u32 + 1).is_err(), + "past the ceiling is rejected, not truncated" + ); + } + + #[test] + fn ranked_count_with_a_property_is_rejected() { + let aggregate = AggregateSelectInput { + kind: "count".to_string(), + property: Some("grade".to_string()), + }; + let error = ranked(aggregate, 3).expect_err("COUNT(field) is not a ranked axis"); + + assert!(error.to_string().contains("COUNT(*)")); + } + + #[test] + fn ranked_sum_without_a_property_is_rejected() { + let aggregate = AggregateSelectInput { + kind: "sum".to_string(), + property: None, + }; + let error = ranked(aggregate, 3).expect_err("SUM() has no field to sum"); + + assert!(error + .to_string() + .contains("requires a non-empty `property`")); + } + + #[test] + fn ranked_unknown_aggregate_type_is_rejected() { + let aggregate = AggregateSelectInput { + kind: "median".to_string(), + property: Some("grade".to_string()), + }; + let error = ranked(aggregate, 3).expect_err("there are three axes"); + + assert!(error.to_string().contains("unknown aggregate type")); + } + + #[test] + fn ranked_empty_group_by_is_rejected() { + let error = build_ranked(ranked_input("", count(), 3, None, None, None)) + .expect_err("a ranking without a grouping ranks nothing"); + + assert!(error.to_string().contains("groupBy")); + } + + // ---- ranked index pins --------------------------------------------- + + #[test] + fn ranked_equality_pin_is_carried() { + let query = build_ranked(ranked_input( + GROUP_BY, + count(), + 3, + None, + None, + Some(vec![json!(["normalizedParentDomainName", "==", "dash"])]), + )) + .expect("one == pin on a leading property"); + + assert_eq!(query.where_clauses.len(), 1); + assert_eq!(query.where_clauses[0].field, "normalizedParentDomainName"); + assert_eq!( + query.where_clauses[0].value, + Value::Text("dash".to_string()) + ); + } + + /// A range cannot name one prefix value tree, so it cannot pin a + /// ranked read's prefix. + #[test] + fn ranked_non_equality_pin_is_rejected() { + let error = build_ranked(ranked_input( + GROUP_BY, + count(), + 3, + None, + None, + Some(vec![json!(["normalizedParentDomainName", ">", "dash"])]), + )) + .expect_err("a range cannot pin a prefix"); + + assert!(error.to_string().contains("ranked")); + } + + /// The write path gives an absent optional value its own prefix + /// subtree, and `null` is how a caller addresses it. + #[test] + fn ranked_null_pin_addresses_the_absent_value_prefix() { + let query = build_ranked(ranked_input( + GROUP_BY, + count(), + 3, + None, + None, + Some(vec![json!(["normalizedParentDomainName", "==", null])]), + )) + .expect("null is a legitimate pin"); + + assert_eq!(query.where_clauses[0].value, Value::Null); + } + + #[test] + fn ranked_repeated_pin_is_rejected() { + let error = build_ranked(ranked_input( + GROUP_BY, + count(), + 3, + None, + None, + Some(vec![ + json!(["normalizedParentDomainName", "==", "dash"]), + json!(["normalizedParentDomainName", "==", "dashpay"]), + ]), + )) + .expect_err("a property cannot be pinned to two values at once"); + + assert!(error.to_string().contains("ranked")); + } + + // ---- having-range builder shape ------------------------------------ + + /// The caller never restates the aggregate in the bound, so the + /// mismatch the server rejects is unwritable. This pins that the + /// clause really is derived from the select. + #[test] + fn having_clause_aggregate_mirrors_the_select() { + let query = + having(avg("grade"), bound(">", json!(3)), 10).expect("a well-formed avg bound"); + + assert_eq!(query.having.len(), 1); + assert_eq!( + query.having[0].aggregate, + HavingAggregate { + function: HavingAggregateFunction::Avg, + field: "grade".to_string(), + } + ); + assert_eq!(query.having[0].operator, HavingOperator::GreaterThan); + } + + #[test] + fn having_count_bound_carries_an_empty_field() { + let query = having(count(), bound(">", json!(100)), 10).expect("count bounds are legal"); + + assert_eq!( + query.having[0].aggregate, + HavingAggregate { + function: HavingAggregateFunction::Count, + field: String::new(), + } + ); + } + + #[test] + fn having_between_requires_two_operands() { + let error = + having(count(), bound("between", json!([1])), 10).expect_err("a range needs both ends"); + assert!(error.to_string().contains("[lower, upper]")); + + let query = having(count(), bound("between", json!([1, 2])), 10) + .expect("two operands is the shape"); + assert_eq!( + query.having[0].right, + HavingRightOperand::Value(Value::Array(vec![Value::I64(1), Value::I64(2)])) + ); + } + + /// Neither describes one contiguous range, and a having-range read is + /// exactly one contiguous slice of one axis secondary. + #[test] + fn having_rejects_non_contiguous_operators() { + for operator in ["!=", "in", "startsWith"] { + let error = match having(count(), bound(operator, json!(1)), 10) { + Ok(_) => panic!("`{operator}` should have been rejected"), + Err(error) => error.to_string(), + }; + + assert!( + error.contains("unsupported having operator"), + "`{operator}` should name the supported set; got: {error}" + ); + } + } + + #[test] + fn having_ordering_is_optional() { + let unordered = having(count(), bound(">", json!(100)), 10).expect("order by is optional"); + assert!(unordered.order_by_clauses.is_empty()); + + let ordered = build_having(having_input( + count(), + bound(">", json!(100)), + 10, + Some("desc"), + )) + .expect("a direction sets the walk order"); + + assert_eq!(ordered.order_by_clauses.len(), 1); + assert_eq!(ordered.order_by_clauses[0].field, RANKED_COUNT_ORDER_KEY); + assert!(!ordered.order_by_clauses[0].ascending); + } + + #[test] + fn having_limit_is_bounded_by_max_having_limit() { + assert!(having(count(), bound(">", json!(1)), 0).is_err()); + assert!(having(count(), bound(">", json!(1)), MAX_HAVING_LIMIT as u32).is_ok()); + assert!(having(count(), bound(">", json!(1)), MAX_HAVING_LIMIT as u32 + 1).is_err()); + } + + // ---- the JS input surface ------------------------------------------- + + /// Deserialize a JS-shaped object through the same path + /// `deserialize_required_query` uses, minus the JS types. + /// + /// The success value is discarded: the input structs cannot derive + /// `Debug` (`IdentifierWasm` does not implement it), and these cases + /// are all about what the deserializer *refuses*. + fn ranked_input_from(map: Vec<(&str, Value)>) -> Result<(), String> { + input_from::(map) + } + + fn having_input_from(map: Vec<(&str, Value)>) -> Result<(), String> { + input_from::(map) + } + + fn input_from(map: Vec<(&str, Value)>) -> Result<(), String> { + let value = Value::Map( + map.into_iter() + .map(|(key, value)| (Value::Text(key.to_string()), value)) + .collect(), + ); + dash_sdk::dpp::platform_value::from_value::(value) + .map(|_| ()) + .map_err(|e| e.to_string()) + } + + /// The expected mistake is pasting a `DocumentsQuery` into a ranked + /// call. Silently dropping its `orderBy` would answer a different + /// question under the default direction, so the input refuses it. + #[test] + fn ranked_input_rejects_a_pasted_order_by() { + let error = ranked_input_from(vec![ + ("orderBy", Value::Array(vec![])), + ("documentTypeName", Value::Text(DOC_TYPE.to_string())), + ("groupBy", Value::Text(GROUP_BY.to_string())), + ("limit", Value::U32(3)), + ]) + .expect_err("orderBy is not part of the ranked surface"); + + assert!(error.contains("orderBy"), "got: {error}"); + } + + #[test] + fn ranked_input_rejects_a_pasted_cursor() { + for cursor in ["startAt", "startAfter"] { + let error = ranked_input_from(vec![ + (cursor, Value::Identifier([7u8; 32])), + ("documentTypeName", Value::Text(DOC_TYPE.to_string())), + ("groupBy", Value::Text(GROUP_BY.to_string())), + ("limit", Value::U32(3)), + ]) + .unwrap_err(); + + assert!(error.contains(cursor), "got: {error}"); + } + } + + /// A having-range page has no rank base, so there is nothing for an + /// offset to skip from. A caller who expects offset pagination is told + /// rather than silently served page one. + #[test] + fn having_input_rejects_an_offset() { + let error = having_input_from(vec![ + ("offset", Value::U32(4)), + ("documentTypeName", Value::Text(DOC_TYPE.to_string())), + ("groupBy", Value::Text(GROUP_BY.to_string())), + ("limit", Value::U32(3)), + ]) + .expect_err("having-range has no offset"); + + assert!(error.contains("offset"), "got: {error}"); + } + + // ---- result shaping -------------------------------------------------- + + fn parts_for(entries: &[RankedEntry], group_by: &str) -> Vec { + let contract = contract(); + let document_type = contract + .document_type_for_name(DOC_TYPE) + .expect("DPNS declares a `domain` document type"); + + group_entry_parts(entries, document_type, group_by, platform_version()) + } + + /// The hex convention is what lets a ranked result be correlated with + /// a `getDocumentsCount` map over the same grouping. + #[test] + fn group_entry_parts_hex_matches_the_aggregate_map_convention() { + let entry = RankedEntry { + key: b"alice".to_vec(), + value: RankedEntryValue::Count(3), + }; + + let parts = parts_for(&[entry], GROUP_BY); + + assert_eq!(parts[0].key_hex, hex::encode(b"alice")); + assert!(!parts[0].key_absent); + } + + #[test] + fn group_entry_parts_decodes_a_string_group_key() { + let entry = RankedEntry { + key: b"alice".to_vec(), + value: RankedEntryValue::Count(3), + }; + + let parts = parts_for(&[entry], GROUP_BY); + + assert_eq!(parts[0].decoded, Some(Value::Text("alice".to_string()))); + } + + /// An empty key is the write path's marker for an absent optional + /// group-by value — a real group, distinct from a key that failed to + /// decode. + #[test] + fn group_entry_parts_marks_an_empty_key_absent() { + let entry = RankedEntry { + key: Vec::new(), + value: RankedEntryValue::Count(1), + }; + + let parts = parts_for(&[entry], GROUP_BY); + + assert!(parts[0].key_absent); + assert_eq!(parts[0].decoded, None); + assert_eq!(parts[0].key_hex, ""); + } + + /// Decoding is best effort: a key this contract's decoder cannot read + /// must not fail the whole query, because the hex still identifies the + /// group. + #[test] + fn group_entry_parts_survives_an_undecodable_key() { + let entry = RankedEntry { + // `$createdAt` decodes an 8-byte timestamp; two bytes is not one. + key: vec![0x01, 0x02], + value: RankedEntryValue::Count(1), + }; + + let parts = parts_for(&[entry], "$createdAt"); + + assert_eq!(parts[0].decoded, None); + assert!(!parts[0].key_absent); + assert_eq!(parts[0].key_hex, "0102"); + } + + /// The average scale is a build-time constant that has already moved + /// by four orders of magnitude. Anything that hardcodes it produces + /// plausible-looking wrong numbers rather than an error. + #[test] + fn value_scale_reads_the_builds_constant() { + assert_eq!(value_scale(RankedAxis::Avg), RANKED_AVG_SCALE); + assert_eq!(value_scale(RankedAxis::Count), 1); + assert_eq!(value_scale(RankedAxis::Sum), 1); + } + + #[test] + fn as_f64_divides_the_avg_variant_by_that_scale() { + let four = RankedEntryValue::AvgFixedPoint(RANKED_AVG_SCALE * 4); + assert_eq!(four.as_f64(), 4.0); + + assert_eq!(RankedEntryValue::Count(7).as_f64(), 7.0); + assert_eq!(RankedEntryValue::Sum(-7).as_f64(), -7.0); + } + + #[test] + fn axis_kind_strings_match_the_typescript_union() { + for (axis, expected) in [ + (RankedAxis::Count, "count"), + (RankedAxis::Sum, "sum"), + (RankedAxis::Avg, "avg"), + ] { + assert_eq!(axis_kind_str(axis), expected); + assert!( + typescript_source().contains(&format!("'{expected}'")), + "`{expected}` is emitted as an aggregate kind but absent from the \ + DocumentsAggregateKind union" + ); + } + } + + /// `#[wasm_bindgen(typescript_custom_section)]` consumes the const it + /// is attached to on non-wasm targets, so the declaration text is the + /// only readable form — and it is the thing under test anyway. + fn typescript_source() -> String { + const DECLARATION: &str = "const DOCUMENTS_RANKED_QUERY_TS"; + let source = include_str!("document_ranked.rs"); + let start = source + .find(DECLARATION) + .expect("this module declares a TypeScript custom section"); + source[start..].to_string() + } + + /// The JSDoc states the limit ceiling as a number a caller reads and + /// relies on. If rs-drive's constant moves, the prose has to move with + /// it. + #[test] + fn typescript_docs_quote_the_real_limit_ceiling() { + let source = typescript_source(); + let quoted = format!("1 <= limit <= {MAX_RANKED_LIMIT}"); + + assert!( + source.contains("ed), + "the ranked/having JSDoc should state the ceiling as `{quoted}`" + ); + assert_eq!( + MAX_RANKED_LIMIT, MAX_HAVING_LIMIT, + "the docs state one ceiling for both surfaces; split the prose if these diverge" + ); + } +} diff --git a/packages/wasm-sdk/src/queries/mod.rs b/packages/wasm-sdk/src/queries/mod.rs index daa10d61bd9..bcccfc7af19 100644 --- a/packages/wasm-sdk/src/queries/mod.rs +++ b/packages/wasm-sdk/src/queries/mod.rs @@ -1,6 +1,7 @@ pub mod address; pub mod data_contract; pub mod document; +pub mod document_ranked; pub mod epoch; pub mod group; pub mod identity; diff --git a/packages/wasm-sdk/tests/unit/data-contract.spec.ts b/packages/wasm-sdk/tests/unit/data-contract.spec.ts index afdae477f92..da39901b9cf 100644 --- a/packages/wasm-sdk/tests/unit/data-contract.spec.ts +++ b/packages/wasm-sdk/tests/unit/data-contract.spec.ts @@ -532,4 +532,30 @@ describe('DataContract', () => { contract2.free(); }); }); + + /** + * The `refersTo` surface is implemented in wasm-dpp2 and reaches this + * package through `pub use wasm_dpp2::*`. Behaviour is covered by + * wasm-dpp2's own suite; what this pins is the re-export, so that + * narrowing it fails loudly here rather than silently in a consumer. + */ + describe('document reference metadata re-export', () => { + it('should expose the refersTo accessors on DataContract', () => { + const contract = sdk.DataContract.fromJSON( + contractFixtureV1, + true, + PLATFORM_VERSION_CONTRACT_V1, + ); + + expect(contract.documentTypeReferences).to.be.a('function'); + expect(contract.documentReferences).to.be.instanceOf(Map); + + contract.free(); + }); + + it('should expose the reference consensus error codes', () => { + expect(sdk.DocumentReferenceErrorCode.ReferencedEntityNotFound).to.equal(40120); + expect(sdk.DocumentReferenceErrorCode.ReferencedKeyIdPropertyInvalid).to.equal(40125); + }); + }); }); From 077f2f532d121917f5e8fc8d9ebd70a94e09e85e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 22 Aug 2026 15:25:23 +0700 Subject: [PATCH 2/7] fix(wasm-sdk): keep wide integer group keys exact at the JS boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ranked or having-range group key decodes to whatever the indexed property's declared type is, and `u64` / `i64` / `u128` / `i128` are all reachable — `decode_value_for_tree_keys` returns them for the correspondingly typed properties, and a `Date` group key decodes to `Value::U64`. Those were routed through the document JSON conversion, which targets a JS `number` and *errors* past `Number.MAX_SAFE_INTEGER` rather than rounding (`serialize_u64` / `serialize_i64` under `json_compatible`); `u128` and `i128` fail earlier still, inside `serde_json`. So a single large group key rejected an entire verified page instead of returning the documented lossless `groupKeyHex` fallback. Those four widths now cross as exact `BigInt`s. Narrower integer types keep the `number` representation the rest of the document JSON surface uses, so the JS type follows the property's declared type rather than the magnitude of any particular value. Classification is split into `group_value_repr` so it can be asserted from host tests — the rendering half touches `js_sys` and is unreachable off-wasm. Also adds `EvoSDK.maxRankedLimit()` and `EvoSDK.rankedAverageScale()`. The README pointed at the former as an `EvoSDK` member, but `maxRankedLimit` was generated only as a static on `WasmSdk`, so the documented call was a TypeError. Both now forward through the same initialization-aware pattern `getLatestVersionNumber` uses, which is the surface the README described. Covered by three host tests over the classification, including the variants the WASM-runtime spec cannot express, and a new `ranked-group-value.spec.ts` exercising the JS boundary through a test-only export — the only way to prove a key past 2^53 comes back exact rather than throwing. Co-Authored-By: Claude Opus 5 --- packages/js-evo-sdk/README.md | 6 +- packages/js-evo-sdk/src/sdk.ts | 20 +++ packages/js-evo-sdk/tests/unit/sdk.spec.ts | 20 +++ .../wasm-sdk/src/queries/document_ranked.rs | 149 +++++++++++++++++- .../tests/unit/ranked-group-value.spec.ts | 74 +++++++++ 5 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 packages/wasm-sdk/tests/unit/ranked-group-value.spec.ts diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 478b5146a58..0d440f7fb2e 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -69,10 +69,12 @@ const local = EvoSDK.devnet('paloma', { await local.connect(); ``` -Two static helpers are also exported: +Static helpers are also exported: - `await EvoSDK.setLogLevel(filter)` — configure the underlying Wasm SDK's tracing globally. - `await EvoSDK.getLatestVersionNumber()` — return the latest Platform protocol version supported by the bundled Wasm SDK. +- `await EvoSDK.maxRankedLimit()` — the hard ceiling on a [ranked / having-range](#ranked-queries) `limit`. +- `await EvoSDK.rankedAverageScale()` — the fixed-point divisor for the `avg` axis of a ranked / having-range result. ## Facades @@ -117,7 +119,7 @@ for (const entry of page.entries) { } ``` -`limit` is required and capped at `EvoSDK`'s `maxRankedLimit()` (a hard reject, not a clamp). `offset` skips ranks — `{ limit: 1, offset: 4 }` is "the 5th best" — and has no ceiling, because the skipped region is attested rather than walked. +`limit` is required and capped at `await EvoSDK.maxRankedLimit()` (a hard reject, not a clamp). `offset` skips ranks — `{ limit: 1, offset: 4 }` is "the 5th best" — and has no ceiling, because the skipped region is attested rather than walked. `sdk.documents.having()` bounds the same axis by value instead of by position (`{ operator: '>', value: 100 }`), and `rankedWithProof` / `havingWithProof` return the proof and block metadata alongside the result. diff --git a/packages/js-evo-sdk/src/sdk.ts b/packages/js-evo-sdk/src/sdk.ts index 9cf8aba176f..362f67106a2 100644 --- a/packages/js-evo-sdk/src/sdk.ts +++ b/packages/js-evo-sdk/src/sdk.ts @@ -223,6 +223,26 @@ export class EvoSDK { return wasm.WasmSdkBuilder.getLatestVersionNumber(); } + /** + * Hard ceiling on a ranked / having-range `limit`. A request above it is + * rejected, not truncated. + */ + static async maxRankedLimit(): Promise { + await initWasm(); + return wasm.WasmSdk.maxRankedLimit(); + } + + /** + * Fixed-point divisor for the `avg` axis of a ranked / having-range + * result. Exposed so a caller who persisted a `DocumentsGroupEntry.value` + * can re-render it without holding on to the result that produced it. + * Never hardcode the number. + */ + static async rankedAverageScale(): Promise { + await initWasm(); + return wasm.WasmSdk.rankedAverageScale(); + } + // Factory helpers that return configured instances (not connected) static testnet(options: ConnectionOptions = {}): EvoSDK { return new EvoSDK({ network: 'testnet', ...options }); } static mainnet(options: ConnectionOptions = {}): EvoSDK { return new EvoSDK({ network: 'mainnet', ...options }); } diff --git a/packages/js-evo-sdk/tests/unit/sdk.spec.ts b/packages/js-evo-sdk/tests/unit/sdk.spec.ts index b9ea275fc0f..2717dfa2fa3 100644 --- a/packages/js-evo-sdk/tests/unit/sdk.spec.ts +++ b/packages/js-evo-sdk/tests/unit/sdk.spec.ts @@ -301,4 +301,24 @@ describe('EvoSDK', () => { expect(sdk.options.devnetName).to.be.undefined(); }); }); + + describe('ranked query constants', () => { + // Documented in the README as the way to discover the ceiling, so the + // forwarding statics have to actually exist on EvoSDK. + it('should expose the ranked limit ceiling', async () => { + const limit = await EvoSDK.maxRankedLimit(); + + expect(limit).to.be.a('number'); + expect(limit).to.be.greaterThan(0); + }); + + it('should expose the avg fixed-point scale as a bigint', async () => { + // Returned rather than documented as a literal precisely so callers + // never hardcode it — it has already changed once. + const scale = await EvoSDK.rankedAverageScale(); + + expect(typeof scale).to.equal('bigint'); + expect(scale > BigInt(0)).to.equal(true); + }); + }); }); diff --git a/packages/wasm-sdk/src/queries/document_ranked.rs b/packages/wasm-sdk/src/queries/document_ranked.rs index 5abc801f8d8..a3712adc99c 100644 --- a/packages/wasm-sdk/src/queries/document_ranked.rs +++ b/packages/wasm-sdk/src/queries/document_ranked.rs @@ -47,7 +47,7 @@ use std::sync::Arc; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; use wasm_dpp2::identifier::IdentifierWasm; -use wasm_dpp2::serialization::conversions::platform_value_to_json; +use wasm_dpp2::serialization::conversions::{js_value_to_platform_value, platform_value_to_json}; #[wasm_bindgen(typescript_custom_section)] const DOCUMENTS_RANKED_QUERY_TS: &'static str = r#" @@ -877,6 +877,89 @@ fn entry_value_to_js(value: RankedEntryValue) -> JsValue { } } +/// The integer widths that can leave JavaScript's safe-integer range, and +/// therefore have to cross as exact `BigInt`s. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExactIntegerKey { + U64(u64), + I64(i64), + U128(u128), + I128(i128), +} + +/// How a decoded group key crosses to JS. +/// +/// Classification lives here, in one place, so that +/// [`group_value_to_js`] cannot drift from what the host tests assert — +/// the rendering half touches `js_sys` and so is unreachable off-wasm. +// Not `Eq`: `Value`'s float variant keeps it at `PartialEq`. +#[derive(Debug, Clone, Copy, PartialEq)] +enum GroupValueRepr<'a> { + /// An exact `BigInt`. + ExactBigInt(ExactIntegerKey), + /// The document JSON convention: base58 identifiers, base64 bytes, + /// `number` for the narrower integer types, strings and bools as-is. + DocumentJson(&'a Value), +} + +/// Decide how a decoded group key should cross to JS. +/// +/// `u64`, `i64`, `u128` and `i128` are pulled out of the JSON conversion +/// because that conversion targets a JS `number` and *errors* past +/// `Number.MAX_SAFE_INTEGER` (`serialize_u64` / `serialize_i64` under +/// `json_compatible`), so a single large group key would otherwise reject a +/// whole verified page — and `u128` / `i128` fail earlier still, inside +/// `serde_json`. All four are reachable: +/// `DocumentPropertyType::decode_value_for_tree_keys` returns them for the +/// correspondingly typed properties, and a `Date` group key decodes to +/// `Value::U64`. +/// +/// A group key is an identity, not an arithmetic operand, so exactness +/// matters more than the convenience of a `number`. The narrower integer +/// types keep the `number` representation the rest of the document JSON +/// surface uses, which means the JS type follows the property's *declared +/// type* rather than the magnitude of any particular value — a `u64` +/// property is always `bigint`, a `u32` property is always `number`. +fn group_value_repr(value: &Value) -> GroupValueRepr<'_> { + match value { + Value::U64(inner) => GroupValueRepr::ExactBigInt(ExactIntegerKey::U64(*inner)), + Value::I64(inner) => GroupValueRepr::ExactBigInt(ExactIntegerKey::I64(*inner)), + Value::U128(inner) => GroupValueRepr::ExactBigInt(ExactIntegerKey::U128(*inner)), + Value::I128(inner) => GroupValueRepr::ExactBigInt(ExactIntegerKey::I128(*inner)), + other => GroupValueRepr::DocumentJson(other), + } +} + +/// A decoded group key as a JS value. See [`group_value_repr`] for why the +/// wide integer types bypass the JSON conversion. +fn group_value_to_js(value: &Value) -> Result { + match group_value_repr(value) { + GroupValueRepr::ExactBigInt(ExactIntegerKey::U64(inner)) => Ok(JsValue::from(inner)), + GroupValueRepr::ExactBigInt(ExactIntegerKey::I64(inner)) => Ok(JsValue::from(inner)), + GroupValueRepr::ExactBigInt(ExactIntegerKey::U128(inner)) => Ok(JsValue::from(inner)), + GroupValueRepr::ExactBigInt(ExactIntegerKey::I128(inner)) => Ok(JsValue::from(inner)), + GroupValueRepr::DocumentJson(inner) => { + platform_value_to_json(inner).map_err(WasmSdkError::from) + } + } +} + +/// Test-only: run a value through the same conversion `getDocumentsRanked` +/// and `getDocumentsHaving` apply to a `groupValue`. +/// +/// Exported because the interesting cases live at the JS boundary and a +/// host-target test cannot reach them — `js_sys` panics off-wasm, so only a +/// WASM-runtime spec can prove that an integer past +/// `Number.MAX_SAFE_INTEGER` comes back exact instead of throwing. Mirrors +/// `testJsValueToJson` in wasm-dpp2, which exists for the same reason. +/// +/// Not part of the supported API surface. +#[wasm_bindgen(js_name = "testRankedGroupValue")] +pub fn test_ranked_group_value(value: JsValue) -> Result { + let decoded = js_value_to_platform_value(&value).map_err(WasmSdkError::from)?; + group_value_to_js(&decoded) +} + /// Build one `DocumentsGroupEntry`, optionally carrying an absolute rank. fn group_entry_to_js(parts: &GroupEntryParts, rank: Option) -> Result { let entry = Object::new(); @@ -884,7 +967,7 @@ fn group_entry_to_js(parts: &GroupEntryParts, rank: Option) -> Result platform_value_to_json(value).map_err(WasmSdkError::from)?, + (Some(value), _) => group_value_to_js(value)?, // Empty key: the group-by value was absent, which is a real // group with a known meaning, not a decode failure. (None, true) => JsValue::NULL, @@ -1696,6 +1779,68 @@ mod tests { assert_eq!(parts[0].key_hex, "0102"); } + /// The four widths that can leave JavaScript's safe-integer range have + /// to cross as exact `BigInt`s — the JSON conversion errors on them + /// rather than rounding, so a single large group key used to reject a + /// whole verified page. + #[test] + fn wide_integer_group_keys_cross_as_exact_bigints() { + let cases = [ + (Value::U64(u64::MAX), ExactIntegerKey::U64(u64::MAX)), + (Value::I64(i64::MIN), ExactIntegerKey::I64(i64::MIN)), + (Value::U128(u128::MAX), ExactIntegerKey::U128(u128::MAX)), + (Value::I128(i128::MIN), ExactIntegerKey::I128(i128::MIN)), + ]; + + for (value, expected) in cases { + assert_eq!( + group_value_repr(&value), + GroupValueRepr::ExactBigInt(expected), + "{value:?} must not go through the JSON conversion" + ); + } + } + + /// A `Date` group key decodes to `Value::U64`, so timestamps take the + /// exact path too rather than depending on staying under 2^53. + #[test] + fn a_date_group_key_crosses_as_an_exact_bigint() { + assert_eq!( + group_value_repr(&Value::U64(1_760_000_000_000)), + GroupValueRepr::ExactBigInt(ExactIntegerKey::U64(1_760_000_000_000)) + ); + } + + /// Everything narrow enough to be lossless as a JS `number`, and + /// everything non-numeric, keeps the document JSON convention — so the + /// JS type follows the property's declared type, not the size of one + /// value. These variants are unreachable from the WASM-runtime spec, + /// whose input conversion normalizes every JS number to `i64`. + #[test] + fn narrow_and_non_numeric_group_keys_keep_the_json_convention() { + let cases = [ + Value::U8(7), + Value::U16(7), + Value::U32(7), + Value::I8(-7), + Value::I16(-7), + Value::I32(-7), + Value::Float(1.5), + Value::Text("alice".to_string()), + Value::Bool(true), + Value::Identifier([3u8; 32]), + Value::Bytes(vec![1, 2, 3]), + Value::Null, + ]; + + for value in &cases { + assert!( + matches!(group_value_repr(value), GroupValueRepr::DocumentJson(_)), + "{value:?} should keep the document JSON representation" + ); + } + } + /// The average scale is a build-time constant that has already moved /// by four orders of magnitude. Anything that hardcodes it produces /// plausible-looking wrong numbers rather than an error. diff --git a/packages/wasm-sdk/tests/unit/ranked-group-value.spec.ts b/packages/wasm-sdk/tests/unit/ranked-group-value.spec.ts new file mode 100644 index 00000000000..02358127ac9 --- /dev/null +++ b/packages/wasm-sdk/tests/unit/ranked-group-value.spec.ts @@ -0,0 +1,74 @@ +/** + * Regression coverage for the `groupValue` conversion on ranked and + * having-range results. + * + * A group key decodes to whatever the indexed property's declared type is, + * and `u64` / `i64` / `u128` / `i128` properties are all reachable (a `Date` + * group key decodes to `u64` too). Routing those through the JSON + * conversion targets a JS `number`, which *errors* past + * `Number.MAX_SAFE_INTEGER` rather than rounding — so one large group key + * used to reject an entire verified page. + * + * These cases live at the JS boundary, which a host-target Rust test cannot + * reach, hence the `testRankedGroupValue` export. + * + * Scope note: the helper's input conversion normalizes every JS number to + * `i64` and cannot represent a BigInt outside the `i64::MIN..u64::MAX` + * range, so the narrower integer types (and `u128` / `i128`) are not + * expressible here. Their classification is covered by the Rust host tests + * in `document_ranked.rs`; what this spec pins is the boundary behaviour + * those tests cannot observe. + */ +import { expect } from './helpers/chai.ts'; +import init, * as sdk from '../../dist/sdk.compressed.js'; + +describe('ranked groupValue conversion', () => { + before(async () => { + await init(); + }); + + describe('integers past the safe-integer range', () => { + it('should return an exact bigint just above Number.MAX_SAFE_INTEGER', () => { + const key = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(2); + + const groupValue = sdk.testRankedGroupValue(key); + + expect(typeof groupValue).to.equal('bigint'); + expect(groupValue).to.equal(key); + }); + + it('should return an exact bigint at the top of the u64 range', () => { + // A `u64` group key can legitimately reach here — token amounts and + // credit balances are the obvious cases. + const key = BigInt('18446744073709551615'); + + const groupValue = sdk.testRankedGroupValue(key); + + expect(groupValue).to.equal(key); + }); + + it('should return an exact bigint for a large negative i64 key', () => { + const key = -(BigInt(Number.MAX_SAFE_INTEGER) + BigInt(2)); + + const groupValue = sdk.testRankedGroupValue(key); + + expect(groupValue).to.equal(key); + }); + + it('should not throw for a key that has no lossless number representation', () => { + // The reported failure: the whole page rejected because one group's + // key was large. Assert the absence of the throw explicitly. + expect(() => sdk.testRankedGroupValue(BigInt('9007199254740993'))).to.not.throw(); + }); + }); + + describe('everything else keeps the document JSON convention', () => { + it('should return a string group key as a string', () => { + expect(sdk.testRankedGroupValue('alice')).to.equal('alice'); + }); + + it('should return a boolean group key as a boolean', () => { + expect(sdk.testRankedGroupValue(true)).to.equal(true); + }); + }); +}); From d50a2f6376ef65432dad549f03eb299f0d642042 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 22 Aug 2026 15:32:26 +0700 Subject: [PATCH 3/7] docs(sdk): list the new evo-sdk README sections in its table of contents The ranked-queries and document-references sections were added as top-level headings without updating the table of contents above them. Co-Authored-By: Claude Opus 5 --- packages/js-evo-sdk/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 0d440f7fb2e..e4128ddddd6 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -14,6 +14,8 @@ Evo SDK provides a high-level, strongly-typed interface for interacting with [Da - [Install](#install) - [Usage](#usage) - [Facades](#facades) +- [Ranked queries](#ranked-queries) +- [Document references (`refersTo`)](#document-references-refersto) - [Contributing](#contributing) - [License](#license) From 2347ba3745fa6890798dd337a6407db69634224b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 22 Aug 2026 17:13:05 +0700 Subject: [PATCH 4/7] refactor(wasm-sdk): drop the test-only ranked export in favour of a type-level invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `testRankedGroupValue` was compiled unconditionally, so wasm-bindgen put it in the production bundle and the generated TypeScript declarations, and both wasm-sdk and js-evo-sdk re-export every generated symbol. A doc comment saying it was unsupported did nothing to stop a consumer depending on it. Rather than gate it behind a feature the test build would have to enable — which would leave the spec unrunnable against a normally-built `dist/` — the property it was checking is now enforced by a signature. `exact_integer_to_js` returns `JsValue` rather than `Result`, so "a wide integer group key can never reject the page it belongs to" holds by construction: making any of those arms fallible would not compile. The only fallible arm left is the JSON conversion, which wide integers no longer reach. That is stronger than the spec was. Classification is the half that can regress, and it stays covered by the three host tests over all twelve `Value` variants — including the narrow integers and `u128` / `i128` the WASM-runtime spec could not express anyway, since its input conversion normalized every JS number to `i64`. The residual the spec did cover is wasm-bindgen's own `JsValue::from` for primitive integers, which the existing evo-sdk specs already exercise through `entry.value`. Co-Authored-By: Claude Opus 5 --- .../wasm-sdk/src/queries/document_ranked.rs | 45 +++++------ .../tests/unit/ranked-group-value.spec.ts | 74 ------------------- 2 files changed, 24 insertions(+), 95 deletions(-) delete mode 100644 packages/wasm-sdk/tests/unit/ranked-group-value.spec.ts diff --git a/packages/wasm-sdk/src/queries/document_ranked.rs b/packages/wasm-sdk/src/queries/document_ranked.rs index a3712adc99c..82330e4353c 100644 --- a/packages/wasm-sdk/src/queries/document_ranked.rs +++ b/packages/wasm-sdk/src/queries/document_ranked.rs @@ -47,7 +47,7 @@ use std::sync::Arc; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; use wasm_dpp2::identifier::IdentifierWasm; -use wasm_dpp2::serialization::conversions::{js_value_to_platform_value, platform_value_to_json}; +use wasm_dpp2::serialization::conversions::platform_value_to_json; #[wasm_bindgen(typescript_custom_section)] const DOCUMENTS_RANKED_QUERY_TS: &'static str = r#" @@ -930,36 +930,33 @@ fn group_value_repr(value: &Value) -> GroupValueRepr<'_> { } } +/// Render a wide integer key as an exact `BigInt`. +/// +/// Returns `JsValue`, not `Result`, and that is the point: "a +/// large group key can never reject the page it belongs to" is the property +/// this whole classification exists to provide, so it is enforced by the +/// signature rather than left to a test to notice. Making any of these arms +/// fallible would not compile without changing this return type. +fn exact_integer_to_js(key: ExactIntegerKey) -> JsValue { + match key { + ExactIntegerKey::U64(inner) => JsValue::from(inner), + ExactIntegerKey::I64(inner) => JsValue::from(inner), + ExactIntegerKey::U128(inner) => JsValue::from(inner), + ExactIntegerKey::I128(inner) => JsValue::from(inner), + } +} + /// A decoded group key as a JS value. See [`group_value_repr`] for why the /// wide integer types bypass the JSON conversion. fn group_value_to_js(value: &Value) -> Result { match group_value_repr(value) { - GroupValueRepr::ExactBigInt(ExactIntegerKey::U64(inner)) => Ok(JsValue::from(inner)), - GroupValueRepr::ExactBigInt(ExactIntegerKey::I64(inner)) => Ok(JsValue::from(inner)), - GroupValueRepr::ExactBigInt(ExactIntegerKey::U128(inner)) => Ok(JsValue::from(inner)), - GroupValueRepr::ExactBigInt(ExactIntegerKey::I128(inner)) => Ok(JsValue::from(inner)), + GroupValueRepr::ExactBigInt(key) => Ok(exact_integer_to_js(key)), GroupValueRepr::DocumentJson(inner) => { platform_value_to_json(inner).map_err(WasmSdkError::from) } } } -/// Test-only: run a value through the same conversion `getDocumentsRanked` -/// and `getDocumentsHaving` apply to a `groupValue`. -/// -/// Exported because the interesting cases live at the JS boundary and a -/// host-target test cannot reach them — `js_sys` panics off-wasm, so only a -/// WASM-runtime spec can prove that an integer past -/// `Number.MAX_SAFE_INTEGER` comes back exact instead of throwing. Mirrors -/// `testJsValueToJson` in wasm-dpp2, which exists for the same reason. -/// -/// Not part of the supported API surface. -#[wasm_bindgen(js_name = "testRankedGroupValue")] -pub fn test_ranked_group_value(value: JsValue) -> Result { - let decoded = js_value_to_platform_value(&value).map_err(WasmSdkError::from)?; - group_value_to_js(&decoded) -} - /// Build one `DocumentsGroupEntry`, optionally carrying an absolute rank. fn group_entry_to_js(parts: &GroupEntryParts, rank: Option) -> Result { let entry = Object::new(); @@ -1783,6 +1780,12 @@ mod tests { /// to cross as exact `BigInt`s — the JSON conversion errors on them /// rather than rounding, so a single large group key used to reject a /// whole verified page. + /// + /// Classification is the half that can regress; the rendering half is + /// infallible by signature ([`exact_integer_to_js`] returns `JsValue`, + /// not `Result`), so pinning the classification here is what guarantees + /// a wide key cannot throw. `js_sys` panics off-wasm, which is why the + /// two halves are split at all. #[test] fn wide_integer_group_keys_cross_as_exact_bigints() { let cases = [ diff --git a/packages/wasm-sdk/tests/unit/ranked-group-value.spec.ts b/packages/wasm-sdk/tests/unit/ranked-group-value.spec.ts deleted file mode 100644 index 02358127ac9..00000000000 --- a/packages/wasm-sdk/tests/unit/ranked-group-value.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Regression coverage for the `groupValue` conversion on ranked and - * having-range results. - * - * A group key decodes to whatever the indexed property's declared type is, - * and `u64` / `i64` / `u128` / `i128` properties are all reachable (a `Date` - * group key decodes to `u64` too). Routing those through the JSON - * conversion targets a JS `number`, which *errors* past - * `Number.MAX_SAFE_INTEGER` rather than rounding — so one large group key - * used to reject an entire verified page. - * - * These cases live at the JS boundary, which a host-target Rust test cannot - * reach, hence the `testRankedGroupValue` export. - * - * Scope note: the helper's input conversion normalizes every JS number to - * `i64` and cannot represent a BigInt outside the `i64::MIN..u64::MAX` - * range, so the narrower integer types (and `u128` / `i128`) are not - * expressible here. Their classification is covered by the Rust host tests - * in `document_ranked.rs`; what this spec pins is the boundary behaviour - * those tests cannot observe. - */ -import { expect } from './helpers/chai.ts'; -import init, * as sdk from '../../dist/sdk.compressed.js'; - -describe('ranked groupValue conversion', () => { - before(async () => { - await init(); - }); - - describe('integers past the safe-integer range', () => { - it('should return an exact bigint just above Number.MAX_SAFE_INTEGER', () => { - const key = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(2); - - const groupValue = sdk.testRankedGroupValue(key); - - expect(typeof groupValue).to.equal('bigint'); - expect(groupValue).to.equal(key); - }); - - it('should return an exact bigint at the top of the u64 range', () => { - // A `u64` group key can legitimately reach here — token amounts and - // credit balances are the obvious cases. - const key = BigInt('18446744073709551615'); - - const groupValue = sdk.testRankedGroupValue(key); - - expect(groupValue).to.equal(key); - }); - - it('should return an exact bigint for a large negative i64 key', () => { - const key = -(BigInt(Number.MAX_SAFE_INTEGER) + BigInt(2)); - - const groupValue = sdk.testRankedGroupValue(key); - - expect(groupValue).to.equal(key); - }); - - it('should not throw for a key that has no lossless number representation', () => { - // The reported failure: the whole page rejected because one group's - // key was large. Assert the absence of the throw explicitly. - expect(() => sdk.testRankedGroupValue(BigInt('9007199254740993'))).to.not.throw(); - }); - }); - - describe('everything else keeps the document JSON convention', () => { - it('should return a string group key as a string', () => { - expect(sdk.testRankedGroupValue('alice')).to.equal('alice'); - }); - - it('should return a boolean group key as a boolean', () => { - expect(sdk.testRankedGroupValue(true)).to.equal(true); - }); - }); -}); From c3e25f35c140be0544b687647e24e09f7362f9b3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 22 Aug 2026 20:59:12 +0700 Subject: [PATCH 5/7] test: pin wide-integer exactness on wasm32 and derive error codes from DPP Two gaps in the tests, both real. The type-level invariant added in 2347ba3 guarantees the wide-integer path cannot fail, but a signature cannot pin *representation*: swapping `exact_integer_to_js` to `JsValue::from_f64(inner as f64)` would still compile, still be infallible, and still pass every host test, while silently rounding group keys past 2^53. Adds `#[wasm_bindgen_test]` cases that run on the wasm32 target against the same function the production result path calls, asserting both `typeof === 'bigint'` and exact equality, plus one case covering the whole entry conversion. No exported test hook: the tests live behind `cfg(all(test, target_arch = "wasm32"))` and the dependency is scoped to that target, so `dist/sdk.d.ts` and `dist/sdk.js` are unchanged. Verified against the described mutation: with the `from_f64` body the host suite still reports 35 passed while the wasm suite fails 2. The consensus-error test compared the enum and `from_code` against the same literals both were implemented from, so it would have kept passing if two names were consistently assigned each other's protocol code. It now builds the six real DPP errors and reads each canonical code through `ErrorWithCode`, which is the source of truth the enum claims to mirror, and additionally asserts the codes are pairwise distinct. Verified by swapping 40123 and 40124 consistently in both places: the old test passed, the new one fails. Following `packages/wasm-drive-verify`, the wasm32 tests are not part of the default `cargo test`; the runner command is documented in Cargo.toml. Co-Authored-By: Claude Opus 5 --- packages/wasm-dpp2/src/consensus_error.rs | 119 +++++++++++++++--- packages/wasm-sdk/Cargo.toml | 7 ++ .../wasm-sdk/src/queries/document_ranked.rs | 88 +++++++++++++ 3 files changed, 198 insertions(+), 16 deletions(-) diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index e245e95a206..e3aa7cfa86c 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -102,47 +102,134 @@ impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); #[cfg(test)] mod tests { use super::*; + use dpp::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; + use dpp::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; + use dpp::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; + use dpp::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; + use dpp::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; + use dpp::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; + use dpp::consensus::state::state_error::StateError; + use dpp::data_contract::document_type::DocumentPropertyReferenceTarget; + use dpp::prelude::Identifier; - /// The enum's discriminants are the contract with JS. Anything that - /// re-numbers them silently breaks every caller's `switch`. - #[test] - fn reference_error_codes_round_trip_through_their_discriminants() { - let cases = [ + fn id() -> Identifier { + Identifier::from([1u8; 32]) + } + + /// The six reference-validation errors, paired with the JS enum variant + /// each is advertised to be. + /// + /// Deliberately built from the real DPP errors rather than from code + /// literals: the codes come back through [`ErrorWithCode`], which is the + /// source of truth the JS enum claims to mirror. Asserting against + /// literals would keep passing if two names were consistently assigned + /// each other's protocol code. + fn cases() -> Vec<(ConsensusError, DocumentReferenceErrorCodeWasm)> { + vec![ ( - 40120, + StateError::ReferencedEntityNotFoundError(ReferencedEntityNotFoundError::new( + id(), + DocumentPropertyReferenceTarget::Identity, + "author".to_string(), + )) + .into(), DocumentReferenceErrorCodeWasm::ReferencedEntityNotFound, ), ( - 40121, + StateError::ReferencedDocumentTypeNotFoundError( + ReferencedDocumentTypeNotFoundError::new( + id(), + "note".to_string(), + "parentNoteId".to_string(), + ), + ) + .into(), DocumentReferenceErrorCodeWasm::ReferencedDocumentTypeNotFound, ), ( - 40122, + StateError::ReferencedDocumentTypeDeletableError( + ReferencedDocumentTypeDeletableError::new( + id(), + "note".to_string(), + "parentNoteId".to_string(), + ), + ) + .into(), DocumentReferenceErrorCodeWasm::ReferencedDocumentTypeDeletable, ), ( - 40123, + StateError::ReferencedIdentityKeyNotFoundError( + ReferencedIdentityKeyNotFoundError::new(id(), 3, "signerKey".to_string()), + ) + .into(), DocumentReferenceErrorCodeWasm::ReferencedIdentityKeyNotFound, ), ( - 40124, + StateError::ReferencedIdentityKeyDisabledError( + ReferencedIdentityKeyDisabledError::new(id(), 3, "signerKey".to_string()), + ) + .into(), DocumentReferenceErrorCodeWasm::ReferencedIdentityKeyDisabled, ), ( - 40125, + StateError::ReferencedKeyIdPropertyInvalidError( + ReferencedKeyIdPropertyInvalidError::new( + "signerKeyId".to_string(), + "signerKey".to_string(), + "not an integer".to_string(), + ), + ) + .into(), DocumentReferenceErrorCodeWasm::ReferencedKeyIdPropertyInvalid, ), - ]; + ] + } - for (code, expected) in cases { + /// Every JS enum variant must carry the code DPP actually emits for the + /// error it names. This is the assertion that makes the enum a mirror of + /// the protocol rather than a second, independent list of numbers. + #[test] + fn each_variant_carries_the_code_dpp_emits_for_that_error() { + for (error, expected) in cases() { + let canonical = error.code(); + + assert_eq!( + expected as u32, canonical, + "{expected:?} is advertised for an error DPP codes as {canonical}" + ); assert_eq!( - DocumentReferenceErrorCodeWasm::from_code(code), - Some(expected) + DocumentReferenceErrorCodeWasm::from_code(canonical), + Some(expected), + "code {canonical} should resolve back to {expected:?}" ); - assert_eq!(expected as u32, code); } } + /// A `ConsensusError` crossing to JS reports the same code, so + /// `error.code` and the enum are comparable without a message regex. + #[test] + fn the_wasm_getter_reports_the_canonical_code() { + for (error, expected) in cases() { + let canonical = error.code(); + let wrapped = ConsensusErrorWasm(error); + + assert_eq!(wrapped.code(), canonical); + assert_eq!(wrapped.document_reference_error_code(), Some(expected)); + } + } + + /// The six codes are distinct — a copy-paste that gave two variants the + /// same discriminant would otherwise slip past the pairwise checks. + #[test] + fn the_reference_codes_are_distinct() { + let mut codes: Vec = cases().into_iter().map(|(error, _)| error.code()).collect(); + let total = codes.len(); + codes.sort_unstable(); + codes.dedup(); + + assert_eq!(codes.len(), total, "reference error codes must be distinct"); + } + #[test] fn codes_outside_the_reference_range_are_not_claimed() { for code in [40119, 40126, 0, 40200] { diff --git a/packages/wasm-sdk/Cargo.toml b/packages/wasm-sdk/Cargo.toml index 179eea2bf4d..9c10cc359ae 100644 --- a/packages/wasm-sdk/Cargo.toml +++ b/packages/wasm-sdk/Cargo.toml @@ -99,6 +99,13 @@ wasm-dpp2 = { path = "../wasm-dpp2" } # and silently defeat the tests that assert an uncompiled one is fetched. wasm-sdk = { path = ".", features = ["mocks"] } tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } +# wasm32-only test harness. Host tests cover every decision that can regress; +# this covers the one thing they cannot observe — that a wide integer group +# key really crosses as an exact JS BigInt. Run with: +# CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner \ +# cargo test -p wasm-sdk --target wasm32-unknown-unknown +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3.58" [package.metadata.wasm-pack] wasm-opt = false diff --git a/packages/wasm-sdk/src/queries/document_ranked.rs b/packages/wasm-sdk/src/queries/document_ranked.rs index 82330e4353c..a99de8d95e4 100644 --- a/packages/wasm-sdk/src/queries/document_ranked.rs +++ b/packages/wasm-sdk/src/queries/document_ranked.rs @@ -1220,6 +1220,94 @@ impl WasmSdk { } } +/// The one property host tests cannot observe: that a wide integer group key +/// really crosses as an exact JavaScript `BigInt`. +/// +/// [`exact_integer_to_js`] returning `JsValue` rather than `Result` makes the +/// path infallible, but a signature cannot pin *representation* — swapping the +/// body for `JsValue::from_f64(inner as f64)` would still compile, still be +/// infallible, and still satisfy every host test, while silently rounding +/// group keys past 2^53. These run on the wasm32 target, against the same +/// function the production result path calls, and need no exported test hook. +/// +/// Not run by the default `cargo test`; see the runner command in +/// `Cargo.toml`. (`packages/wasm-drive-verify` carries its wasm tests the +/// same way.) +#[cfg(all(test, target_arch = "wasm32"))] +mod wasm_tests { + use super::*; + use wasm_bindgen_test::wasm_bindgen_test; + + /// The smallest integer that `f64` cannot represent exactly, so the one + /// that most cheaply distinguishes a `BigInt` from a rounded `number`. + const FIRST_INEXACT_IN_F64: u64 = (1u64 << 53) + 1; + + fn js_type_of(value: &JsValue) -> String { + value + .js_typeof() + .as_string() + .expect("typeof always yields a string") + } + + #[wasm_bindgen_test] + fn wide_unsigned_keys_render_as_exact_bigints() { + for key in [FIRST_INEXACT_IN_F64, u64::MAX] { + let rendered = exact_integer_to_js(ExactIntegerKey::U64(key)); + + assert_eq!(js_type_of(&rendered), "bigint"); + assert_eq!(rendered, JsValue::from(key)); + assert_ne!( + rendered, + JsValue::from_f64(key as f64), + "a rounded number must never pass for the exact key {key}" + ); + } + } + + #[wasm_bindgen_test] + fn wide_signed_keys_render_as_exact_bigints() { + for key in [-(FIRST_INEXACT_IN_F64 as i64), i64::MIN, i64::MAX] { + let rendered = exact_integer_to_js(ExactIntegerKey::I64(key)); + + assert_eq!(js_type_of(&rendered), "bigint"); + assert_eq!(rendered, JsValue::from(key)); + } + } + + /// The two widths that cannot even be expressed as a JS `number`, and + /// which failed inside `serde_json` before this path existed. + #[wasm_bindgen_test] + fn oversized_keys_render_as_exact_bigints() { + let unsigned = exact_integer_to_js(ExactIntegerKey::U128(u128::MAX)); + assert_eq!(js_type_of(&unsigned), "bigint"); + assert_eq!(unsigned, JsValue::from(u128::MAX)); + + let signed = exact_integer_to_js(ExactIntegerKey::I128(i128::MIN)); + assert_eq!(js_type_of(&signed), "bigint"); + assert_eq!(signed, JsValue::from(i128::MIN)); + } + + /// The exact integer a proof committed to must survive the whole entry + /// conversion, not just the renderer in isolation. + #[wasm_bindgen_test] + fn a_wide_group_key_survives_the_entry_conversion() { + let parts = GroupEntryParts { + key_hex: "ff".to_string(), + decoded: Some(Value::U64(u64::MAX)), + key_absent: false, + value: RankedEntryValue::Count(1), + }; + + let entry = + group_entry_to_js(&parts, Some(0)).expect("a wide key must not reject the page"); + let group_value = js_sys::Reflect::get(&entry, &JsValue::from_str("groupValue")) + .expect("the entry carries a groupValue"); + + assert_eq!(js_type_of(&group_value), "bigint"); + assert_eq!(group_value, JsValue::from(u64::MAX)); + } +} + #[cfg(test)] mod tests { use super::*; From 72512194793254292730f2abbfc9f3ce75544ef7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 22 Aug 2026 21:00:20 +0700 Subject: [PATCH 6/7] chore: sync Cargo.lock for the wasm-sdk wasm-bindgen-test dev-dependency One line; the package entry already existed via wasm-drive-verify. Without it every `--locked` build in CI fails. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 0a5f13e2142..4e41cb9cd99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8852,6 +8852,7 @@ dependencies = [ "tracing-wasm", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-bindgen-test", "wasm-dpp2", "wasm-sdk", ] From 068a645e4e713adf09407d7800c73181e03e8209 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 22 Aug 2026 22:27:20 +0700 Subject: [PATCH 7/7] docs(wasm-sdk): record why ranked pin operands stop at 64 bits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review raised that a 128-bit equality pin cannot cross the query boundary. The mechanism is real — operands arrive through serde-wasm-bindgen's `deserialize_any`, whose BigInt branch is bounded by `i64::MIN..=u64::MAX`, and `serde_json::Value` could not hold a wider one either — but the case is not reachable, so this records the analysis rather than changing behaviour. A contract cannot declare a 128-bit integer property. The schema path `DocumentPropertyType::try_from_value_map` sends `"integer"` to `find_integer_type_for_subschema_value`, which reads `minimum` / `maximum` as `i64` and whose every branch tops out at `U64` / `I64`; `"number"` gives `F64`. The only constructors of `DocumentPropertyType::{U128, I128}` are the deprecated `try_from_name` and the random-document-type test generator. With no 128-bit property there is no 128-bit index property to pin, so no valid ranked or having index is unreachable through these bindings. Worth noting the bound is also not specific to this surface: the eight pre-existing document query entry points carry the identical `Vec` where-clause shape. If DPP ever gains a schema route to those widths, the fix belongs to all ten together and has to bypass `deserialize_any` for the operand. Co-Authored-By: Claude Opus 5 --- .../wasm-sdk/src/queries/document_ranked.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/wasm-sdk/src/queries/document_ranked.rs b/packages/wasm-sdk/src/queries/document_ranked.rs index a99de8d95e4..d04ffc42d0b 100644 --- a/packages/wasm-sdk/src/queries/document_ranked.rs +++ b/packages/wasm-sdk/src/queries/document_ranked.rs @@ -445,6 +445,26 @@ struct DocumentsRankedQueryInput { direction: Option, #[serde(default)] offset: Option, + /// Pin operands are bounded to `i64::MIN..=u64::MAX`, the same bound the + /// pre-existing `DocumentsQuery` surface carries: they arrive through + /// serde-wasm-bindgen's `deserialize_any`, whose BigInt branch refuses + /// anything wider, and `serde_json::Value` could not hold it either. + /// + /// That is not currently a reachable limitation. A contract cannot + /// declare a 128-bit integer property: the schema path + /// (`DocumentPropertyType::try_from_value_map` → + /// `find_integer_type_for_subschema_value`) reads `minimum` / `maximum` + /// as `i64` and tops out at `U64` / `I64`, and the only constructors of + /// `DocumentPropertyType::{U128, I128}` are the deprecated + /// `try_from_name` and the random-document-type test generator. With no + /// 128-bit property there is no 128-bit index property to pin. + /// + /// If DPP ever gains a schema route to those widths, widening this is a + /// change for the whole document-query surface rather than these two + /// entry points: all ten share the `Vec` shape, and + /// the fix has to bypass `deserialize_any` for the operand (reading + /// `where` off the raw object, with the field declared `IgnoredAny` so + /// `deny_unknown_fields` still holds). #[serde(rename = "where", default)] where_clauses: Option>, }