Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion book/src/drive/index-only-document-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ types unchanged: "the five most-liked posts in `#dash`" is an
O(log n + k) read with an O(log n + k) proof, and Items count in
count/ranked trees exactly as References do.

**`timeRange` buckets** compose too: a bucketed indexOnly index writes
one commitment entry per containing bucket under the grid-qualified
level, exactly as stored types do — the walkers' bucket fan-out, the
probes' path derivation (`entry_keys_for_raw`, shared so probe and write
paths cannot drift), and the `IN_TIME_RANGE` count aggregates are all the
same machinery ("how many likes under `#dash` this hour"). The source can
only be `$createdAt` (the prefix rule admits no other timestamp), which
`required` must carry, so a delete's values reproduce the exact bucket
set. A bucketed index involves `$createdAt` and therefore never serves as
the proof index; and document synthesis over bucketed entries is refused
with guidance — the bucket level carries bucket-start granularity, not
the document's timestamp, and the raw entries are served by the type's
non-bucketed indexes.

The **sum axes** compose the same way: a `summable: "<prop>"` index
stores `ItemWithSumItem(<row commitment>, <amount>)` terminals — the same
commitment payload, plus the summed property's value — so entries
Expand Down Expand Up @@ -85,7 +99,7 @@ aggregate keywords follow:
| terminal is `$ownerId` or a single-id refersTo property | the member key must alone be a referable entity id (`identityPublicKey` is compound and rejected) |
| indexed `$createdAt` requires `$createdAt` in `required` | creation only assigns timestamps for required system times |
| `documentsMutable: false`, no transfers/trading/history/transient | no stored row, no revision |
| non-unique, non-contested, `nullSearchable` default, no `timeRange` | v1 scope; buckets are a follow-up |
| non-unique, non-contested, `nullSearchable` default | v1 scope |

`indexOnly` and the index set (terminals included) are immutable across
contract updates — a later-added index could never be backfilled.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2040,13 +2040,20 @@ pub(super) fn apply_index_only(
index_name, name,
)));
}
if index.time_range.is_some() {
return Err(structure_error(format!(
"index \"{}\" on indexOnly document type \"{}\" cannot declare a timeRange: \
bucketed indexOnly indexes are not yet supported",
index_name, name,
)));
}
// `timeRange` is admitted: a bucketed indexOnly index writes one
// entry per containing bucket, exactly as stored types do (the
// walkers' bucket fan-out is shared). No indexOnly-specific
// source rule is needed — the transform's source must be a
// system timestamp (the shared timeRange rules), it must be the
// index's first property, and the prefix rule below admits only
// `$ownerId` and `$createdAt` as system properties, which pins
// the source to `$createdAt` (the only timestamp an immutable,
// create-once document carries). Delete-by-values stays
// deterministic: `$createdAt` is forced into `required` (rule
// below), so the carried value reproduces the exact bucket set
// the create wrote. A bucketed index involves `$createdAt` and
// therefore never counts as the required `$createdAt`-free
// proof index.
// The sum axes (summable / rangeSummable / rankedSummable /
// rankedAverageable / the averageable sugar) are admitted: a
// summable index's terminal entry is an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,100 @@ fn rejects_summable_naming_non_integer_property() {
);
}

#[test]
fn accepts_time_range_bucketed_index() {
// A bucketed indexOnly index writes one entry per containing bucket,
// sharing the stored types' walker fan-out. `$createdAt` must be the
// transform source (the prefix rule admits no other system timestamp)
// and must be required (shared timeRange rule).
let mut schema = likes_schema();
schema
.set_value(
"required",
platform_value!(["hashtag", "postId", "$createdAt"]),
)
.expect("required applies");
schema
.get_mut("indices")
.expect("indices accessible")
.expect("indices present")
.as_array_mut()
.expect("indices is an array")
.push(platform_value!({
"name": "byHourHashtag",
"properties": [{ "$createdAt": "asc" }, { "hashtag": "asc" }],
"terminal": "$ownerId",
"timeRange": { "on": "$createdAt", "range": 3600u64, "step": 900u64 },
"countable": true,
"rangeCountable": true
}));
let document_type = parse_with(schema, PlatformVersion::latest(), false)
.expect("bucketed indexOnly index admitted");
let bucketed = document_type
.indices
.values()
.find(|index| index.time_range.is_some())
.expect("the bucketed index parsed");
assert_eq!(bucketed.time_range.as_ref().unwrap().overlap_factor(), 4);
}

