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", ] diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index b01b03f2c5f..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) @@ -69,10 +71,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 @@ -82,7 +86,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 +100,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 `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. + +## 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/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/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/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/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..e3aa7cfa86c 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,162 @@ 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::*; + 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; + + 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![ + ( + StateError::ReferencedEntityNotFoundError(ReferencedEntityNotFoundError::new( + id(), + DocumentPropertyReferenceTarget::Identity, + "author".to_string(), + )) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedEntityNotFound, + ), + ( + StateError::ReferencedDocumentTypeNotFoundError( + ReferencedDocumentTypeNotFoundError::new( + id(), + "note".to_string(), + "parentNoteId".to_string(), + ), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedDocumentTypeNotFound, + ), + ( + StateError::ReferencedDocumentTypeDeletableError( + ReferencedDocumentTypeDeletableError::new( + id(), + "note".to_string(), + "parentNoteId".to_string(), + ), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedDocumentTypeDeletable, + ), + ( + StateError::ReferencedIdentityKeyNotFoundError( + ReferencedIdentityKeyNotFoundError::new(id(), 3, "signerKey".to_string()), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedIdentityKeyNotFound, + ), + ( + StateError::ReferencedIdentityKeyDisabledError( + ReferencedIdentityKeyDisabledError::new(id(), 3, "signerKey".to_string()), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedIdentityKeyDisabled, + ), + ( + StateError::ReferencedKeyIdPropertyInvalidError( + ReferencedKeyIdPropertyInvalidError::new( + "signerKeyId".to_string(), + "signerKey".to_string(), + "not an integer".to_string(), + ), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedKeyIdPropertyInvalid, + ), + ] + } + + /// 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(canonical), + Some(expected), + "code {canonical} should resolve back to {expected:?}" + ); + } + } + + /// 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] { + 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/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.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..d04ffc42d0b --- /dev/null +++ b/packages/wasm-sdk/src/queries/document_ranked.rs @@ -0,0 +1,2019 @@ +//! 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, + /// 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>, +} + +#[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), + } +} + +/// 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), + } +} + +/// 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(key) => Ok(exact_integer_to_js(key)), + GroupValueRepr::DocumentJson(inner) => { + platform_value_to_json(inner).map_err(WasmSdkError::from) + } + } +} + +/// 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), _) => 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, + (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, + )) + } +} + +/// 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::*; + 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 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. + /// + /// 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 = [ + (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. + #[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); + }); + }); });