Document Sum Trees
Summing a numeric property across the documents that match a query used to mean fetching them all and adding values up client-side. The grovedb upgrade that landed alongside Document Count Trees adds provable sum trees and references with sum item as primitives — the building blocks Drive uses to turn sum(amount)-style queries into an O(log n) provable lookup. This chapter explains the three sum-tree variants, how a document type opts into one, the unified GetDocumentsSum endpoint that exposes the feature, and the parallels with the count-tree machinery.
Status: the grovedb-level sum-tree primitives (
SumTree,ProvableSumTree,BigSumTree, and reference elements that carry a sum-item contribution) are in place. The Drive-level schema syntax, query handler, and SDK surfaces described below are the proposed design — the Sum Index Examples chapter is the worked-example companion, and the tip-jar contract fixture atpackages/rs-drive/tests/supporting_files/contract/tip-jar/tip-jar-contract.jsonis the schema this design targets.
Why Sum Trees Exist
The default primary-key tree for a document type is a NormalTree. To total the amount field across its documents, Drive walks the subtree, deserializes every record, sums the property client-side, and returns the result. That is fine for small types but becomes painful as soon as a UI needs "how much has this creator received in tips?" on a tip jar with millions of entries — and worse if the caller wants a proof of the total, because the proof has to enumerate every contributing document.
GroveDB has two sum-aware tree variants. Both are provable — the running sum is committed to the merk root in each case — but they differ in where the sum is stored inside the tree, and that controls which kinds of sum queries can be answered without enumerating leaves:
SumTree— stores a singlei64sum at the root of the tree. The total sum is one read; any per-subtree sum requires walking down to that subtree's root and reading its (separate) tree element.ProvableSumTree— stores ani64sum at every internal node, not just the root. Each node's sum covers everything in the subtree below it, so range queries like "what's the total amount tipped between time A and time B?" or "what's the sum per recipient over time?" can be answered by walking the boundary nodes and combining their pre-computed sub-sums, without touching any leaf.
GroveDB merk trees are binary — each internal node has exactly a left and a right child:
The dashed box is the wrapping Element (the "tree" in grovedb terms) and contains the root node of the merk tree. Both variants store the total sum on the wrapping element — that's the O(1) field Drive reads for total sums. The difference is what's inside: in a SumTree the merk root and the rest of the tree don't carry the sum, so only the wrapper has it. In a ProvableSumTree the sum is also stored on the root node itself and on every internal merk-tree node, so it's committed into the merk root hash and provable per-subtree.
flowchart LR
subgraph ST ["SumTree"]
direction TB
subgraph ST_ELEM ["Tree element s=18"]
direction TB
A["root"]:::node
end
A --> B["·"]:::node
A --> C["amt=5"]:::leaf
B --> D["amt=10"]:::leaf
B --> E["amt=3"]:::leaf
end
subgraph PST ["ProvableSumTree"]
direction TB
subgraph PST_ELEM ["Tree element s=18"]
direction TB
H["root s=18"]:::sumnode
end
H --> I["s=13"]:::sumnode
H --> J["amt=5"]:::leaf
I --> K["amt=10"]:::leaf
I --> L["amt=3"]:::leaf
end
ST ~~~ PST
classDef node fill:#6e7681,color:#fff,stroke:#6e7681;
classDef sumnode fill:#3fb950,color:#0d1117,stroke:#3fb950,stroke-width:2px;
classDef leaf fill:#21262d,color:#c9d1d9,stroke:#484f58;
style ST_ELEM fill:none,stroke:#1f6feb,stroke-width:2px,stroke-dasharray: 6 4,color:#1f6feb
style PST_ELEM fill:none,stroke:#1f6feb,stroke-width:2px,stroke-dasharray: 6 4,color:#1f6feb
In a SumTree, the only sum-bearing node is the root. To compute "total amount tipped per recipient" you'd have to navigate to each recipient-keyed subtree (a separate grovedb tree, not a child node of the binary structure above), read its root sum, and pay for a separate proof per read — N reads for N distinct recipients. In a ProvableSumTree, every internal node along the binary path already carries the sum of its left and right subtrees, so a range query like "amounts where sentAt ∈ [t1, t2]" walks only the boundary path and combines the pre-committed sub-sums in a single traversal and a single proof.
A document type opts in via two schema flags. Note that — unlike count, where the flag is a plain bool — sum needs to know which property to sum, so both flags carry a property name:
documentsSummable: "<property>"→ primary-key tree is aSumTreesumming the named property. Enables O(1) total-sum for the document type; sufficient forGetDocumentsSumwith nowherefilter.rangeSummable: true(paired withdocumentsSummable) → primary-key tree is aProvableSumTree. The same flag is also accepted per-index, where it controls range-sum storage layout (see below) and is required for anyGetDocumentsSumrequest that carries a range where-clause.
The named property must be type: integer and listed in the document type's required array. We don't define a null contribution rule — a missing-or-null value would have to either contribute 0 (and silently mask a misconfigured insert) or fail the insert (and invalidate documents that were valid at write time). Requiring the property avoids both choices.
How a Document Type Picks Its Tree Variant
Selection lives in DocumentTypePrimaryKeyTreeType::primary_key_tree_type — the same dispatcher that picks the count-tree variant — extended to consider sum flags alongside count flags:
#![allow(unused)] fn main() { // proposed v1 selection logic — **sum-only projection** for chapter clarity. // The real dispatcher also picks the combined count+sum variants // (`CountSumTree`, `ProvableCountSumTree`, // `ProvableCountProvableSumTree`) when count flags are set alongside // the sum flags — those branches are omitted here and covered in // "Choosing What to Set" below. The v0 count-only logic stays in // place behind a version bump. match (range_summable, documents_summable, range_countable, documents_countable) { (true, _, _, _) => Ok(TreeType::ProvableSumTree), (false, true, _, _) => Ok(TreeType::SumTree), (false, false, true, _) => Ok(TreeType::ProvableCountTree), (false, false, false, true) => Ok(TreeType::CountTree), (false, false, false, false) => Ok(TreeType::NormalTree), } }
primary_key_tree_type() stays the single source of truth — every Drive code path that needs to know which tree variant to read from or write to routes through this helper, including:
- Contract insert and update (to
CREATEthe right tree element when the document type is added). - Document insert / delete (to know how to update the sum alongside the document — adding
amountto every ancestor sum field, decrementing on delete). - Cost estimation (so fees match the variant that will actually be used).
The contract insert/update paths use thin Drive helpers parallel to the existing count variants and the count chapter's batch_insert_empty_*_tree family:
batch_insert_empty_tree— NormalTree.batch_insert_empty_sum_tree— SumTree, used whendocuments_summable.is_some() && !range_summable().batch_insert_empty_provable_sum_tree— ProvableSumTree, used whenrange_summable().
A sum tree's contents under each value-keyed path are inserted via a different family of helpers — the reference-with-sum-item primitive. Where a non-summable index stores a reference at [index_value]/[0]/<doc_id>, a summable index stores a reference that also carries an i64 sum contribution (the document's amount value at write time). When the parent tree is a SumTree or ProvableSumTree, each insert's sum contribution propagates up the merk path, exactly as a count contribution does in the count case — but the contribution is amount rather than +1. Helpers:
batch_insert_sum_item— drops a bare sum item under a sum tree.batch_insert_reference_with_sum_item— drops a reference that contributes a named amount to its parent sum tree. This is the helper non-primary-key sum indexes use.
Each helper goes through LowLevelDriveOperation::for_known_path_key_*_sum_* (or its _estimated_path_key_* cousin in cost-estimation paths), so the contract setup, document operations, and proof generation all see the same on-disk shape.
Storage-Layout Invariants
Because the tree variant is fixed at contract-creation time and baked into how the tree element is laid out on disk, both flags are immutable across a contract update — and the named summable property is too, since changing which property feeds the sum would silently invalidate every existing aggregation:
- Changing
documents_summablefrom any state to any other state (including changing the property name) on avalidate_configupdate returnsDocumentTypeUpdateError. - Same for
range_summable.
Tests pinning these guards will live alongside the existing count-tree tests in packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs. Don't relax them: if a NormalTree-backed document type were silently switched to SumTree mid-contract, every subsequent insert or delete would update a sum value attached to a tree element that physically isn't a sum tree, leading to grovedb element-shape errors at best and consensus drift at worst.
The named property's value is read at insert time and frozen into the reference-with-sum-item — Drive doesn't re-read it on delete (it pulls the contribution from the reference itself). So changing the property's value would require a delete-then-reinsert; document mutability concerns apply normally.
Summing Documents at Query Time
A single unified gRPC endpoint exposes the feature: GetDocumentsSum — structurally identical to GetDocumentsCount. The response shape varies by request mode (total / per-In-value / per-distinct-value-in-range / total-over-range), see Range Modes below. The wire-level shape mirrors count: on the no-proof path the response's SumResults carries an inner oneof variant { sint64 aggregate_sum; SumEntries entries; } — total-sum and range-without-distinct modes return aggregate_sum (a single i64), per-In-value and per-distinct-value-in-range modes return entries (a list of SumEntry { optional bytes in_key; bytes key; sint64 sum } where in_key is the prefix value for compound In + range shapes and absent for flat queries). The endpoint has two underlying paths (prove vs. no-prove); every mode is valid on both paths.
The two-path / two-shape split is identical to the count endpoint's, and for the same reasons. What's new in the sum case:
- Sums are signed (
i64/sint64on the wire) — grovedb's sum trees model overflow into negative space rather than saturating, so a verifier extracting a sum from a proof can detect "this aggregation overflowedi64::MAX" by recovering a value that doesn't match the document set's expected magnitude. If you expect aggregations beyondi64::MAX, use aBigSumTree-backed variant (bigDocumentsSummable: "amount"— out of scope for this chapter; covered alongside theBigSumTreeDrive plumbing). - Each sum query needs the property name to sum baked into the picker — there's no implicit "+1 per matched doc." The picker resolves the property from the covering index's
summable: "<property>"flag and rejects queries whose target property isn't the same one any candidate index sums.
No-Prove (Server-Side O(1) or O(log n))
When prove=false, drive-abci calls into DriveDocumentSumQuery (the proposed analog of DriveDocumentCountQuery in packages/rs-drive/src/query/drive_document_count_query/mod.rs). The handler picks a path based on the where clauses:
Unfiltered total (no where clauses) on a documentsSummable: "amount" document type:
The doctype's primary-key tree at [contract_doc, contract_id, 1, doctype, 0] is itself a SumTree. One grovedb read gives sum_value — the total of amount across every document of this type. O(1).
Equal/In only:
- Pick a
summable: "<prop>"index whose properties exactly match the Equal/In where-clause fields.<prop>must equal the request's target sum property. The same strict-coverage contract count uses applies — partial coverage rejects withWhereClauseOnNonIndexedProperty. (See "Index design" below.) - Walk the tree from the root down to the terminal level, pushing
prop_nameandserialize_value_for_key(prop_name, value)at each step.Equalextends one path;Inclones the current path once per value in its array (a cartesian fork) and the per-branch sums are summed. - Read the
SumTreeelement at the resulting path and return itssum_value. O(1) per branch.
If the request carries an In clause, the response is the entries variant — one SumEntry per In value. Otherwise the response is the aggregate_sum variant — a single i64.
Index design contract: a summable: "amount" index sums exactly its declared properties' coverage of amount. Want sum(amount) WHERE recipient = X? Define a [recipient] index with summable: "amount". Want sum(amount) WHERE recipient = X AND sentAt > T? Define a [recipient, sentAt] index with summable: "amount" AND rangeSummable: true. Partial coverage (e.g. recipient = X against a [recipient, sentAt] index without the range clause) is rejected — define a more specific summable index, or set documentsSummable: "amount" on the document type for unfiltered total sums. The prove path enforces the same contract, so prove=true and prove=false reject in the same situations with the same error.
Range:
- Pick a
rangeSummable: trueindex where the Equal/In clauses cover the prefix and the range operator hits the index's last property. - Build the path
[contract_doc, doctype, prefix..., range_prop_name]— pointing at the property-nameProvableSumTree. - Issue a grovedb path query with the converted range
QueryItem(>,>=,<,<=,Range,RangeInclusive,RangeAfter,RangeAfterTo,RangeAfterToInclusive) and walk the children whose keys lie inside the range. - Each child's
sum_value_or_default()is theamountsum at that property value. Either combine all per-value sums and return as theaggregate_sumvariant (summed mode), or emit them as per-valueSumEntrys under theentriesvariant (distinct mode), then apply order / cursor / limit.
Prove (Client-Side Verify-Then-Aggregate or Aggregate-Sum Proof)
When prove=true, the proof shape depends on whether the query carries a range clause.
With a range clause: the handler picks one of two prove sub-paths based on return_distinct_sums_in_range:
-
Aggregate (
return_distinct_sums_in_range = false, default): drive-abci builds a grovedbAggregateSumOnRangepath query against the property-nameProvableSumTree, andget_proved_path_queryproduces an aggregate-sum proof. The client verifies viaGroveDb::verify_aggregate_sum_queryand recovers(root_hash, sum)directly — proof size is O(log n) regardless of how many keys match. No documents are ever materialized. -
Distinct (
return_distinct_sums_in_range = true): drive-abci builds a regular range path query (noAggregateSumOnRangewrapper) against the sameProvableSumTree. Because the leaf is aProvableSumTree, merk emits oneNode::KVSum(key, value, sum)op per matched in-range key, with eachsumcryptographically committed to the merk root vianode_hash_with_sum(kv_hash, l_hash, r_hash, sum)— same forge-resistance as the aggregate path'sHashWithSumcollapse. The SDK'sdrive_proof_verifier::verify_distinct_sum_proofruns the standard hash-chain check, then walks the proof's op stream to extract the sums as aBTreeMap<Vec<u8>, i64>. Trade-off vs. the aggregate path: proof size is O(distinct values matched) rather than O(log n).
Without a range clause (point-lookup with prove): two sub-paths based on the request shape.
-
Unfiltered total +
documentsSummable: "amount": drive-abci proves the doctype's primary-keySumTreeelement at[contract_doc, contract_id, 1, doctype, 0]. One merk path proof; the SDK'sdrive_proof_verifier::verify_primary_key_sum_tree_proofreadssum_valueoff the verified element. O(log n) bytes. -
Equal/In against a fully-covering
summable: "amount"index: drive-abci proves oneElement::SumTreeper covered branch. Two sub-shapes parallel to count's:- Equal-only fully-covered → one element at
[..., last_field, last_value, 0]. Inat any index position (with any number of trailing Equals) → one element per In value, fetched via outer Query + a subquery whoseset_subquery_pathcarries the post-In Equal segments.
The In position rule and the
set_subquery_pathmechanics are byte-for-byte the same as the count case — see the count chapter's Prove section for the rationale. Sum picks up the same permissive layout because both paths usepoint_lookup_sum_path_query(no document-key terminator descent, noorder_byinterpretation, nolimit/offsetsemantics — it's a pure SumTree-element lookup). - Equal-only fully-covered → one element at
Both sub-paths share the proof shape: each SumTree element's sum_value is cryptographically bound to the merk root via node_hash_with_sum(kv_hash, l_hash, r_hash, sum). Neither materializes documents or runs per-key bookkeeping client-side.
Proof size: O(k × log n) where k is the number of covered branches (1 for the documents_summable fast path and Equal-only fully-covered case; ≤ |In values| for Equal-prefix + In-on-last).
Symmetric rejection contract: prove sum requires a summable: "<prop>" index whose properties exactly match the where clauses and whose summed property matches the request's target — same requirement as the no-proof Total / PerInValue modes. Partial coverage rejects with a WhereClauseOnNonIndexedProperty-class error. The documentsSummable: "<prop>" fast path handles unfiltered total sums in O(log n) proof bytes when set on the document type. No silent fallback to materializing matching documents.
Supported Where Operators
Identical to the count endpoint:
Equal(==) — single point lookup against the sum tree at a fully-resolved index path.In(in) — cartesian fork. Each value in theInarray becomes its own index path; their sums are combined (or, for split sums, merged by split key). AnInclause withkvalues costskpoint lookups, not a tree walk. TheInclause also doubles as the per-value split signal in the unifiedGetDocumentsSumendpoint — at most oneInper request.- Range (
>,>=,<,<=,between*,startsWith) — walks the property-nameProvableSumTree's children whose keys lie inside the range, combining each childSumTree's sum value. Requires the index to haverangeSummable: trueAND the range property to be the index's last property.
Range queries take a single range terminator clause plus a prefix of Equal clauses and/or one In clause. In on a prefix property exercises grovedb's native subquery primitive — each emitted entry carries both the in_key (the In value for that fork) and the key (the terminator value within the range). Per-fork sums are NOT merged server-side — same No-Merge Compound Semantics reasoning as count.
Range Modes
A range query produces one of two response shapes, controlled by return_distinct_sums_in_range:
return_distinct_sums_in_range = false(default) —SumResults.aggregate_sumcarrying the sum of the per-valueSumTreesums within the range. Use for "how much was tipped between t1 and t2?".return_distinct_sums_in_range = true—SumResults.entrieswith oneSumEntryper distinct property value within the range. Use for "show me the histogram of tip amounts per timestamp in [t1, t2]".
No-Merge Compound Semantics
For compound queries (In on a prefix property + range on the terminator), entries are returned unmerged — one SumEntry per emitted (in_key, key) pair. The server does NOT collapse them down to a flat histogram. Same three reasons as count:
- Correctness under
limit. Pushing alimitinto grovedb's path query truncates the emitted elements before any merge could run. With cross-fork merging this can undercount the merged sums. - Proof verification stays straightforward. A malicious server omitting one
Inbranch shows up as missing entries with thatin_keyrather than as a silent undercount in a merged total. - No information loss. A caller who wanted the merged histogram can compute
result.fold(by=key, sum=sum)client-side trivially.
The rs-sdk surfaces this via DocumentSplitSums.0: Vec<VerifiedSplitSum>. Callers wanting the historical flat-map shape can call DocumentSplitSums::into_flat_map() which combines across in_key forks.
Pagination
Identical to count's pagination — order_by controls split-mode entry ordering; limit truncates after min(requested, max_query_limit) with None normalized to default_query_limit. Pagination is by range narrowing, not cursor. Ignored on aggregate mode.
Range Queries on the Prove Path
Same shape as count's Range Queries on the Prove Path:
- Aggregate sub-path (default) builds
AggregateSumOnRange— proof size O(log n). - Distinct sub-path (
return_distinct_sums_in_range = true) builds a regular range proof against the property-nameProvableSumTree. Per-(in_key, key)KVSumops, each bound to the merk root vianode_hash_with_sum. Inon a prefix property is supported on the distinct sub-path. The aggregate sub-path rejectsInon prefix (single-range merk primitive can't fork at the merk layer)."desc"direction in the firstorder_byclause flows through to grovedb'sQuery.left_to_right.
Range Queries and ProvableSumTree
Range sum queries (>, <, between*) over an index with rangeSummable: true are answered in O(log n) by walking the property-name ProvableSumTree's boundary nodes. The proof path uses grovedb's AggregateSumOnRange, which lets clients verify a range sum without ever materializing the underlying documents.
Why Internal-Node Sums Make Range Sums O(log n)
In a sorted merk tree the keys partition into a left (smaller) and right (larger) subtree at every internal node. To answer "what's the sum of amount for documents with sentAt > T?" you walk the boundary between "below T" and "above T" from the root down, and at each step you decide what to do with the other subtree based on a single read:
- If a subtree lies entirely above the cutoff, add its full sum and don't descend.
- If it lies entirely below, ignore it (contributes 0).
- If it straddles, recurse.
On a ProvableSumTree every internal node carries the sum of its left and right subtrees, so the "add the full sum" step is a single O(1) read. The walk visits one node per tree level — O(log n).
Concretely, picture a ProvableSumTree of 8 tips with sorted sentAt keys and amount leaves:
flowchart TB
R["root s=80"]:::sumroot
R --> L1["s=30"]:::sumnode
R --> R1["s=50"]:::sumnode
L1 --> LL["s=15"]:::sumnode
L1 --> LR["s=15"]:::sumnode
R1 --> RL["s=20"]:::sumnode
R1 --> RR["s=30"]:::sumnode
LL --> x1["t=1, amt=5"]:::leaf
LL --> x3["t=3, amt=10"]:::leaf
LR --> x5["t=5, amt=7"]:::leaf
LR --> x7["t=7, amt=8"]:::leaf
RL --> x9["t=9, amt=12"]:::leaf
RL --> x11["t=11, amt=8"]:::leaf
RR --> x13["t=13, amt=15"]:::leaf
RR --> x15["t=15, amt=15"]:::leaf
classDef sumroot fill:#1f6feb,color:#fff,stroke:#1f6feb,stroke-width:2px;
classDef sumnode fill:#3fb950,color:#0d1117,stroke:#3fb950,stroke-width:2px;
classDef leaf fill:#21262d,color:#c9d1d9,stroke:#484f58;
For "give me the sum of amount for items with sentAt > 6":
- root (s=80): 6 falls inside the left subtree (which holds t=1..7). Read both children's sub-sums. Right subtree's keys are all > 6 → take its full
s=50and don't descend. Recurse into left. - left (s=30): 6 falls inside its right subtree (t=5,7). Read both children. Left-left's keys (1,3) are both ≤ 6 → contribute 0. Recurse into left-right.
- left-right (s=15): 6 splits this leaf-pair. Read both leaves. Key 5 ≤ 6 → contribute 0. Key 7 > 6 → contribute its
amt=8. - Total = 50 (right of root) + 0 (left-left) + 0 (t=5) + 8 (t=7) = 58.
We visited 4 internal nodes on the boundary path and read sub-sums off 3 siblings without descending. Six of the eight items were never enumerated: their contributions were combined straight out of the committed sub-sum fields.
Why This Is Provable
A merk proof of the same boundary walk includes:
- The boundary path from root to the leaf adjacent to the cutoff.
- The siblings of every node on the boundary path (so the verifier can recompute hashes up to the merk root).
Each sibling node, on a ProvableSumTree, ships its committed sub-sum alongside its hash. The verifier walks the same logic the server did — "this sibling lies entirely above 6, add its s=… value" — and ends up with the same total without enumerating the sibling subtrees. Verification is also O(log n).
The same primitive answers any range query of the form [A, B]: walk to the cutoff at A, then to the cutoff at B, and combine sub-sums along the way.
Authoring a Contract That Uses Sum Trees
Two opt-in surfaces, parallel to count. They're independent and can be used together:
- Top-level flags on the document type control the primary-key tree variant.
- A per-index
summable: "<property>"flag controls whether that specific index's tree carries sums.
Primary-Key Tree Flags
Set at the same level as type / properties / indices on a document type:
{
"tip": {
"type": "object",
"documentsSummable": "amount",
"properties": {
"recipient": { "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, "position": 0,
"contentMediaType": "application/x.dash.dpp.identifier" },
"amount": { "type": "integer", "minimum": 1, "position": 1 },
"sentAt": { "type": "integer", "minimum": 0, "position": 2 }
},
"required": ["recipient", "amount", "sentAt"],
"additionalProperties": false
}
}
That contract gets a SumTree for the tip primary-key tree, summing amount. GetDocumentsSum for tip with no where filter is now an O(1) lookup of the tree element's sum value.
To opt into a ProvableSumTree for the primary-key tree instead — useful if you want range queries on the primary key or intend to use this document type behind range proof primitives — pair with rangeSummable: true:
{
"tip": {
"type": "object",
"documentsSummable": "amount",
"rangeSummable": true,
...
}
}
Both flags are immutable across a contract update — you pick the tree variant at contract creation; you can't switch later without creating a new document type.
Per-Index Summable Flag
Set on a single entry in the document type's indices array:
{
"indices": [
{
"name": "byRecipient",
"properties": [{ "recipient": "asc" }],
"summable": "amount"
}
]
}
With byRecipient.summable: "amount" the byRecipient index's tree carries running sums of amount, so GetDocumentsSum with where: [["recipient", "==", X]] reaches the sum via that index in O(1). Without the flag the query rejects with WhereClauseOnNonIndexedProperty — there's no slow fallback, only fast sums on properly-indexed properties.
The summable field accepts a single shape: a string naming an integer property declared on the document type and listed in required. The named property must be the same one named at any other summable level (doctype documentsSummable and other indexes' summable) — multi-property summing on a single tree isn't supported and won't be; if you need to sum two properties, declare two separate aggregation surfaces.
A few notes about the index-level flag:
- Setting
summableincreases storage cost — every insert and delete updates the index tree's sum alongside the document, and the reference under the index value tree carries an extrai64sum-item contribution. - Setting
rangeSummable: trueincreases storage cost further — every internal node of the property-name tree carries running-sum metadata, not just the root. - The flag is on the whole index, not per-property. Same strict-coverage rule as count: a
["recipient", "sentAt"]summable index gives O(1) sums forWHERE recipient = X AND sentAt = Tbut NOT forWHERE recipient = Xalone. Define both indexes if you want both queries. - Index-level
summableis independent of the primary-key flags. You can havedocumentsSummable: "amount"on the document type ANDsummable: "amount"on a specific index. summableon auniqueindex is mostly a no-op, but not always, mirroring count's caveat. A unique index stores its terminal as a bare reference at key[0]rather than wrapping it in a sum tree, so for documents whose indexed fields are all non-null the flag has no storage effect. Null-bearing entries take the same sum-tree branch a non-unique index uses, and the sum tree at that path aggregates them. Sums on all-non-null exact matches still return correctly (the reference's stored sum contribution) because the on-disk reference reads as a sum item via grovedb's default-aggregate semantics.
Choosing What to Set
| You want | Set |
|---|---|
Fast sum(amount) for the whole document type | documentsSummable: "amount" on the document type |
O(1) filtered sum: sum(amount) WHERE col = X | summable: "amount" on an index whose properties are exactly ["col"]. Partial coverage of a wider index rejects with WhereClauseOnNonIndexedProperty — define a dedicated index. |
Per-In-value sub-sums | summable: "amount" on an index whose properties exactly match the query's == clauses plus the In field. The In field may sit at any position. |
O(log n) range sum: sum(amount) WHERE col BETWEEN A AND B | rangeSummable: true on an index whose last property is col and whose other properties cover any equality predicates as a prefix. Requires summable: "amount". |
| Per-distinct-value range histogram | Same rangeSummable: true index as above, plus return_distinct_sums_in_range = true on the request. |
| Range sum proof | Same rangeSummable: true index. Handler uses grovedb's AggregateSumOnRange — proof is O(log n), no cap on matched docs. |
Aggregations beyond i64::MAX | Out of scope for this chapter — see the BigSumTree variant. |
| Both a sum AND a count on the same tree | Combine the count flags (documentsCountable / rangeCountable / per-index countable) with the sum flags. The dispatcher picks one of three combined variants depending on which axes opt into per-node aggregation: CountSumTree (both at root only), ProvableCountSumTree (per-node count, root-only sum — useful when range count is wanted but range sum isn't), or ProvableCountProvableSumTree / PCPS (both per-node — the grovedb PR 670 newcomer, enables AggregateCountAndSumOnRange recovering both metrics in a single range proof). One tree, two simultaneous queries, no double storage. The tip-jar contract above doesn't use these combinations (it's pure-sum to keep the introduction focused); a worked example using (count, sum) together is covered in a separate chapter alongside its own example contract. |
| Nothing sum-aware (default) | Don't set any of these flags. Primary-key tree stays a NormalTree. |
Every sum query requires either documentsSummable: "<prop>" (for unfiltered totals) or a summable: "<prop>" / rangeSummable: true index whose properties exactly match the query's where-clause fields. No covering index → the call returns a clear InvalidArgument describing what the picker was looking for. Pick your indexes deliberately at contract creation time — per-index summable / rangeSummable flags can't be added later (contract indexes are immutable post-creation).
SDK Access at Three Layers
rs-sdk (native Rust)
Both shapes will land on the standard Fetch trait against a single DocumentSumQuery:
#![allow(unused)] fn main() { use dash_sdk::platform::documents::document_sum_query::DocumentSumQuery; use dash_sdk::platform::Fetch; use drive::query::{WhereClause, WhereOperator}; use drive_proof_verifier::{DocumentSum, DocumentSplitSums}; // Total sum: no In clause. let DocumentSum(sum) = DocumentSum::fetch( &sdk, DocumentSumQuery::new(contract.clone(), "tip", "amount")?, ) .await? .expect("DocumentSum::fetch always returns a value on success"); // Split sum: signal split by including an `In` clause whose field // is the split property. let split_query = DocumentSumQuery::new(contract, "tip", "amount")? .with_where(WhereClause { field: "recipient".to_string(), operator: WhereOperator::In, value: platform_value::Value::Array(vec![alice.into(), bob.into()]), }); let splits = DocumentSplitSums::fetch(&sdk, split_query) .await? .expect("DocumentSplitSums::fetch always returns a value on success"); // `splits` is `DocumentSplitSums(Vec<SplitSumEntry>)` — collapse via `splits.into_flat_map()`. }
DocumentSumQuery wraps an internal DocumentQuery (reusing where-clause / order-by / contract-id machinery) and exposes with_where(WhereClause) + with_order_by(OrderClause) builders. The SDK picks the request mode from query shape plus explicit request flags. The target sum property is part of the query construction — the SDK validates against the contract that some covering index sums it.
wasm-sdk (browser)
Two methods on the WasmSdk JS class — one entry per [plain | withProofInfo] variant covers every sum mode:
sdk.getDocumentsSum(
query: DocumentsQuery,
sumProperty: string,
): Promise<Map<string, bigint>>;
sdk.getDocumentsSumWithProofInfo(
query: DocumentsQuery,
sumProperty: string,
): Promise<ProofMetadataResponseTyped<Map<string, bigint>>>;
Result shapes mirror count's wasm SDK, with bigint carrying a signed sum:
- No
where, or Equal-onlywhere— single map entry with the empty-string key carrying the total sum. whereincludes anInclause — one entry per (deduped) In value.whereincludes a range clause +returnDistinctSumsInRange: true— one entry per distinct property value in the range.
Map keys are hex-encoded bytes matching the canonical serialize_value_for_key encoding of each property value, same convention as count.
rs-sdk-ffi (iOS / native bindings)
#![allow(unused)] fn main() { dash_sdk_document_sum( sdk, data_contract, document_type, sum_property, // name of the integer property to sum where_json, // null or JSON [{field, operator, value}] order_by_json, // null or JSON [{field, direction}] return_distinct_sums_in_range, // bool limit, // i64; -1 = server default, >= 0 = explicit cap ) -> JSON {"sums": {"<hex-key>": <i64>, ...}} }
Single FFI entry covers every sum mode — the result is always {"sums": {...}} with hex-encoded keys. For total sums (no where/In, distinct flag off), the map carries a single entry with the empty-string key. where_json is the same JSON shape dash_sdk_document_search already accepts. The endpoint returns its result as a JSON-encoded C string allocated on the heap — caller frees it via the standard SDK string-free routine.