#[test]
fn rejects_time_range_bucketed_index_without_required_created_at() {
// Same shape, but $createdAt missing from `required` — the indexOnly
// indexed-$createdAt rule fires (creation only assigns the timestamp
// for required system times, and an entry cannot represent a missing
// value).
let mut schema = likes_schema();
schema
.get_mut("indices")
.expect("indices accessible")
.expect("indices present")
.as_array_mut()
.expect("indices is an array")
.push(platform_value!({
"name": "byHourHashtag",
"properties": [{ "$createdAt": "asc" }, { "hashtag": "asc" }],
"terminal": "$ownerId",
"timeRange": { "on": "$createdAt", "range": 3600u64, "step": 900u64 }
}));
expect_structure_error(
parse_with(schema, PlatformVersion::latest(), false),
"must be listed in `required`",
);
}

#[test]
fn rejects_only_bucketed_indexes() {
// A bucketed index involves $createdAt, so a doctype whose every index
// is bucketed has no $createdAt-free proof index and stays refused.
let mut schema = likes_schema();
schema
.set_value(
"required",
platform_value!(["hashtag", "postId", "$createdAt"]),
)
.expect("required applies");
schema
.set_value(
"indices",
platform_value!([{
"name": "byHourHashtagPost",
"properties": [
{ "$createdAt": "asc" },
{ "hashtag": "asc" },
{ "postId": "asc" }
],
"terminal": "$ownerId",
"timeRange": { "on": "$createdAt", "range": 3600u64, "step": 900u64 }
}]),
)
.expect("indices apply");
expect_structure_error(
parse_with(schema, PlatformVersion::latest(), false),
"does not involve $createdAt",
);
}

#[test]
fn rejects_terminal_repeating_an_index_property() {
// byLiker's prefix is [$ownerId]; making $ownerId its terminal too
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,197 @@ mod index_only_executed_proof_tests {
);
}

/// A signed `beat` create — the bucketed-type counterpart of
/// `signed_mark_create`. The `beat` doctype's `byHourHashtag` index
/// buckets `$createdAt` on a 3600s/900s grid, so a create fans out one
/// entry per containing bucket, while the plain `byHashtag` index is
/// the `$createdAt`-free proof index the executed proofs run against.
async fn signed_beat_create(
contract: &DataContract,
owner: Identifier,
hashtag: &str,
nonce: u64,
key: &dpp::identity::IdentityPublicKey,
signer: &SimpleSigner,
rng: &mut StdRng,
platform_version: &PlatformVersion,
) -> (StateTransition, Document) {
use dpp::document::DocumentV0Setters;
let beat_type = contract
.document_type_for_name("beat")
.expect("beat doctype exists");
let entropy = Bytes32::random_with_rng(rng);
let mut beat = beat_type
.random_document_with_identifier_and_entropy(
rng,
owner,
entropy,
DocumentFieldFillType::FillIfNotRequired,
DocumentFieldFillSize::AnyDocumentFillSize,
platform_version,
)
.expect("expected a random beat");
beat.set("hashtag", hashtag.into());
// Consensus assigns `$createdAt` from the block time at create
// (BlockInfo::default() in this suite), and the delete-by-values
// must carry that committed value to reproduce the bucket set.
beat.set_created_at(Some(0));
let create = BatchTransition::new_document_creation_transition_from_document(
beat.clone(),
beat_type,
entropy.0,
key,
nonce,
0,
None,
signer,
platform_version,
None,
)
.await
.expect("expected the create transition");
(create, beat)
}

/// The bucketed lifecycle through the pipeline: a `beat` create fans
/// out per bucket and executes, its executed proof verifies against
/// the non-bucketed proof index, a duplicate collides on the probes,
/// and the delete removes every bucket entry and proves absence.
#[tokio::test]
async fn test_bucketed_beat_lifecycle_and_executed_proofs() {
let platform_version = PlatformVersion::latest();
let mut platform = TestPlatformBuilder::new()
.build_with_mock_rpc()
.set_genesis_state();
let platform_state = platform.state.load();
let mut rng = StdRng::seed_from_u64(9099);

let (alice, alice_signer, alice_key) =
setup_identity(&mut platform, 958, dash_to_credits!(1.0));
let contract = register_likes(&platform, alice.id(), platform_version);
let contract_arc = Arc::new(contract.clone());

let (create, beat) = signed_beat_create(
&contract,
alice.id(),
"dash",
2,
&alice_key,
&alice_signer,
&mut rng,
platform_version,
)
.await;
let result = process_and_commit(&platform, &platform_state, &create, platform_version);
assert_eq!(
result.valid_count(),
1,
"the beat create must execute: {:?}",
result.execution_results()
);

// ── executed-create proof (via the non-bucketed proof index) ───
let proof = platform
.drive
.prove_state_transition(&create, None, platform_version)
.expect("expected to prove the executed create")
.into_data()
.expect("expected proof bytes");
let lookup = |_id: &dpp::identifier::Identifier| Ok(Some(Arc::clone(&contract_arc)));
let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof(
&create,
&BlockInfo::default(),
proof.as_slice(),
&lookup,
platform_version,
)
.expect("expected the executed bucketed create proof to verify");
assert_ne!(root_hash, [0u8; 32]);
let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else {
panic!("expected verified documents");
};
assert!(
documents
.into_iter()
.next()
.expect("one document")
.1
.is_some(),
"the created beat is present"
);

// ── a duplicate collides on the probes ─────────────────────────
let (duplicate, _) = signed_beat_create(
&contract,
alice.id(),
"dash",
3,
&alice_key,
&alice_signer,
&mut rng,
platform_version,
)
.await;
let result = process_and_commit(&platform, &platform_state, &duplicate, platform_version);
assert_eq!(result.invalid_paid_count(), 1);
assert_matches!(
result.execution_results().as_slice(),
[StateTransitionExecutionResult::PaidConsensusError {
error: ConsensusError::StateError(StateError::DuplicateUniqueIndexError(_)),
..
}],
"a second beat by the same owner under the same hashtag must collide"
);

// ── delete removes every bucket entry and proves absence ───────
let beat_type = contract
.document_type_for_name("beat")
.expect("beat doctype exists");
let delete = BatchTransition::new_document_deletion_transition_from_document(
beat,
beat_type,
&alice_key,
4,
0,
None,
&alice_signer,
platform_version,
None,
)
.await
.expect("expected the delete transition");
let result = process_and_commit(&platform, &platform_state, &delete, platform_version);
assert_eq!(
result.valid_count(),
1,
"the beat delete must execute: {:?}",
result.execution_results()
);

let proof = platform
.drive
.prove_state_transition(&delete, None, platform_version)
.expect("expected to prove the executed delete")
.into_data()
.expect("expected proof bytes");
let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof(
&delete,
&BlockInfo::default(),
proof.as_slice(),
&lookup,
platform_version,
)
.expect("expected the executed bucketed delete proof to verify");
assert_ne!(root_hash, [0u8; 32]);
let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else {
panic!("expected verified documents");
};
assert!(
documents.into_iter().next().expect("one entry").1.is_none(),
"the deleted beat must be proven absent"
);
}

/// A signed `tip` create for the given amount — the summable-type
/// counterpart of `signed_mark_create`. The `tip` doctype's proof index
/// (`byPost`) is summable, so its entries are
Expand Down
Loading
Loading