1use super::AcceptedSchemaCatalogContext;
8use crate::{
9 db::{
10 DbSession, DynamicMutation, DynamicMutationResult, DynamicStructuralPatch,
11 DynamicTypedBindingError, DynamicTypedEntityBinding, DynamicTypedFieldBindingRequest,
12 DynamicTypedFieldType, DynamicTypedMutation, DynamicTypedStructuralPatch, DynamicWriteCell,
13 commit::{CommitRowOp, database_incarnation_id},
14 data::{
15 AcceptedMutationIntentPatch, AcceptedPreKeyInsert, DecodedDataStoreKey, FieldSlot,
16 RawRow, StructuralRowContract, StructuralSlotReader,
17 canonical_row_from_raw_row_with_accepted_decode_contract,
18 resolve_existing_replace_structural_patch_with_accepted_contract,
19 resolve_insert_structural_patch_with_accepted_contract,
20 resolve_update_structural_patch_with_accepted_contract,
21 },
22 executor::{
23 AcceptedMutationConstraintScheduler,
24 commit_structural_row_ops_with_mutation_progress_for_path,
25 commit_structural_row_ops_with_window_for_path, mutation_key_exists_error,
26 },
27 integrity::MutationProgressRecordOp,
28 schema::{
29 AcceptedFieldKind, AcceptedIdentityAllocation, AcceptedRowLayoutRuntimeContract,
30 FieldId, FieldInsertGeneration, IdentityStatementCursor, lower_field_type,
31 output_value_from_runtime,
32 },
33 write_context::{AcceptedWriteContext, MutationMode},
34 },
35 error::{InternalError, MutationDiagnosticContext},
36 metrics::sink::{MetricsEvent, SaveMutationKind, record},
37 traits::CanisterKind,
38 types::{CurrentTimestamp, Timestamp},
39 value::{InputValue, Value},
40};
41use icydb_schema::{EntitySourceKey, FieldSourceKey, FieldType, TypeSourceKey};
42
43#[derive(Clone, Debug, Eq, PartialEq)]
44struct AcceptedIdentityInsertField {
45 field_id: FieldId,
46 field_slot: usize,
47 accepted_kind: AcceptedFieldKind,
48}
49
50struct AcceptedStructuralMutationCommitOptions {
51 mutation_progress: Option<MutationProgressRecordOp>,
52 capture_output_values: bool,
53}
54
55impl AcceptedStructuralMutationCommitOptions {
56 const fn standard() -> Self {
57 Self {
58 mutation_progress: None,
59 capture_output_values: true,
60 }
61 }
62
63 const fn with_mutation_progress(mutation_progress: MutationProgressRecordOp) -> Self {
64 Self {
65 mutation_progress: Some(mutation_progress),
66 capture_output_values: false,
67 }
68 }
69}
70
71pub(in crate::db::session) enum AcceptedStructuralMutationTarget {
74 ResolveFromAfterImage,
75 Expected(Box<DecodedDataStoreKey>),
76 ExpectedLoaded(AcceptedLoadedStructuralRow),
77}
78
79pub(in crate::db::session) struct AcceptedLoadedStructuralRow {
82 key: Box<DecodedDataStoreKey>,
83 row: RawRow,
84}
85
86impl AcceptedLoadedStructuralRow {
87 pub(in crate::db::session) fn from_validated_parts(
88 key: DecodedDataStoreKey,
89 row: RawRow,
90 ) -> Self {
91 Self {
92 key: Box::new(key),
93 row,
94 }
95 }
96
97 fn into_parts(self) -> (DecodedDataStoreKey, RawRow) {
98 (*self.key, self.row)
99 }
100}
101
102impl AcceptedStructuralMutationTarget {
103 pub(in crate::db::session) fn expected(key: DecodedDataStoreKey) -> Self {
104 Self::Expected(Box::new(key))
105 }
106
107 pub(in crate::db::session) const fn expected_loaded(row: AcceptedLoadedStructuralRow) -> Self {
110 Self::ExpectedLoaded(row)
111 }
112}
113
114pub(in crate::db::session) enum AcceptedStructuralMutation {
117 Save {
118 mode: MutationMode,
119 target: AcceptedStructuralMutationTarget,
120 patch: AcceptedMutationIntentPatch,
121 },
122 Delete {
123 key: Box<DecodedDataStoreKey>,
124 },
125}
126
127impl AcceptedStructuralMutation {
128 pub(in crate::db::session) const fn save(
129 mode: MutationMode,
130 target: AcceptedStructuralMutationTarget,
131 patch: AcceptedMutationIntentPatch,
132 ) -> Self {
133 Self::Save {
134 mode,
135 target,
136 patch,
137 }
138 }
139
140 pub(in crate::db::session) fn delete(key: DecodedDataStoreKey) -> Self {
141 Self::Delete { key: Box::new(key) }
142 }
143}
144
145const MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS: usize = 4_096;
146const MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES: usize = 16 * 1024 * 1024;
147const MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES: usize = 1024 * 1024;
148
149fn add_structural_mutation_staged_bytes(
150 total: &mut usize,
151 lengths: impl IntoIterator<Item = usize>,
152) -> Result<(), InternalError> {
153 for length in lengths {
154 *total = total.checked_add(length).ok_or_else(|| {
155 InternalError::mutation_batch_staged_bytes_exceeded(
156 None,
157 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
158 )
159 })?;
160 if *total > MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES {
161 return Err(InternalError::mutation_batch_staged_bytes_exceeded(
162 Some(*total),
163 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
164 ));
165 }
166 }
167 Ok(())
168}
169
170fn validate_structural_mutation_result_bytes(encoded_bytes: usize) -> Result<(), InternalError> {
171 if encoded_bytes > MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES {
172 return Err(InternalError::mutation_batch_result_bytes_exceeded(
173 encoded_bytes,
174 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES,
175 ));
176 }
177 Ok(())
178}
179
180pub(in crate::db::session) struct AcceptedStructuralMutationRow {
182 values: Vec<Value>,
183 logical_changed: bool,
184}
185
186impl AcceptedStructuralMutationRow {
187 #[cfg(any(feature = "sql", test))]
188 pub(in crate::db::session) fn into_values(self) -> Vec<Value> {
189 self.values
190 }
191
192 pub(in crate::db::session) const fn logical_changed(&self) -> bool {
193 self.logical_changed
194 }
195}
196
197const fn dynamic_mutation_mode(request: &DynamicMutation) -> Option<MutationMode> {
198 match request {
199 DynamicMutation::Insert { .. } => Some(MutationMode::Insert),
200 DynamicMutation::Update { .. } => Some(MutationMode::Update),
201 DynamicMutation::Replace { .. } => Some(MutationMode::Replace),
202 DynamicMutation::Delete { .. } => None,
203 }
204}
205
206const fn dynamic_typed_mutation_mode(request: &DynamicTypedMutation) -> MutationMode {
207 match request {
208 DynamicTypedMutation::Insert { .. } => MutationMode::Insert,
209 DynamicTypedMutation::Update { .. } => MutationMode::Update,
210 DynamicTypedMutation::Replace { .. } => MutationMode::Replace,
211 }
212}
213
214const fn diagnostic_mutation_operation(
215 mode: MutationMode,
216) -> icydb_diagnostic_code::DiagnosticMutationOperation {
217 match mode {
218 MutationMode::Insert => icydb_diagnostic_code::DiagnosticMutationOperation::Insert,
219 MutationMode::Replace => icydb_diagnostic_code::DiagnosticMutationOperation::Replace,
220 MutationMode::Update => icydb_diagnostic_code::DiagnosticMutationOperation::Update,
221 }
222}
223
224const fn mutation_diagnostic_context(
225 entity_tag: crate::types::EntityTag,
226 mode: MutationMode,
227 batch_position: u32,
228) -> MutationDiagnosticContext {
229 MutationDiagnosticContext::new(
230 entity_tag.value(),
231 diagnostic_mutation_operation(mode),
232 batch_position,
233 )
234}
235
236const fn dynamic_write_context(operation_timestamp: Timestamp) -> AcceptedWriteContext {
237 AcceptedWriteContext::new(operation_timestamp)
238}
239
240fn insert_key_exists_after_generation(identity_generated: bool) -> InternalError {
241 if identity_generated {
242 InternalError::identity_state_corruption()
243 } else {
244 mutation_key_exists_error()
245 }
246}
247
248fn dynamic_key(
249 entity_tag: crate::types::EntityTag,
250 key: &InputValue,
251) -> Result<DecodedDataStoreKey, InternalError> {
252 let value = key
253 .clone()
254 .try_into_runtime_non_enum()
255 .ok_or_else(InternalError::executor_unsupported)?;
256 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
257}
258
259fn lower_dynamic_patch(
260 entity_path: &str,
261 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
262 patch: &DynamicStructuralPatch,
263 mode: MutationMode,
264 mutation_context: MutationDiagnosticContext,
265) -> Result<AcceptedMutationIntentPatch, InternalError> {
266 let mut lowered = AcceptedMutationIntentPatch::new();
267 for (field_name, cell) in patch.fields() {
268 let slot = descriptor
269 .field_slot_index_by_name(field_name)
270 .ok_or_else(|| {
271 InternalError::mutation_structural_field_unknown(entity_path, field_name)
272 })?;
273 let field = descriptor
274 .field_for_slot_index(slot)
275 .ok_or_else(InternalError::executor_invariant)?;
276 if !matches!(cell, DynamicWriteCell::Omitted)
277 && (field.write_policy().insert_generation().is_some()
278 || field.write_policy().write_management().is_some())
279 {
280 return Err(InternalError::mutation_database_owned_field_explicit(
281 mutation_context,
282 field.field_id().get(),
283 ));
284 }
285 let slot = FieldSlot::from_validated_index(slot);
286 lowered = match cell {
287 DynamicWriteCell::Omitted => lowered,
288 DynamicWriteCell::Default => match mode {
289 MutationMode::Insert | MutationMode::Replace => {
290 lowered.set_explicit_insert_default(slot)
291 }
292 MutationMode::Update => lowered.set_explicit_update_default(slot),
293 },
294 DynamicWriteCell::Null => lowered.set_authored(slot, InputValue::Null),
295 DynamicWriteCell::Value(value) => lowered.set_authored(slot, value.clone()),
296 };
297 }
298 Ok(lowered)
299}
300
301fn lower_dynamic_mutation_intent(
302 entity_tag: crate::types::EntityTag,
303 entity_path: &str,
304 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
305 request: &DynamicMutation,
306 batch_position: u32,
307) -> Result<(AcceptedStructuralMutation, Option<SaveMutationKind>), InternalError> {
308 match request {
309 DynamicMutation::Insert { patch, .. } => {
310 let mode = MutationMode::Insert;
311 Ok((
312 AcceptedStructuralMutation::save(
313 mode,
314 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
315 lower_dynamic_patch(
316 entity_path,
317 descriptor,
318 patch,
319 mode,
320 mutation_diagnostic_context(entity_tag, mode, batch_position),
321 )?,
322 ),
323 Some(SaveMutationKind::Insert),
324 ))
325 }
326 DynamicMutation::Update { key, patch, .. }
327 | DynamicMutation::Replace { key, patch, .. } => {
328 let mode =
329 dynamic_mutation_mode(request).ok_or_else(InternalError::executor_invariant)?;
330 let kind = match mode {
331 MutationMode::Insert => SaveMutationKind::Insert,
332 MutationMode::Replace => SaveMutationKind::Replace,
333 MutationMode::Update => SaveMutationKind::Update,
334 };
335 Ok((
336 AcceptedStructuralMutation::save(
337 mode,
338 AcceptedStructuralMutationTarget::expected(dynamic_key(entity_tag, key)?),
339 lower_dynamic_patch(
340 entity_path,
341 descriptor,
342 patch,
343 mode,
344 mutation_diagnostic_context(entity_tag, mode, batch_position),
345 )?,
346 ),
347 Some(kind),
348 ))
349 }
350 DynamicMutation::Delete { key, .. } => Ok((
351 AcceptedStructuralMutation::delete(dynamic_key(entity_tag, key)?),
352 None,
353 )),
354 }
355}
356
357fn lower_typed_patch(
358 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
359 patch: &DynamicTypedStructuralPatch,
360 mode: MutationMode,
361 mutation_context: MutationDiagnosticContext,
362) -> Result<AcceptedMutationIntentPatch, InternalError> {
363 let mut lowered = AcceptedMutationIntentPatch::new();
364 for (field_id, slot, cell) in patch.fields() {
365 let slot_index = usize::from(*slot);
366 let field = descriptor
367 .field_for_slot_index(slot_index)
368 .ok_or_else(InternalError::store_invariant)?;
369 if field.field_id().get() != *field_id {
370 return Err(InternalError::store_invariant());
371 }
372 if !matches!(cell, DynamicWriteCell::Omitted)
373 && (field.write_policy().insert_generation().is_some()
374 || field.write_policy().write_management().is_some())
375 {
376 return Err(InternalError::mutation_database_owned_field_explicit(
377 mutation_context,
378 field.field_id().get(),
379 ));
380 }
381 let slot = FieldSlot::from_validated_index(slot_index);
382 lowered = match cell {
383 DynamicWriteCell::Omitted => lowered,
384 DynamicWriteCell::Default => match mode {
385 MutationMode::Insert | MutationMode::Replace => {
386 lowered.set_explicit_insert_default(slot)
387 }
388 MutationMode::Update => lowered.set_explicit_update_default(slot),
389 },
390 DynamicWriteCell::Null => lowered.set_authored(slot, InputValue::Null),
391 DynamicWriteCell::Value(value) => lowered.set_authored(slot, value.clone()),
392 };
393 }
394 Ok(lowered)
395}
396
397fn preserve_dynamic_replacement_identity(
398 key: &DecodedDataStoreKey,
399 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
400 mut patch: AcceptedMutationIntentPatch,
401) -> Result<AcceptedMutationIntentPatch, InternalError> {
402 let primary_key_slots = descriptor.primary_key_slot_indices();
403 let runtime_key = key.primary_key_runtime_value();
404 let components = match runtime_key {
405 Value::List(values) if primary_key_slots.len() > 1 => values,
406 value if primary_key_slots.len() == 1 => vec![value],
407 _ => return Err(InternalError::executor_invariant()),
408 };
409 if components.len() != primary_key_slots.len() {
410 return Err(InternalError::executor_invariant());
411 }
412
413 for (slot, value) in primary_key_slots.iter().copied().zip(components) {
414 let _ = descriptor
415 .field_for_slot_index(slot)
416 .ok_or_else(InternalError::executor_invariant)?;
417 let has_explicit_intent = patch
418 .entries()
419 .iter()
420 .any(|entry| entry.slot().index() == slot);
421 if has_explicit_intent {
422 continue;
423 }
424 let value = InputValue::try_from_runtime_non_enum(&value)
425 .ok_or_else(InternalError::executor_invariant)?;
426 patch =
427 patch.set_preserved_replacement_identity(FieldSlot::from_validated_index(slot), value);
428 }
429
430 Ok(patch)
431}
432
433fn accepted_identity_insert_field(
437 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
438) -> Result<Option<AcceptedIdentityInsertField>, InternalError> {
439 let mut identity = None;
440 for field in descriptor.fields() {
441 if field.write_policy().insert_generation() != Some(FieldInsertGeneration::Identity) {
442 continue;
443 }
444 let field_slot = usize::from(field.slot().get());
445 if identity
446 .replace(AcceptedIdentityInsertField {
447 field_id: field.field_id(),
448 field_slot,
449 accepted_kind: field.kind().clone(),
450 })
451 .is_some()
452 || descriptor.primary_key_slot_indices() != [field_slot]
453 {
454 return Err(InternalError::identity_corruption());
455 }
456 }
457 Ok(identity)
458}
459
460fn checked_pre_key_candidate_count(count: usize) -> Result<u32, InternalError> {
461 u32::try_from(count).map_err(|_| InternalError::identity_candidate_count_exhausted())
462}
463
464fn validate_identity_materialization(
465 entity_tag: crate::types::EntityTag,
466 identity_field: &AcceptedIdentityInsertField,
467 candidate: &AcceptedPreKeyInsert,
468 allocation: &AcceptedIdentityAllocation,
469 data_key: &DecodedDataStoreKey,
470 reader: &StructuralSlotReader<'_>,
471) -> Result<(), InternalError> {
472 let owner = allocation.owner();
473 let slot_value = reader.required_cached_value(identity_field.field_slot)?;
474 if candidate.entity_tag() != entity_tag
475 || candidate.input_ordinal() != allocation.input_ordinal()
476 || owner.entity_tag() != entity_tag
477 || owner.field_id() != identity_field.field_id
478 || allocation.field_slot() != identity_field.field_slot
479 || slot_value != allocation.value()
480 || data_key.primary_key_runtime_value() != *allocation.value()
481 {
482 return Err(InternalError::identity_corruption());
483 }
484 Ok(())
485}
486
487fn data_key_from_row(
488 entity_tag: crate::types::EntityTag,
489 contract: &StructuralRowContract,
490 row: &RawRow,
491) -> Result<DecodedDataStoreKey, InternalError> {
492 let reader =
493 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(row, contract)?;
494 let values = contract
495 .primary_key_slot_indices()
496 .iter()
497 .map(|slot| reader.required_cached_value(*slot).cloned())
498 .collect::<Result<Vec<_>, _>>()?;
499 let value = match values.as_slice() {
500 [value] => value.clone(),
501 _ => Value::List(values),
502 };
503 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
504}
505
506#[cfg(feature = "sql")]
507pub(in crate::db::session) fn structural_data_key_from_runtime_values(
508 entity_tag: crate::types::EntityTag,
509 values: Vec<Value>,
510) -> Result<DecodedDataStoreKey, InternalError> {
511 let value = match values.as_slice() {
512 [value] => value.clone(),
513 _ => Value::List(values),
514 };
515 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
516}
517
518fn validated_existing_row(
519 store: crate::db::registry::StoreHandle,
520 data_key: &DecodedDataStoreKey,
521 contract: &StructuralRowContract,
522) -> Result<Option<RawRow>, InternalError> {
523 let raw_key = data_key.to_raw()?;
524 let row = store.with_data(|data| data.get(&raw_key));
525 if let Some(row) = row.as_ref() {
526 let reader =
527 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(row, contract)?;
528 reader.validate_primary_key(data_key)?;
529 }
530 Ok(row)
531}
532
533fn prepare_dynamic_mutation_result(
534 catalog: &AcceptedSchemaCatalogContext,
535 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
536 rows: Vec<AcceptedStructuralMutationRow>,
537 enforce_mixed_batch_result_bound: bool,
538) -> Result<DynamicMutationResult, InternalError> {
539 let affected_rows = rows.iter().try_fold(0_u32, |total, row| {
540 total
541 .checked_add(u32::from(row.logical_changed()))
542 .ok_or_else(InternalError::executor_invariant)
543 })?;
544 let columns = descriptor
545 .fields()
546 .iter()
547 .map(|field| field.name().to_string())
548 .collect();
549 let rows = rows
550 .into_iter()
551 .map(|row| {
552 row.values
553 .iter()
554 .map(|value| {
555 output_value_from_runtime(catalog.enum_catalog(), value)
556 .map_err(|_| InternalError::store_invariant())
557 })
558 .collect::<Result<Vec<_>, _>>()
559 })
560 .collect::<Result<Vec<_>, _>>()?;
561 let result = DynamicMutationResult {
562 entity: catalog.snapshot().entity_name().to_string(),
563 columns,
564 rows,
565 affected_rows,
566 };
567 if enforce_mixed_batch_result_bound {
568 let encoded =
569 candid::encode_one(&result).map_err(|_| InternalError::executor_invariant())?;
570 validate_structural_mutation_result_bytes(encoded.len())?;
571 }
572 Ok(result)
573}
574
575fn dynamic_typed_field_type(
576 field_type: DynamicTypedFieldType,
577) -> Result<FieldType, DynamicTypedBindingError> {
578 match field_type {
579 DynamicTypedFieldType::Scalar(scalar) => Ok(FieldType::Scalar(scalar)),
580 DynamicTypedFieldType::List(item) => {
581 Ok(FieldType::List(Box::new(dynamic_typed_field_type(*item)?)))
582 }
583 DynamicTypedFieldType::Named(source_key) => TypeSourceKey::try_new(source_key)
584 .map(FieldType::Named)
585 .map_err(|_| DynamicTypedBindingError::FieldUnavailable),
586 }
587}
588
589fn typed_adapter_field_kind_matches(
590 accepted: &AcceptedFieldKind,
591 expected: &AcceptedFieldKind,
592) -> bool {
593 if accepted == expected {
594 return true;
595 }
596 match (accepted, expected) {
597 (AcceptedFieldKind::Relation { key_kind, .. }, expected) => {
598 typed_adapter_field_kind_matches(key_kind, expected)
599 }
600 (AcceptedFieldKind::List(accepted), AcceptedFieldKind::List(expected)) => {
601 typed_adapter_field_kind_matches(accepted, expected)
602 }
603 _ => false,
604 }
605}
606
607impl<C: CanisterKind> DbSession<C> {
608 pub fn issue_typed_entity_binding(
610 &self,
611 entity_source_key: &str,
612 field_requests: &[DynamicTypedFieldBindingRequest],
613 ) -> Result<DynamicTypedEntityBinding, DynamicTypedBindingError> {
614 let entity_source = EntitySourceKey::try_new(entity_source_key)
615 .map_err(|_| DynamicTypedBindingError::FieldUnavailable)?;
616 let field_requests = field_requests
617 .iter()
618 .map(|request| {
619 Ok((
620 FieldSourceKey::try_new(request.source_key.clone())
621 .map_err(|_| DynamicTypedBindingError::FieldUnavailable)?,
622 dynamic_typed_field_type(request.field_type.clone())?,
623 request.nullable,
624 ))
625 })
626 .collect::<Result<Vec<_>, DynamicTypedBindingError>>()?;
627 let catalog = self
628 .find_accepted_schema_catalog_context_for_entity_source_key(entity_source.as_str())?
629 .ok_or(DynamicTypedBindingError::FieldUnavailable)?;
630 let identity = catalog.identity();
631 if identity.entity_path() != entity_source.as_str() {
632 return Err(InternalError::store_invariant().into());
633 }
634 let store = self.db.recovered_store(identity.store_path())?;
635 let bundle = store
636 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
637 .ok_or_else(InternalError::store_invariant)?;
638 let entity_tag = identity.entity_tag();
639 if bundle.source_bindings().entity(&entity_source) != Some(entity_tag)
640 || bundle.revision() != catalog.revision()
641 {
642 return Err(InternalError::store_invariant().into());
643 }
644 let snapshot = bundle
645 .entity_snapshots()
646 .get(&entity_tag)
647 .ok_or_else(InternalError::store_invariant)?;
648 let row_contract = catalog.inspection_plan().row_contract();
649 let mut fields = Vec::with_capacity(field_requests.len());
650 for (source, field_type, nullable) in &field_requests {
651 let field_id = bundle
652 .source_bindings()
653 .field(entity_tag, source)
654 .ok_or(DynamicTypedBindingError::FieldUnavailable)?;
655 let field = snapshot
656 .fields()
657 .iter()
658 .find(|field| field.id() == field_id)
659 .ok_or_else(InternalError::store_invariant)?;
660 let runtime_field =
661 row_contract.required_accepted_field_contract(usize::from(field.slot().get()))?;
662 if runtime_field.field_id() != field_id {
663 return Err(InternalError::store_invariant().into());
664 }
665 let expected_kind = lower_field_type(field_type, bundle.source_bindings())
666 .map_err(|_| DynamicTypedBindingError::IncompatibleField)?;
667 if field.nullable() != *nullable
668 || !typed_adapter_field_kind_matches(field.kind(), &expected_kind)
669 {
670 return Err(DynamicTypedBindingError::IncompatibleField);
671 }
672 fields.push((
673 source.as_str().to_string(),
674 field_id.get(),
675 field.slot().get(),
676 field.name().to_string(),
677 ));
678 }
679 let adapter_names = bundle.typed_adapter_names()?;
680
681 DynamicTypedEntityBinding::new(
682 database_incarnation_id()?.to_bytes(),
683 entity_source.as_str().to_string(),
684 snapshot.entity_name().to_string(),
685 entity_tag.value(),
686 catalog.revision().get(),
687 catalog.fingerprint(),
688 row_contract.current_layout_version().get(),
689 fields,
690 adapter_names.named_types,
691 adapter_names.enum_variants,
692 adapter_names.composite_fields,
693 )
694 .map_err(Into::into)
695 }
696
697 pub(in crate::db::session) fn current_typed_entity_binding_catalog(
698 &self,
699 binding: &DynamicTypedEntityBinding,
700 ) -> Result<Option<AcceptedSchemaCatalogContext>, InternalError> {
701 if database_incarnation_id()?.to_bytes() != binding.database_incarnation {
702 return Ok(None);
703 }
704 let Some(catalog) = self.find_accepted_schema_catalog_context_for_entity_source_key(
705 binding.entity_source.as_str(),
706 )?
707 else {
708 return Ok(None);
709 };
710 let row_contract = catalog.inspection_plan().row_contract();
711 let identity = catalog.identity();
712 if identity.entity_path() != binding.entity_source.as_str()
713 || identity.entity_tag().value() != binding.entity_tag
714 || catalog.revision().get() != binding.accepted_revision
715 || catalog.fingerprint() != binding.accepted_fingerprint
716 || row_contract.current_layout_version().get() != binding.entity_generation
717 {
718 return Ok(None);
719 }
720 let entity_source = EntitySourceKey::try_new(binding.entity_source.clone())
721 .map_err(|_| InternalError::store_invariant())?;
722 let store = self.db.recovered_store(identity.store_path())?;
723 let bundle = store
724 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
725 .ok_or_else(InternalError::store_invariant)?;
726 if bundle.revision() != catalog.revision()
727 || bundle.source_bindings().entity(&entity_source) != Some(identity.entity_tag())
728 {
729 return Ok(None);
730 }
731 let snapshot = bundle
732 .entity_snapshots()
733 .get(&identity.entity_tag())
734 .ok_or_else(InternalError::store_invariant)?;
735 for (source_key, expected_field_id, expected_slot) in binding.field_identity_bindings() {
736 let source = FieldSourceKey::try_new(source_key)
737 .map_err(|_| InternalError::store_invariant())?;
738 let Some(field_id) = bundle
739 .source_bindings()
740 .field(identity.entity_tag(), &source)
741 else {
742 return Ok(None);
743 };
744 let Some(field) = snapshot
745 .fields()
746 .iter()
747 .find(|field| field.id() == field_id)
748 else {
749 return Err(InternalError::store_invariant());
750 };
751 if field_id.get() != expected_field_id || field.slot().get() != expected_slot {
752 return Ok(None);
753 }
754 }
755 Ok(Some(catalog))
756 }
757
758 pub fn typed_entity_binding_is_current(
760 &self,
761 binding: &DynamicTypedEntityBinding,
762 ) -> Result<bool, InternalError> {
763 self.current_typed_entity_binding_catalog(binding)
764 .map(|catalog| catalog.is_some())
765 }
766
767 #[cfg(feature = "sql")]
770 pub(in crate::db::session) fn execute_accepted_structural_delete_batch(
771 &self,
772 catalog: &AcceptedSchemaCatalogContext,
773 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
774 keys: Vec<DecodedDataStoreKey>,
775 precommit_validation: impl FnOnce(&[Vec<Value>]) -> Result<(), InternalError>,
776 ) -> Result<Vec<Vec<Value>>, InternalError> {
777 let mutations = keys
778 .into_iter()
779 .map(AcceptedStructuralMutation::delete)
780 .collect();
781 self.execute_accepted_structural_mutation_batch_inner(
782 catalog,
783 descriptor,
784 mutations,
785 Timestamp::now(),
786 AcceptedStructuralMutationCommitOptions::standard(),
787 |rows| {
788 let rows = rows
789 .into_iter()
790 .map(AcceptedStructuralMutationRow::into_values)
791 .collect::<Vec<_>>();
792 precommit_validation(rows.as_slice())?;
793 Ok(rows)
794 },
795 )
796 }
797
798 pub(in crate::db::session) fn execute_accepted_structural_save_batch<T>(
806 &self,
807 catalog: &AcceptedSchemaCatalogContext,
808 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
809 mutations: Vec<AcceptedStructuralMutation>,
810 operation_timestamp: Timestamp,
811 precommit_preparation: impl FnOnce(
812 Vec<AcceptedStructuralMutationRow>,
813 ) -> Result<T, InternalError>,
814 ) -> Result<T, InternalError> {
815 self.execute_accepted_structural_mutation_batch_inner(
816 catalog,
817 descriptor,
818 mutations,
819 operation_timestamp,
820 AcceptedStructuralMutationCommitOptions::standard(),
821 precommit_preparation,
822 )
823 }
824
825 #[cfg(any(feature = "sql", test))]
827 pub(in crate::db::session) fn execute_accepted_structural_update_with_mutation_progress(
828 &self,
829 catalog: &AcceptedSchemaCatalogContext,
830 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
831 mutations: Vec<AcceptedStructuralMutation>,
832 operation_timestamp: Timestamp,
833 mutation_progress: MutationProgressRecordOp,
834 ) -> Result<usize, InternalError> {
835 self.execute_accepted_structural_mutation_batch_inner(
836 catalog,
837 descriptor,
838 mutations,
839 operation_timestamp,
840 AcceptedStructuralMutationCommitOptions::with_mutation_progress(mutation_progress),
841 |rows| Ok(rows.len()),
842 )
843 }
844
845 #[expect(
846 clippy::too_many_lines,
847 reason = "one phased owner keeps accepted authority, mutation context, precommit preparation, output capture, and commit staging inseparable"
848 )]
849 fn execute_accepted_structural_mutation_batch_inner<T>(
850 &self,
851 catalog: &AcceptedSchemaCatalogContext,
852 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
853 mutations: Vec<AcceptedStructuralMutation>,
854 operation_timestamp: Timestamp,
855 options: AcceptedStructuralMutationCommitOptions,
856 precommit_preparation: impl FnOnce(
857 Vec<AcceptedStructuralMutationRow>,
858 ) -> Result<T, InternalError>,
859 ) -> Result<T, InternalError> {
860 let identity = catalog.identity();
861 let AcceptedStructuralMutationCommitOptions {
862 mutation_progress,
863 capture_output_values,
864 } = options;
865 let entity_path = identity.entity_path();
866 let store_path = identity.store_path();
867 let row_decode_contract =
868 descriptor.row_decode_contract(catalog.value_catalog_handle().clone());
869 let row_contract = StructuralRowContract::from_accepted_decode_contract(
870 entity_path,
871 row_decode_contract.clone(),
872 );
873 let store = self.db.recovered_store(store_path)?;
874 let write_context = dynamic_write_context(operation_timestamp);
875 let identity_field = accepted_identity_insert_field(descriptor)?;
876 let identity_incarnation = identity_field
877 .as_ref()
878 .map(|_| database_incarnation_id())
879 .transpose()?;
880 let mutation_count = mutations.len();
881 if mutation_count > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
882 return Err(InternalError::mutation_batch_too_many_items(
883 mutation_count,
884 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
885 ));
886 }
887 let identity_candidate_count = mutations
888 .iter()
889 .filter(|mutation| {
890 matches!(
891 mutation,
892 AcceptedStructuralMutation::Save {
893 mode: MutationMode::Insert,
894 target: AcceptedStructuralMutationTarget::ResolveFromAfterImage,
895 ..
896 }
897 )
898 })
899 .count();
900 let _ = checked_pre_key_candidate_count(identity_candidate_count)?;
901 let mut identity_cursor: Option<IdentityStatementCursor> = None;
902 let mut identity_insert_ordinal = 0_u32;
903 let mut scheduler = AcceptedMutationConstraintScheduler::new(
904 entity_path,
905 identity.entity_tag(),
906 row_decode_contract.clone(),
907 catalog.fingerprint(),
908 catalog.fingerprint_method_version(),
909 catalog.accepted_row_constraints(),
910 mutation_count,
911 );
912 let mut output = Vec::with_capacity(mutation_count);
913 let mut staged_bytes = 0_usize;
914
915 for (input_index, mutation) in mutations.into_iter().enumerate() {
916 let batch_input_ordinal = u32::try_from(input_index).map_err(|_| {
917 InternalError::mutation_batch_too_many_items(
918 mutation_count,
919 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
920 )
921 })?;
922 let AcceptedStructuralMutation::Save {
923 mode,
924 target,
925 patch: authored_patch,
926 } = mutation
927 else {
928 let AcceptedStructuralMutation::Delete { key } = mutation else {
929 return Err(InternalError::executor_invariant());
930 };
931 let before = validated_existing_row(store, &key, &row_contract)?
932 .ok_or_else(|| InternalError::store_not_found(&key))?;
933 let raw_key = key.to_raw()?;
934 let canonical_before = canonical_row_from_raw_row_with_accepted_decode_contract(
935 entity_path,
936 row_decode_contract.clone(),
937 &before,
938 )?;
939 add_structural_mutation_staged_bytes(
940 &mut staged_bytes,
941 [
942 raw_key.as_bytes().len(),
943 canonical_before.as_raw_row().as_bytes().len(),
944 ],
945 )?;
946 scheduler.schedule_delete(
947 CommitRowOp::new(
948 entity_path,
949 raw_key,
950 Some(canonical_before.as_raw_row().as_bytes().to_vec()),
951 None,
952 catalog.fingerprint(),
953 ),
954 batch_input_ordinal,
955 )?;
956 let reader = StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
957 canonical_before.as_raw_row(),
958 &row_contract,
959 )?;
960 let values = if capture_output_values {
961 let mut values = Vec::with_capacity(descriptor.fields().len());
962 for field in descriptor.fields() {
963 values.push(
964 reader
965 .required_cached_value(usize::from(field.slot().get()))?
966 .clone(),
967 );
968 }
969 values
970 } else {
971 Vec::new()
972 };
973 output.push(AcceptedStructuralMutationRow {
974 values,
975 logical_changed: true,
976 });
977 continue;
978 };
979 let mutation_context =
980 mutation_diagnostic_context(identity.entity_tag(), mode, batch_input_ordinal);
981 let (expected_key, preloaded_before, pre_key_insert, mut keyed_patch) = match target {
982 AcceptedStructuralMutationTarget::ResolveFromAfterImage => {
983 let candidate_ordinal =
984 if identity_field.is_some() && matches!(mode, MutationMode::Insert) {
985 identity_insert_ordinal
986 } else {
987 batch_input_ordinal
988 };
989 (
990 None,
991 None,
992 Some(AcceptedPreKeyInsert::new(
993 identity.entity_tag(),
994 authored_patch,
995 candidate_ordinal,
996 )),
997 None,
998 )
999 }
1000 AcceptedStructuralMutationTarget::Expected(key) => {
1001 (Some(*key), None, None, Some(authored_patch))
1002 }
1003 AcceptedStructuralMutationTarget::ExpectedLoaded(loaded) => {
1004 let (key, row) = loaded.into_parts();
1005 (Some(key), Some(row), None, Some(authored_patch))
1006 }
1007 };
1008 if matches!(mode, MutationMode::Replace)
1009 && let Some(key) = expected_key.as_ref()
1010 {
1011 let patch = keyed_patch
1012 .take()
1013 .ok_or_else(InternalError::executor_invariant)?;
1014 keyed_patch = Some(preserve_dynamic_replacement_identity(
1015 key, descriptor, patch,
1016 )?);
1017 }
1018 let patch = pre_key_insert
1019 .as_ref()
1020 .map(AcceptedPreKeyInsert::fields)
1021 .or(keyed_patch.as_ref())
1022 .ok_or_else(InternalError::executor_invariant)?;
1023 let before = match (expected_key.as_ref(), preloaded_before) {
1024 (Some(_), Some(row)) => Some(row),
1025 (Some(key), None) => validated_existing_row(store, key, &row_contract)?,
1026 (None, None) => None,
1027 (None, Some(_)) => return Err(InternalError::executor_invariant()),
1028 };
1029 match mode {
1030 MutationMode::Insert if before.is_some() => {
1031 return Err(mutation_key_exists_error());
1032 }
1033 MutationMode::Update if before.is_none() => {
1034 let key = expected_key
1035 .as_ref()
1036 .ok_or_else(InternalError::executor_invariant)?;
1037 return Err(InternalError::store_not_found(key));
1038 }
1039 MutationMode::Insert | MutationMode::Replace | MutationMode::Update => {}
1040 }
1041
1042 let identity_allocation = if let Some(identity_field) = identity_field.as_ref()
1043 && matches!(mode, MutationMode::Insert)
1044 && before.is_none()
1045 {
1046 let candidate = pre_key_insert.as_ref().ok_or_else(|| {
1047 InternalError::mutation_database_owned_field_explicit(
1048 mutation_context,
1049 identity_field.field_id.get(),
1050 )
1051 })?;
1052 if identity_cursor.is_none() {
1053 let incarnation = identity_incarnation
1054 .ok_or_else(InternalError::identity_state_corruption)?;
1055 identity_cursor = Some(store.with_schema(|schema_store| {
1056 schema_store.identity_statement_cursor(
1057 incarnation,
1058 identity.entity_tag(),
1059 identity_field.field_id,
1060 &identity_field.accepted_kind,
1061 )
1062 })?);
1063 }
1064 let allocation = identity_cursor
1065 .as_mut()
1066 .ok_or_else(InternalError::identity_state_corruption)?
1067 .allocate(identity_field.field_slot, candidate.input_ordinal())?;
1068 identity_insert_ordinal = identity_insert_ordinal
1069 .checked_add(1)
1070 .ok_or_else(InternalError::identity_candidate_count_exhausted)?;
1071 Some(allocation)
1072 } else if let Some(identity_field) = identity_field.as_ref()
1073 && matches!(mode, MutationMode::Replace)
1074 && before.is_none()
1075 {
1076 return Err(InternalError::mutation_database_owned_field_explicit(
1077 mutation_context,
1078 identity_field.field_id.get(),
1079 ));
1080 } else {
1081 None
1082 };
1083
1084 let resolved = match (mode, before.as_ref()) {
1085 (MutationMode::Insert | MutationMode::Replace, None) => {
1086 resolve_insert_structural_patch_with_accepted_contract(
1087 entity_path,
1088 row_decode_contract.clone(),
1089 catalog.fingerprint(),
1090 catalog.accepted_row_constraints(),
1091 patch,
1092 write_context,
1093 mutation_context,
1094 identity_allocation.as_ref(),
1095 )?
1096 }
1097 (MutationMode::Update, Some(before)) => {
1098 resolve_update_structural_patch_with_accepted_contract(
1099 entity_path,
1100 row_decode_contract.clone(),
1101 catalog.fingerprint(),
1102 catalog.accepted_row_constraints(),
1103 before,
1104 patch,
1105 write_context,
1106 mutation_context,
1107 )?
1108 }
1109 (MutationMode::Replace, Some(before)) => {
1110 resolve_existing_replace_structural_patch_with_accepted_contract(
1111 entity_path,
1112 row_decode_contract.clone(),
1113 catalog.fingerprint(),
1114 catalog.accepted_row_constraints(),
1115 before,
1116 patch,
1117 write_context,
1118 mutation_context,
1119 )?
1120 }
1121 (MutationMode::Insert, Some(_)) | (MutationMode::Update, None) => {
1122 return Err(InternalError::executor_invariant());
1123 }
1124 };
1125 let (after, provenance) = resolved.into_parts();
1126 let reader = StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
1127 after.as_raw_row(),
1128 &row_contract,
1129 )?;
1130 let data_key = match expected_key {
1131 Some(key) => {
1132 reader.validate_primary_key(&key)?;
1133 key
1134 }
1135 None => {
1136 data_key_from_row(identity.entity_tag(), &row_contract, after.as_raw_row())?
1137 }
1138 };
1139 if let Some(allocation) = identity_allocation.as_ref() {
1140 validate_identity_materialization(
1141 identity.entity_tag(),
1142 identity_field
1143 .as_ref()
1144 .ok_or_else(InternalError::identity_corruption)?,
1145 pre_key_insert
1146 .as_ref()
1147 .ok_or_else(InternalError::identity_corruption)?,
1148 allocation,
1149 &data_key,
1150 &reader,
1151 )?;
1152 }
1153 if matches!(mode, MutationMode::Insert)
1154 && validated_existing_row(store, &data_key, &row_contract)?.is_some()
1155 {
1156 return Err(insert_key_exists_after_generation(
1157 identity_allocation.is_some(),
1158 ));
1159 }
1160 let raw_key = data_key.to_raw()?;
1161 let canonical_before = before
1162 .as_ref()
1163 .map(|before| {
1164 canonical_row_from_raw_row_with_accepted_decode_contract(
1165 entity_path,
1166 row_decode_contract.clone(),
1167 before,
1168 )
1169 })
1170 .transpose()?;
1171 let logical_changed = canonical_before.as_ref().is_none_or(|before| {
1172 before.as_raw_row().as_bytes() != after.as_raw_row().as_bytes()
1173 });
1174 let physical_changed = before
1175 .as_ref()
1176 .is_none_or(|before| before.as_bytes() != after.as_raw_row().as_bytes());
1177 add_structural_mutation_staged_bytes(
1178 &mut staged_bytes,
1179 [
1180 raw_key.as_bytes().len(),
1181 canonical_before
1182 .as_ref()
1183 .map_or(0, |before| before.as_raw_row().as_bytes().len()),
1184 after.as_raw_row().as_bytes().len(),
1185 ],
1186 )?;
1187 let row_op = physical_changed.then(|| {
1188 CommitRowOp::new(
1189 entity_path,
1190 raw_key.clone(),
1191 canonical_before
1192 .as_ref()
1193 .map(|before| before.as_raw_row().as_bytes().to_vec()),
1194 Some(after.as_raw_row().as_bytes().to_vec()),
1195 catalog.fingerprint(),
1196 )
1197 });
1198 scheduler.schedule_save_after_image(
1199 mode,
1200 &data_key,
1201 after.as_raw_row(),
1202 provenance.as_slice(),
1203 row_op,
1204 batch_input_ordinal,
1205 )?;
1206 let values = if capture_output_values {
1207 let mut values = Vec::with_capacity(descriptor.fields().len());
1208 for field in descriptor.fields() {
1209 values.push(
1210 reader
1211 .required_cached_value(usize::from(field.slot().get()))?
1212 .clone(),
1213 );
1214 }
1215 values
1216 } else {
1217 Vec::new()
1218 };
1219 output.push(AcceptedStructuralMutationRow {
1220 values,
1221 logical_changed,
1222 });
1223 }
1224
1225 let batch = scheduler.finish();
1226 let prepared = precommit_preparation(output)?;
1227 let identity_ranges = identity_cursor
1228 .map(IdentityStatementCursor::into_range_advance)
1229 .transpose()?
1230 .into_iter()
1231 .flatten()
1232 .collect::<Vec<_>>();
1233 if batch.is_empty() && !identity_ranges.is_empty() {
1234 return Err(InternalError::identity_corruption());
1235 }
1236 if batch.is_empty() {
1237 if mutation_progress.is_some() {
1238 return Err(InternalError::executor_invariant());
1239 }
1240 } else if let Some(mutation_progress) = mutation_progress {
1241 commit_structural_row_ops_with_mutation_progress_for_path(
1242 &self.db,
1243 entity_path,
1244 batch,
1245 identity_ranges,
1246 mutation_progress,
1247 "accepted_structural_batch_apply",
1248 )?;
1249 } else {
1250 commit_structural_row_ops_with_window_for_path(
1251 &self.db,
1252 entity_path,
1253 batch,
1254 identity_ranges,
1255 "accepted_structural_batch_apply",
1256 )?;
1257 }
1258 Ok(prepared)
1259 }
1260
1261 fn execute_one_accepted_save_mutation(
1262 &self,
1263 catalog: &AcceptedSchemaCatalogContext,
1264 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
1265 mode: MutationMode,
1266 target: AcceptedStructuralMutationTarget,
1267 patch: AcceptedMutationIntentPatch,
1268 ) -> Result<DynamicMutationResult, InternalError> {
1269 let identity = catalog.identity();
1270 let entity_path = identity.entity_path();
1271 let result = self.execute_accepted_structural_save_batch(
1272 catalog,
1273 descriptor,
1274 vec![AcceptedStructuralMutation::save(mode, target, patch)],
1275 Timestamp::now(),
1276 |rows| prepare_dynamic_mutation_result(catalog, descriptor, rows, false),
1277 )?;
1278 record(MetricsEvent::SaveMutation {
1279 entity_path: entity_path.into(),
1280 kind: match mode {
1281 MutationMode::Insert => SaveMutationKind::Insert,
1282 MutationMode::Replace => SaveMutationKind::Replace,
1283 MutationMode::Update => SaveMutationKind::Update,
1284 },
1285 rows_touched: u64::from(result.affected_rows),
1286 });
1287 Ok(result)
1288 }
1289
1290 pub fn execute_trusted_dynamic_mutation(
1297 &self,
1298 request: &DynamicMutation,
1299 ) -> Result<DynamicMutationResult, InternalError> {
1300 self.execute_trusted_dynamic_mutation_batch_with_result_policy(vec![request.clone()], false)
1301 }
1302
1303 pub fn execute_trusted_dynamic_mutation_batch(
1309 &self,
1310 requests: Vec<DynamicMutation>,
1311 ) -> Result<DynamicMutationResult, InternalError> {
1312 self.execute_trusted_dynamic_mutation_batch_with_result_policy(requests, true)
1313 }
1314
1315 fn execute_trusted_dynamic_mutation_batch_with_result_policy(
1316 &self,
1317 requests: Vec<DynamicMutation>,
1318 enforce_mixed_batch_result_bound: bool,
1319 ) -> Result<DynamicMutationResult, InternalError> {
1320 if requests.is_empty() {
1321 return Err(InternalError::mutation_batch_empty());
1322 }
1323 if requests.len() > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1324 return Err(InternalError::mutation_batch_too_many_items(
1325 requests.len(),
1326 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1327 ));
1328 }
1329 let first = requests
1330 .first()
1331 .ok_or_else(InternalError::mutation_batch_empty)?;
1332 if first.entity().is_empty() {
1333 return Err(InternalError::executor_unsupported());
1334 }
1335 let catalog = self.accepted_schema_catalog_context_for_entity_name(Some(first.entity()))?;
1336 let accepted_identity = catalog.identity();
1337 let descriptor =
1338 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1339 let mut mutations = Vec::with_capacity(requests.len());
1340 let mut save_kinds = Vec::with_capacity(requests.len());
1341
1342 for (batch_position, request) in requests.iter().enumerate() {
1343 let batch_position = u32::try_from(batch_position).map_err(|_| {
1344 InternalError::mutation_batch_too_many_items(
1345 requests.len(),
1346 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1347 )
1348 })?;
1349 if request.entity().is_empty() {
1350 return Err(InternalError::executor_unsupported());
1351 }
1352 let item_catalog =
1353 self.accepted_schema_catalog_context_for_entity_name(Some(request.entity()))?;
1354 if item_catalog.identity() != accepted_identity {
1355 return Err(InternalError::mutation_batch_entity_mismatch(
1356 batch_position,
1357 accepted_identity.entity_tag().value(),
1358 item_catalog.identity().entity_tag().value(),
1359 ));
1360 }
1361 let (mutation, save_kind) = lower_dynamic_mutation_intent(
1362 accepted_identity.entity_tag(),
1363 accepted_identity.entity_path(),
1364 &descriptor,
1365 request,
1366 batch_position,
1367 )?;
1368 mutations.push(mutation);
1369 save_kinds.push(save_kind);
1370 }
1371
1372 let entity_path = accepted_identity.entity_path_handle();
1373 let (result, metrics) = self.execute_accepted_structural_mutation_batch_inner(
1374 &catalog,
1375 &descriptor,
1376 mutations,
1377 Timestamp::now(),
1378 AcceptedStructuralMutationCommitOptions::standard(),
1379 |rows| {
1380 if rows.len() != save_kinds.len() {
1381 return Err(InternalError::executor_invariant());
1382 }
1383 let metrics = rows
1384 .iter()
1385 .zip(save_kinds)
1386 .filter_map(|(row, kind)| kind.map(|kind| (kind, row.logical_changed())))
1387 .collect::<Vec<_>>();
1388 let result = prepare_dynamic_mutation_result(
1389 &catalog,
1390 &descriptor,
1391 rows,
1392 enforce_mixed_batch_result_bound,
1393 )?;
1394 Ok((result, metrics))
1395 },
1396 )?;
1397 for (kind, logical_changed) in metrics {
1398 record(MetricsEvent::SaveMutation {
1399 entity_path: entity_path.clone(),
1400 kind,
1401 rows_touched: u64::from(logical_changed),
1402 });
1403 }
1404 Ok(result)
1405 }
1406
1407 #[doc(hidden)]
1410 pub fn execute_trusted_typed_mutation(
1411 &self,
1412 binding: &DynamicTypedEntityBinding,
1413 request: &DynamicTypedMutation,
1414 ) -> Result<Option<DynamicMutationResult>, InternalError> {
1415 let Some(catalog) = self.current_typed_entity_binding_catalog(binding)? else {
1416 return Ok(None);
1417 };
1418 let identity = catalog.identity();
1419 let descriptor =
1420 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1421 let mode = dynamic_typed_mutation_mode(request);
1422 let (target, patch) = match request {
1423 DynamicTypedMutation::Insert { patch } => (
1424 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
1425 patch,
1426 ),
1427 DynamicTypedMutation::Update { key, patch }
1428 | DynamicTypedMutation::Replace { key, patch } => (
1429 AcceptedStructuralMutationTarget::expected(dynamic_key(
1430 identity.entity_tag(),
1431 key,
1432 )?),
1433 patch,
1434 ),
1435 };
1436 if !patch.is_bound_to(binding) {
1437 return Ok(None);
1438 }
1439 let patch = lower_typed_patch(
1440 &descriptor,
1441 patch,
1442 mode,
1443 mutation_diagnostic_context(identity.entity_tag(), mode, 0),
1444 )?;
1445 self.execute_one_accepted_save_mutation(&catalog, &descriptor, mode, target, patch)
1446 .map(Some)
1447 }
1448
1449 pub fn execute_trusted_dynamic_insert_batch(
1455 &self,
1456 entity: &str,
1457 patches: Vec<DynamicStructuralPatch>,
1458 ) -> Result<DynamicMutationResult, InternalError> {
1459 let mutations = patches
1460 .into_iter()
1461 .map(|patch| DynamicMutation::Insert {
1462 entity: entity.to_string(),
1463 patch,
1464 })
1465 .collect();
1466 self.execute_trusted_dynamic_mutation_batch_with_result_policy(mutations, false)
1467 }
1468}
1469
1470#[cfg(test)]
1471mod typed_adapter_tests {
1472 use super::{
1473 AcceptedFieldKind, DbSession, DynamicTypedBindingError, DynamicTypedFieldBindingRequest,
1474 DynamicTypedFieldType, DynamicTypedMutation, DynamicWriteCell, dynamic_typed_field_type,
1475 typed_adapter_field_kind_matches,
1476 };
1477 use crate::{
1478 db::{
1479 data::DataStore,
1480 index::IndexStore,
1481 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
1482 schema::{
1483 AcceptedSchemaRevision, FieldId, FieldStorageDecode, LeafCodec,
1484 PersistedFieldSnapshot, PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot,
1485 SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
1486 accepted_schema_candidate_with_field_bindings_for_tests,
1487 },
1488 },
1489 traits::{CanisterKind, Path},
1490 types::EntityTag,
1491 value::InputValue,
1492 };
1493 use icydb_schema::{EntitySourceKey, FieldSourceKey, ScalarType};
1494 use std::{cell::RefCell, collections::BTreeMap};
1495
1496 const STORE_PATH: &str = "session::write::typed_adapter_tests::Store";
1497 const ENTITY_SOURCE: &str = "session::write::typed_adapter_tests::Entity";
1498 const OTHER_ENTITY_SOURCE: &str = "session::write::typed_adapter_tests::OtherEntity";
1499 const ID_SOURCE: &str = "session::write::typed_adapter_tests::Entity::id";
1500 const VALUE_SOURCE: &str = "session::write::typed_adapter_tests::Entity::value";
1501 const REPLACEMENT_SOURCE: &str =
1502 "session::write::typed_adapter_tests::Entity::replacement_value";
1503 const OTHER_ID_SOURCE: &str = "session::write::typed_adapter_tests::OtherEntity::id";
1504
1505 struct TestCanister;
1506
1507 impl Path for TestCanister {
1508 const PATH: &'static str = "session::write::typed_adapter_tests::Canister";
1509 }
1510
1511 impl CanisterKind for TestCanister {
1512 const COMMIT_MEMORY_ID: u8 = 41;
1513 const COMMIT_STABLE_KEY: &'static str = "icydb.typed_adapter_tests.commit.v1";
1514 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 42;
1515 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
1516 "icydb.typed_adapter_tests.integrity.progress.v1";
1517 }
1518
1519 thread_local! {
1520 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
1521 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
1522 static SCHEMA_STORE: RefCell<SchemaStore> =
1523 const { RefCell::new(SchemaStore::init_heap()) };
1524 static STORE_REGISTRY: StoreRegistry = {
1525 let mut registry = StoreRegistry::new();
1526 registry.register_store(
1527 STORE_PATH,
1528 &DATA_STORE,
1529 &INDEX_STORE,
1530 &SCHEMA_STORE,
1531 StoreAllocationIdentities::absent(),
1532 StoreRuntimeStorageCapabilities::heap(),
1533 ).expect("typed adapter test store should register");
1534 registry
1535 };
1536 }
1537
1538 fn nat64_field(id: u32, name: &str, slot: u16) -> PersistedFieldSnapshot {
1539 PersistedFieldSnapshot::new_initial(
1540 FieldId::new(id),
1541 name.to_string(),
1542 SchemaFieldSlot::new(slot),
1543 AcceptedFieldKind::Nat64,
1544 Vec::new(),
1545 false,
1546 SchemaInsertDefault::None,
1547 FieldStorageDecode::ByKind,
1548 LeafCodec::Scalar(ScalarCodec::Nat64),
1549 )
1550 }
1551
1552 fn snapshot(
1553 entity_source: &str,
1554 entity_name: &str,
1555 fields: Vec<PersistedFieldSnapshot>,
1556 ) -> PersistedSchemaSnapshot {
1557 let layout = SchemaRowLayout::initial(
1558 fields
1559 .iter()
1560 .map(|field| (field.id(), field.slot()))
1561 .collect(),
1562 );
1563 PersistedSchemaSnapshot::new(
1564 SchemaVersion::initial(),
1565 entity_source.to_string(),
1566 entity_name.to_string(),
1567 FieldId::new(1),
1568 layout,
1569 fields,
1570 )
1571 }
1572
1573 fn field_source(source: &str) -> FieldSourceKey {
1574 FieldSourceKey::try_new(source).expect("typed field source should admit")
1575 }
1576
1577 fn entity_source(source: &str) -> EntitySourceKey {
1578 EntitySourceKey::try_new(source).expect("typed entity source should admit")
1579 }
1580
1581 fn publish(
1582 session: &DbSession<TestCanister>,
1583 expected: AcceptedSchemaRevision,
1584 revision: AcceptedSchemaRevision,
1585 snapshots: BTreeMap<EntityTag, PersistedSchemaSnapshot>,
1586 fields: BTreeMap<(EntityTag, FieldSourceKey), FieldId>,
1587 ) {
1588 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
1589 STORE_PATH, revision, snapshots, fields,
1590 );
1591 let store = session
1592 .db
1593 .store_handle(STORE_PATH)
1594 .expect("typed adapter test store should resolve");
1595 crate::db::commit::publish_accepted_schema_candidate(
1596 STORE_PATH, store, expected, &candidate,
1597 )
1598 .expect("typed binding candidate should publish");
1599 }
1600
1601 fn request(source: &str) -> DynamicTypedFieldBindingRequest {
1602 DynamicTypedFieldBindingRequest::new(
1603 source.to_string(),
1604 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
1605 false,
1606 )
1607 }
1608
1609 fn assert_query_diagnostic(
1610 error: crate::db::QueryError,
1611 code: icydb_diagnostic_code::DiagnosticCode,
1612 origin: icydb_diagnostic_code::ErrorOrigin,
1613 detail: icydb_diagnostic_code::DiagnosticDetail,
1614 ) {
1615 let diagnostic = error.diagnostic();
1616 assert_eq!(diagnostic.code(), code);
1617 assert_eq!(diagnostic.origin(), origin);
1618 assert_eq!(diagnostic.detail(), Some(&detail));
1619 }
1620
1621 #[test]
1622 fn typed_adapter_kind_matching_is_exact_but_accepts_relation_key_wrappers() {
1623 let relation = AcceptedFieldKind::Relation {
1624 target_path: "test::Target".to_string(),
1625 target_entity_name: "Target".to_string(),
1626 target_entity_tag: EntityTag::new(7),
1627 target_store_path: "test::Store".to_string(),
1628 key_kind: Box::new(AcceptedFieldKind::Nat64),
1629 };
1630
1631 assert!(typed_adapter_field_kind_matches(
1632 &relation,
1633 &AcceptedFieldKind::Nat64,
1634 ));
1635 assert!(typed_adapter_field_kind_matches(
1636 &AcceptedFieldKind::List(Box::new(relation)),
1637 &AcceptedFieldKind::List(Box::new(AcceptedFieldKind::Nat64)),
1638 ));
1639 assert!(!typed_adapter_field_kind_matches(
1640 &AcceptedFieldKind::Nat64,
1641 &AcceptedFieldKind::Nat32,
1642 ));
1643 }
1644
1645 #[test]
1646 fn typed_adapter_field_contract_rejects_invalid_named_source_identity() {
1647 assert!(matches!(
1648 dynamic_typed_field_type(DynamicTypedFieldType::Named(String::new())),
1649 Err(DynamicTypedBindingError::FieldUnavailable),
1650 ));
1651 assert!(matches!(
1652 dynamic_typed_field_type(DynamicTypedFieldType::Scalar(ScalarType::Nat16)),
1653 Ok(icydb_schema::FieldType::Scalar(ScalarType::Nat16)),
1654 ));
1655 }
1656
1657 #[expect(clippy::too_many_lines)]
1660 #[test]
1661 fn typed_binding_uses_accepted_ids_and_slots_across_renames_and_name_reuse() {
1662 let entity_tag = EntityTag::new(91);
1663 let other_entity_tag = EntityTag::new(92);
1664 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
1665 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
1666 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
1667
1668 let session = DbSession::<TestCanister>::new(
1669 &STORE_REGISTRY,
1670 &crate::db::RequestExecutionRoot::__new_runtime_root(),
1671 );
1672 session
1673 .db
1674 .ensure_recovered_state()
1675 .expect("typed adapter test database should initialize");
1676 publish(
1677 &session,
1678 AcceptedSchemaRevision::NONE,
1679 AcceptedSchemaRevision::INITIAL,
1680 BTreeMap::from([(
1681 entity_tag,
1682 snapshot(
1683 ENTITY_SOURCE,
1684 "Entity",
1685 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
1686 ),
1687 )]),
1688 BTreeMap::from([
1689 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1690 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
1691 ]),
1692 );
1693
1694 let initial_catalog = session
1695 .find_accepted_schema_catalog_context_for_entity_source_key(ENTITY_SOURCE)
1696 .expect("initial source catalog lookup should inspect")
1697 .expect("initial source catalog should exist");
1698 assert_eq!(initial_catalog.identity().entity_tag(), entity_tag);
1699 let initial = session
1700 .issue_typed_entity_binding(
1701 entity_source(ENTITY_SOURCE).as_str(),
1702 &[request(ID_SOURCE), request(VALUE_SOURCE)],
1703 )
1704 .expect("initial typed binding should issue");
1705 assert_eq!(initial.field_slot(ID_SOURCE), Some(0));
1706 assert_eq!(initial.field_slot(VALUE_SOURCE), Some(1));
1707 assert_eq!(initial.output_field_slot("value"), Some(1));
1708 let initial_patch = initial
1709 .bind_write_fields(vec![(
1710 VALUE_SOURCE.to_string(),
1711 DynamicWriteCell::Value(InputValue::Nat64(7)),
1712 )])
1713 .expect("source-bound patch should lower");
1714 assert_eq!(
1715 initial_patch.fields(),
1716 &[(2, 1, DynamicWriteCell::Value(InputValue::Nat64(7)))]
1717 );
1718
1719 publish(
1720 &session,
1721 AcceptedSchemaRevision::INITIAL,
1722 AcceptedSchemaRevision::new(2),
1723 BTreeMap::from([
1724 (
1725 entity_tag,
1726 snapshot(
1727 ENTITY_SOURCE,
1728 "RenamedEntity",
1729 vec![
1730 nat64_field(1, "id", 0),
1731 nat64_field(2, "renamed_value", 1),
1732 nat64_field(3, "value", 2),
1733 ],
1734 ),
1735 ),
1736 (
1737 other_entity_tag,
1738 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
1739 ),
1740 ]),
1741 BTreeMap::from([
1742 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1743 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
1744 (
1745 (entity_tag, field_source(REPLACEMENT_SOURCE)),
1746 FieldId::new(3),
1747 ),
1748 (
1749 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
1750 FieldId::new(1),
1751 ),
1752 ]),
1753 );
1754
1755 let stale_authority = session
1756 .ensure_accepted_schema_authority_is_current_for_store_path(
1757 STORE_PATH,
1758 initial_catalog.value_catalog_handle().authority(),
1759 )
1760 .expect_err("the initial accepted authority must be stale after revision two");
1761 assert_eq!(
1762 stale_authority.diagnostic_facts(),
1763 vec![
1764 (
1765 icydb_diagnostic_code::DiagnosticFactTag::ExpectedRevision,
1766 AcceptedSchemaRevision::INITIAL.get(),
1767 ),
1768 (
1769 icydb_diagnostic_code::DiagnosticFactTag::CurrentRevision,
1770 AcceptedSchemaRevision::new(2).get(),
1771 ),
1772 ],
1773 );
1774
1775 assert!(
1776 !session
1777 .typed_entity_binding_is_current(&initial)
1778 .expect("renamed binding currentness should inspect")
1779 );
1780 let renamed = session
1781 .issue_typed_entity_binding(ENTITY_SOURCE, &[request(ID_SOURCE), request(VALUE_SOURCE)])
1782 .expect("renamed source-bound adapter should rebind");
1783 assert_eq!(renamed.entity(), "RenamedEntity");
1784 assert_eq!(renamed.field_slot(VALUE_SOURCE), Some(1));
1785 assert_eq!(renamed.output_field_slot("renamed_value"), Some(1));
1786 assert_eq!(renamed.output_field_slot("value"), None);
1787
1788 publish(
1789 &session,
1790 AcceptedSchemaRevision::new(2),
1791 AcceptedSchemaRevision::new(3),
1792 BTreeMap::from([
1793 (
1794 entity_tag,
1795 snapshot(
1796 ENTITY_SOURCE,
1797 "RenamedEntity",
1798 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
1799 ),
1800 ),
1801 (
1802 other_entity_tag,
1803 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
1804 ),
1805 ]),
1806 BTreeMap::from([
1807 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1808 (
1809 (entity_tag, field_source(REPLACEMENT_SOURCE)),
1810 FieldId::new(2),
1811 ),
1812 (
1813 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
1814 FieldId::new(1),
1815 ),
1816 ]),
1817 );
1818
1819 assert!(matches!(
1820 session.issue_typed_entity_binding(
1821 ENTITY_SOURCE,
1822 &[request(ID_SOURCE), request(VALUE_SOURCE)],
1823 ),
1824 Err(DynamicTypedBindingError::FieldUnavailable),
1825 ));
1826 assert!(
1827 !session
1828 .typed_entity_binding_is_current(&renamed)
1829 .expect("removed source binding should become stale")
1830 );
1831
1832 let replacement = session
1833 .issue_typed_entity_binding(
1834 ENTITY_SOURCE,
1835 &[request(ID_SOURCE), request(REPLACEMENT_SOURCE)],
1836 )
1837 .expect("explicit replacement source should bind");
1838 assert!(
1839 session
1840 .execute_trusted_typed_mutation(
1841 &replacement,
1842 &DynamicTypedMutation::Insert {
1843 patch: initial_patch
1844 },
1845 )
1846 .expect("cross-binding patch should fail closed")
1847 .is_none()
1848 );
1849 let patch = replacement
1850 .bind_write_fields(vec![
1851 (
1852 ID_SOURCE.to_string(),
1853 DynamicWriteCell::Value(InputValue::Nat64(1)),
1854 ),
1855 (
1856 REPLACEMENT_SOURCE.to_string(),
1857 DynamicWriteCell::Value(InputValue::Nat64(9)),
1858 ),
1859 ])
1860 .expect("replacement source write should bind by accepted IDs and slots");
1861 let result = session
1862 .execute_trusted_typed_mutation(&replacement, &DynamicTypedMutation::Insert { patch })
1863 .expect("typed insert should use the accepted mutation pipeline")
1864 .expect("replacement binding should remain current");
1865 assert_eq!(result.entity, "RenamedEntity");
1866 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
1867 assert_eq!(
1868 result.rows,
1869 vec![vec![
1870 crate::value::OutputValue::Nat64(1),
1871 crate::value::OutputValue::Nat64(9)
1872 ]]
1873 );
1874 assert_eq!(result.affected_rows, 1);
1875
1876 let second_patch = replacement
1877 .bind_write_fields(vec![
1878 (
1879 ID_SOURCE.to_string(),
1880 DynamicWriteCell::Value(InputValue::Nat64(2)),
1881 ),
1882 (
1883 REPLACEMENT_SOURCE.to_string(),
1884 DynamicWriteCell::Value(InputValue::Nat64(10)),
1885 ),
1886 ])
1887 .expect("second source-bound patch should lower");
1888 session
1889 .execute_trusted_typed_mutation(
1890 &replacement,
1891 &DynamicTypedMutation::Insert {
1892 patch: second_patch,
1893 },
1894 )
1895 .expect("second typed insert should use the accepted mutation pipeline")
1896 .expect("replacement binding should remain current");
1897
1898 {
1899 let query = crate::db::DynamicQuery::new("RenamedEntity")
1900 .select(["id", "value"])
1901 .order_by(crate::db::asc("id"))
1902 .limit(1);
1903 let result = session
1904 .execute_trusted_live_page(&query, None)
1905 .expect("SQL-free dynamic execution should use accepted authority");
1906 assert_eq!(result.entity, "RenamedEntity");
1907 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
1908 assert_eq!(
1909 result.rows,
1910 vec![vec![
1911 crate::value::OutputValue::Nat64(1),
1912 crate::value::OutputValue::Nat64(9)
1913 ]]
1914 );
1915 assert_eq!(result.row_count, 1);
1916 assert_query_diagnostic(
1917 session
1918 .execute_trusted_live_page(&query.cursor("00"), None)
1919 .expect_err("scalar execution must reject grouped cursor state"),
1920 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
1921 icydb_diagnostic_code::ErrorOrigin::Query,
1922 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
1923 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
1924 },
1925 );
1926 assert_query_diagnostic(
1927 session
1928 .execute_public_dynamic_grouped_query(
1929 &crate::db::DynamicQuery::new("RenamedEntity").grouped_limits(1, 1024),
1930 )
1931 .expect_err("grouped execution must reject scalar query state"),
1932 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
1933 icydb_diagnostic_code::ErrorOrigin::Query,
1934 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
1935 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
1936 },
1937 );
1938
1939 let grouped_query = crate::db::DynamicQuery::new("RenamedEntity")
1940 .filter(crate::db::FieldRef::new("id").eq(1_u64))
1941 .group_by("value")
1942 .aggregate(crate::db::count())
1943 .grouped_limits(1, 16 * 1024)
1944 .limit(1);
1945 let grouped = session
1946 .execute_public_dynamic_grouped_query(&grouped_query)
1947 .expect("SQL-free grouped execution should use accepted authority");
1948 let typed_grouped = session
1949 .execute_public_dynamic_grouped_query_for_typed_binding(
1950 &replacement,
1951 &grouped_query,
1952 )
1953 .expect("typed grouped execution should inspect accepted authority")
1954 .expect("replacement binding should remain current");
1955 assert_eq!(typed_grouped, grouped);
1956 assert!(
1957 session
1958 .execute_public_dynamic_grouped_query_for_typed_binding(
1959 &renamed,
1960 &grouped_query,
1961 )
1962 .expect("stale grouped binding should inspect accepted authority")
1963 .is_none(),
1964 "stale typed grouped bindings must fail closed before execution"
1965 );
1966 assert_eq!(grouped.entity, "RenamedEntity");
1967 assert_eq!(grouped.row_count, 1);
1968 assert_eq!(grouped.rows.len(), 1);
1969 assert_eq!(
1970 grouped.rows[0].group_key(),
1971 &[crate::value::OutputValue::Nat64(9)]
1972 );
1973 assert_eq!(
1974 grouped.rows[0].aggregate_values(),
1975 &[crate::value::OutputValue::Nat64(1)]
1976 );
1977 assert_eq!(grouped.next_cursor, None);
1978
1979 let grouped_state_error = session
1980 .execute_trusted_dynamic_grouped_query(&grouped_query.clone().grouped_limits(1, 1))
1981 .expect_err("grouped retained state must respect its explicit byte ceiling");
1982 assert!(matches!(
1983 grouped_state_error.diagnostic().detail(),
1984 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
1985 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
1986 })
1987 ));
1988 assert_eq!(
1989 grouped_state_error.diagnostic_facts()[0],
1990 (
1991 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
1992 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::GroupDistinctStateBytes.raw(),
1993 ),
1994 );
1995
1996 assert_query_diagnostic(
1997 session
1998 .execute_public_dynamic_grouped_query(&grouped_query.clone().select(["value"]))
1999 .expect_err("grouped output must reject scalar selection"),
2000 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
2001 icydb_diagnostic_code::ErrorOrigin::Query,
2002 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
2003 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
2004 },
2005 );
2006 assert_query_diagnostic(
2007 session
2008 .execute_public_dynamic_grouped_query(
2009 &crate::db::DynamicQuery::new("RenamedEntity")
2010 .group_by("value")
2011 .aggregate(crate::db::count()),
2012 )
2013 .expect_err("public grouped execution must require explicit limits"),
2014 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
2015 icydb_diagnostic_code::ErrorOrigin::Query,
2016 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
2017 reason:
2018 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
2019 },
2020 );
2021 assert_query_diagnostic(
2022 session
2023 .execute_trusted_dynamic_grouped_query(
2024 &crate::db::DynamicQuery::new("RenamedEntity")
2025 .group_by("value")
2026 .aggregate(crate::db::count())
2027 .grouped_limits(0, 1024),
2028 )
2029 .expect_err("trusted grouped execution must reject zero limits"),
2030 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
2031 icydb_diagnostic_code::ErrorOrigin::Query,
2032 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
2033 reason:
2034 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
2035 },
2036 );
2037 assert_query_diagnostic(
2038 session
2039 .execute_public_dynamic_grouped_query(&grouped_query.grouped_limits(101, 1024))
2040 .expect_err("public grouped execution must enforce its group budget"),
2041 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
2042 icydb_diagnostic_code::ErrorOrigin::Query,
2043 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
2044 reason:
2045 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryExceedsBudget,
2046 },
2047 );
2048
2049 let paged_query = crate::db::DynamicQuery::new("RenamedEntity")
2050 .group_by("value")
2051 .aggregate(crate::db::count())
2052 .grouped_limits(2, 16 * 1024)
2053 .limit(1);
2054 assert_query_diagnostic(
2055 session
2056 .execute_public_dynamic_grouped_query(&paged_query)
2057 .expect_err("public grouped execution must reject an unbounded full scan"),
2058 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
2059 icydb_diagnostic_code::ErrorOrigin::Query,
2060 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
2061 reason:
2062 icydb_diagnostic_code::QueryReadAdmissionCode::UnboundedFullScanRejected,
2063 },
2064 );
2065 let first_page = session
2066 .execute_trusted_dynamic_grouped_query(&paged_query)
2067 .expect("SQL-free grouped first page should execute");
2068 assert_eq!(first_page.row_count, 1);
2069 assert_eq!(
2070 first_page.rows[0].group_key(),
2071 &[crate::value::OutputValue::Nat64(9)]
2072 );
2073 let cursor = first_page
2074 .next_cursor
2075 .expect("first grouped page should return a continuation cursor");
2076 assert_query_diagnostic(
2077 session
2078 .execute_trusted_dynamic_grouped_query(
2079 &paged_query.clone().cursor(format!("{cursor}0")),
2080 )
2081 .expect_err("tampered grouped cursor must fail closed"),
2082 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
2083 icydb_diagnostic_code::ErrorOrigin::Cursor,
2084 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
2085 kind: icydb_diagnostic_code::QueryErrorKind::InvalidContinuationCursor,
2086 },
2087 );
2088 let second_page = session
2089 .execute_trusted_dynamic_grouped_query(&paged_query.cursor(cursor))
2090 .expect("SQL-free grouped continuation should execute");
2091 assert_eq!(second_page.row_count, 1);
2092 assert_eq!(
2093 second_page.rows[0].group_key(),
2094 &[crate::value::OutputValue::Nat64(10)]
2095 );
2096 assert_eq!(second_page.next_cursor, None);
2097 }
2098 }
2099}
2100
2101#[cfg(test)]
2102mod mixed_relation_batch_tests {
2103 use super::{DbSession, DynamicMutation, DynamicStructuralPatch, DynamicWriteCell};
2104 use crate::{
2105 db::{
2106 DynamicQuery, asc,
2107 data::DataStore,
2108 desc,
2109 index::IndexStore,
2110 query::expr::FilterExpr,
2111 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
2112 schema::{
2113 AcceptedConstraintCatalog, AcceptedFieldKind, AcceptedSchemaRevision, FieldId,
2114 FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
2115 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
2116 PersistedRelationEdgeSnapshot, PersistedSchemaSnapshot, RelationId, ScalarCodec,
2117 SchemaFieldSlot, SchemaIndexId, SchemaInsertDefault, SchemaRowLayout, SchemaStore,
2118 SchemaVersion, accepted_schema_candidate_with_field_bindings_for_tests,
2119 },
2120 },
2121 error::ErrorClass,
2122 traits::{CanisterKind, Path},
2123 types::EntityTag,
2124 value::{InputValue, OutputValue},
2125 };
2126 use icydb_schema::FieldSourceKey;
2127 use std::{cell::RefCell, collections::BTreeMap};
2128
2129 const STORE_PATH: &str = "session::write::mixed_relation_batch_tests::Store";
2130 const ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node";
2131 const ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::id";
2132 const PARENT_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::parent_id";
2133 const CODE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::code";
2134 const ENTITY_NAME: &str = "MixedRelationNode";
2135 const ENTITY_TAG: EntityTag = EntityTag::new(94);
2136 const OTHER_ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other";
2137 const OTHER_ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::id";
2138 const OTHER_VALUE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::value";
2139 const OTHER_ENTITY_NAME: &str = "MixedRelationOther";
2140 const OTHER_ENTITY_TAG: EntityTag = EntityTag::new(95);
2141
2142 struct TestCanister;
2143
2144 impl Path for TestCanister {
2145 const PATH: &'static str = "session::write::mixed_relation_batch_tests::Canister";
2146 }
2147
2148 impl CanisterKind for TestCanister {
2149 const COMMIT_MEMORY_ID: u8 = 47;
2150 const COMMIT_STABLE_KEY: &'static str = "icydb.mixed_relation_batch_tests.commit.v1";
2151 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 48;
2152 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
2153 "icydb.mixed_relation_batch_tests.integrity.progress.v1";
2154 }
2155
2156 thread_local! {
2157 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
2158 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
2159 static SCHEMA_STORE: RefCell<SchemaStore> =
2160 const { RefCell::new(SchemaStore::init_heap()) };
2161 static STORE_REGISTRY: StoreRegistry = {
2162 let mut registry = StoreRegistry::new();
2163 registry.register_store(
2164 STORE_PATH,
2165 &DATA_STORE,
2166 &INDEX_STORE,
2167 &SCHEMA_STORE,
2168 StoreAllocationIdentities::absent(),
2169 StoreRuntimeStorageCapabilities::heap(),
2170 ).expect("mixed relation test store should register");
2171 registry
2172 };
2173 }
2174
2175 fn source_key(source: &str) -> FieldSourceKey {
2176 FieldSourceKey::try_new(source).expect("mixed relation field source should admit")
2177 }
2178
2179 fn relation_snapshot() -> PersistedSchemaSnapshot {
2180 let fields = vec![
2181 PersistedFieldSnapshot::new_initial(
2182 FieldId::new(1),
2183 "id".to_string(),
2184 SchemaFieldSlot::new(0),
2185 AcceptedFieldKind::Nat64,
2186 Vec::new(),
2187 false,
2188 SchemaInsertDefault::None,
2189 FieldStorageDecode::ByKind,
2190 LeafCodec::Scalar(ScalarCodec::Nat64),
2191 ),
2192 PersistedFieldSnapshot::new_initial(
2193 FieldId::new(2),
2194 "parent_id".to_string(),
2195 SchemaFieldSlot::new(1),
2196 AcceptedFieldKind::Nat64,
2197 Vec::new(),
2198 true,
2199 SchemaInsertDefault::None,
2200 FieldStorageDecode::ByKind,
2201 LeafCodec::Scalar(ScalarCodec::Nat64),
2202 ),
2203 PersistedFieldSnapshot::new_initial(
2204 FieldId::new(3),
2205 "code".to_string(),
2206 SchemaFieldSlot::new(2),
2207 AcceptedFieldKind::Nat64,
2208 Vec::new(),
2209 false,
2210 SchemaInsertDefault::None,
2211 FieldStorageDecode::ByKind,
2212 LeafCodec::Scalar(ScalarCodec::Nat64),
2213 ),
2214 ];
2215 let relation = PersistedRelationEdgeSnapshot::new(
2216 RelationId::new(1).expect("mixed relation identity should be non-zero"),
2217 "parent".to_string(),
2218 ENTITY_SOURCE.to_string(),
2219 vec![FieldId::new(2)],
2220 );
2221 let snapshot = PersistedSchemaSnapshot::new_with_indexes(
2222 SchemaVersion::initial(),
2223 ENTITY_SOURCE.to_string(),
2224 ENTITY_NAME.to_string(),
2225 FieldId::new(1),
2226 SchemaRowLayout::initial(
2227 fields
2228 .iter()
2229 .map(|field| (field.id(), field.slot()))
2230 .collect(),
2231 ),
2232 fields,
2233 vec![PersistedIndexSnapshot::new(
2234 SchemaIndexId::new(1).expect("mixed unique index identity should be non-zero"),
2235 1,
2236 "by_code".to_string(),
2237 STORE_PATH.to_string(),
2238 true,
2239 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
2240 FieldId::new(3),
2241 SchemaFieldSlot::new(2),
2242 vec!["code".to_string()],
2243 AcceptedFieldKind::Nat64,
2244 false,
2245 )]),
2246 None,
2247 )],
2248 )
2249 .with_relations(vec![relation]);
2250 let constraints = AcceptedConstraintCatalog::initial(
2251 snapshot.fields(),
2252 snapshot.indexes(),
2253 snapshot.relations(),
2254 )
2255 .expect("mixed relation constraints should close");
2256 snapshot.with_constraint_catalog(constraints)
2257 }
2258
2259 fn other_snapshot() -> PersistedSchemaSnapshot {
2260 let fields = vec![
2261 PersistedFieldSnapshot::new_initial(
2262 FieldId::new(1),
2263 "id".to_string(),
2264 SchemaFieldSlot::new(0),
2265 AcceptedFieldKind::Nat64,
2266 Vec::new(),
2267 false,
2268 SchemaInsertDefault::None,
2269 FieldStorageDecode::ByKind,
2270 LeafCodec::Scalar(ScalarCodec::Nat64),
2271 ),
2272 PersistedFieldSnapshot::new_initial(
2273 FieldId::new(2),
2274 "value".to_string(),
2275 SchemaFieldSlot::new(1),
2276 AcceptedFieldKind::Nat64,
2277 Vec::new(),
2278 false,
2279 SchemaInsertDefault::None,
2280 FieldStorageDecode::ByKind,
2281 LeafCodec::Scalar(ScalarCodec::Nat64),
2282 ),
2283 ];
2284 PersistedSchemaSnapshot::new(
2285 SchemaVersion::initial(),
2286 OTHER_ENTITY_SOURCE.to_string(),
2287 OTHER_ENTITY_NAME.to_string(),
2288 FieldId::new(1),
2289 SchemaRowLayout::initial(
2290 fields
2291 .iter()
2292 .map(|field| (field.id(), field.slot()))
2293 .collect(),
2294 ),
2295 fields,
2296 )
2297 }
2298
2299 fn initialize() -> DbSession<TestCanister> {
2300 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2301 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2302 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2303 let session = DbSession::<TestCanister>::new(
2304 &STORE_REGISTRY,
2305 &crate::db::RequestExecutionRoot::__new_runtime_root(),
2306 );
2307 session
2308 .db
2309 .ensure_recovered_state()
2310 .expect("mixed relation database should initialize");
2311 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
2312 STORE_PATH,
2313 AcceptedSchemaRevision::INITIAL,
2314 BTreeMap::from([
2315 (ENTITY_TAG, relation_snapshot()),
2316 (OTHER_ENTITY_TAG, other_snapshot()),
2317 ]),
2318 BTreeMap::from([
2319 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
2320 ((ENTITY_TAG, source_key(PARENT_SOURCE)), FieldId::new(2)),
2321 ((ENTITY_TAG, source_key(CODE_SOURCE)), FieldId::new(3)),
2322 (
2323 (OTHER_ENTITY_TAG, source_key(OTHER_ID_SOURCE)),
2324 FieldId::new(1),
2325 ),
2326 (
2327 (OTHER_ENTITY_TAG, source_key(OTHER_VALUE_SOURCE)),
2328 FieldId::new(2),
2329 ),
2330 ]),
2331 );
2332 let store = session
2333 .db
2334 .store_handle(STORE_PATH)
2335 .expect("mixed relation store should resolve");
2336 crate::db::commit::publish_accepted_schema_candidate(
2337 STORE_PATH,
2338 store,
2339 AcceptedSchemaRevision::NONE,
2340 &candidate,
2341 )
2342 .expect("mixed relation candidate should publish");
2343 session
2344 }
2345
2346 fn patch(id: Option<u64>, parent: Option<u64>, code: Option<u64>) -> DynamicStructuralPatch {
2347 let mut fields = Vec::new();
2348 if let Some(id) = id {
2349 fields.push((
2350 "id".to_string(),
2351 DynamicWriteCell::Value(InputValue::Nat64(id)),
2352 ));
2353 }
2354 fields.push((
2355 "parent_id".to_string(),
2356 parent.map_or(DynamicWriteCell::Null, |parent| {
2357 DynamicWriteCell::Value(InputValue::Nat64(parent))
2358 }),
2359 ));
2360 if let Some(code) = code {
2361 fields.push((
2362 "code".to_string(),
2363 DynamicWriteCell::Value(InputValue::Nat64(code)),
2364 ));
2365 }
2366 DynamicStructuralPatch::new(fields)
2367 }
2368
2369 fn insert(id: u64, parent: Option<u64>) -> DynamicMutation {
2370 insert_with_code(id, parent, id)
2371 }
2372
2373 fn insert_with_code(id: u64, parent: Option<u64>, code: u64) -> DynamicMutation {
2374 DynamicMutation::Insert {
2375 entity: ENTITY_NAME.to_string(),
2376 patch: patch(Some(id), parent, Some(code)),
2377 }
2378 }
2379
2380 fn update_parent(id: u64, parent: Option<u64>) -> DynamicMutation {
2381 DynamicMutation::Update {
2382 entity: ENTITY_NAME.to_string(),
2383 key: InputValue::Nat64(id),
2384 patch: patch(None, parent, None),
2385 }
2386 }
2387
2388 fn update_code(id: u64, code: u64) -> DynamicMutation {
2389 DynamicMutation::Update {
2390 entity: ENTITY_NAME.to_string(),
2391 key: InputValue::Nat64(id),
2392 patch: DynamicStructuralPatch::new(vec![(
2393 "code".to_string(),
2394 DynamicWriteCell::Value(InputValue::Nat64(code)),
2395 )]),
2396 }
2397 }
2398
2399 fn delete(id: u64) -> DynamicMutation {
2400 DynamicMutation::Delete {
2401 entity: ENTITY_NAME.to_string(),
2402 key: InputValue::Nat64(id),
2403 }
2404 }
2405
2406 fn expected_row(id: u64, parent: Option<u64>) -> Vec<OutputValue> {
2407 expected_row_with_code(id, parent, id)
2408 }
2409
2410 fn expected_row_with_code(id: u64, parent: Option<u64>, code: u64) -> Vec<OutputValue> {
2411 vec![
2412 OutputValue::Nat64(id),
2413 parent.map_or(OutputValue::Null, OutputValue::Nat64),
2414 OutputValue::Nat64(code),
2415 ]
2416 }
2417
2418 fn other_patch(id: Option<u64>, value: u64) -> DynamicStructuralPatch {
2419 let mut fields = Vec::new();
2420 if let Some(id) = id {
2421 fields.push((
2422 "id".to_string(),
2423 DynamicWriteCell::Value(InputValue::Nat64(id)),
2424 ));
2425 }
2426 fields.push((
2427 "value".to_string(),
2428 DynamicWriteCell::Value(InputValue::Nat64(value)),
2429 ));
2430 DynamicStructuralPatch::new(fields)
2431 }
2432
2433 fn assert_relation_violation(error: &crate::error::InternalError) {
2434 assert!(error.diagnostic_facts().contains(&(
2435 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
2436 icydb_diagnostic_code::DiagnosticConstraintKind::Relation.raw(),
2437 )));
2438 }
2439
2440 #[test]
2441 fn live_pages_resume_mixed_projection_from_authenticated_hidden_order_values() {
2442 let session = initialize();
2443 session
2444 .execute_trusted_dynamic_mutation_batch(vec![
2445 insert_with_code(1, None, 10),
2446 insert_with_code(2, Some(1), 20),
2447 insert_with_code(3, None, 30),
2448 ])
2449 .expect("live-page rows should insert");
2450 let query = DynamicQuery::new(ENTITY_NAME)
2451 .select(["id"])
2452 .order_by(desc("code"));
2453
2454 let first = session
2455 .execute_public_live_page(&query, None)
2456 .expect("initial live page should execute");
2457 assert_eq!(
2458 first.rows,
2459 vec![vec![OutputValue::Nat64(3)], vec![OutputValue::Nat64(2)]]
2460 );
2461 let cursor = first
2462 .continuation
2463 .as_deref()
2464 .expect("unreturned matching row should produce continuation");
2465 let second = session
2466 .execute_public_live_page(&query, Some(cursor))
2467 .expect("authenticated live continuation should resume");
2468 assert_eq!(second.rows, vec![vec![OutputValue::Nat64(1)]]);
2469 assert_eq!(second.continuation, None);
2470
2471 let total_limit = session
2472 .execute_public_live_page(&query.clone().limit(2), None)
2473 .expect("total live-page limit should execute");
2474 assert_eq!(
2475 total_limit.rows,
2476 vec![vec![OutputValue::Nat64(3)], vec![OutputValue::Nat64(2)]],
2477 );
2478 assert_eq!(
2479 total_limit.continuation, None,
2480 "query LIMIT is a total traversal window rather than a page size",
2481 );
2482
2483 let three_row_window = query.clone().limit(3);
2484 let limited_first = session
2485 .execute_public_live_page(&three_row_window, None)
2486 .expect("first total-window page should execute");
2487 let limited_cursor = limited_first
2488 .continuation
2489 .as_deref()
2490 .expect("a partially consumed total window should continue");
2491 let limited_second = session
2492 .execute_public_live_page(&three_row_window, Some(limited_cursor))
2493 .expect("remaining total window should preserve the plan signature");
2494 assert_eq!(limited_second.rows, vec![vec![OutputValue::Nat64(1)]]);
2495 assert_eq!(limited_second.continuation, None);
2496
2497 let mixed_order = DynamicQuery::new(ENTITY_NAME)
2498 .select(["id"])
2499 .order_by(desc("parent_id"))
2500 .order_by(asc("id"));
2501 let mixed_first = session
2502 .execute_trusted_live_page(&mixed_order, None)
2503 .expect("mixed-direction nullable order should execute");
2504 assert_eq!(
2505 mixed_first.rows,
2506 vec![vec![OutputValue::Nat64(2)], vec![OutputValue::Nat64(1)]],
2507 );
2508 let mixed_cursor = mixed_first
2509 .continuation
2510 .as_deref()
2511 .expect("duplicate null order values should retain continuation");
2512 let mixed_second = session
2513 .execute_trusted_live_page(&mixed_order, Some(mixed_cursor))
2514 .expect("mixed-direction nullable order should resume");
2515 assert_eq!(mixed_second.rows, vec![vec![OutputValue::Nat64(3)]]);
2516 assert_eq!(mixed_second.continuation, None);
2517
2518 let mismatched_window = session
2519 .execute_public_live_page(&query.clone().limit(3), Some(cursor))
2520 .expect_err("a changed total limit must invalidate the continuation");
2521 assert_eq!(
2522 mismatched_window.diagnostic_code(),
2523 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
2524 );
2525
2526 let mut tampered = cursor.as_bytes().to_vec();
2527 let last = tampered.len().saturating_sub(1);
2528 tampered[last] = if tampered[last] == b'0' { b'1' } else { b'0' };
2529 let tampered = String::from_utf8(tampered).expect("hex cursor should remain UTF-8");
2530 let error = session
2531 .execute_public_live_page(&query, Some(tampered.as_str()))
2532 .expect_err("tampered cursor must fail closed");
2533 assert_eq!(
2534 error.diagnostic_code(),
2535 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
2536 );
2537 }
2538
2539 #[test]
2540 fn live_pages_resume_across_changed_output_work_envelopes() {
2541 let session = initialize();
2542 session
2543 .execute_trusted_dynamic_mutation_batch(vec![
2544 insert(1, None),
2545 insert(2, None),
2546 insert(3, None),
2547 ])
2548 .expect("output-envelope rows should insert");
2549 let query = DynamicQuery::new(ENTITY_NAME)
2550 .select(["id"])
2551 .order_by(desc("code"));
2552 let first = session
2553 .execute_trusted_live_page_with_result_bytes_limit_for_tests(&query, None, 32)
2554 .expect("small output envelope should publish the first bounded page");
2555 assert_eq!(first.rows, vec![vec![OutputValue::Nat64(3)]]);
2556 let continuation = first
2557 .continuation
2558 .expect("small output envelope should leave authenticated progress");
2559
2560 let second = session
2561 .execute_trusted_live_page_with_result_bytes_limit_for_tests(
2562 &query,
2563 Some(continuation.as_str()),
2564 64,
2565 )
2566 .unwrap_or_else(|error| {
2567 panic!(
2568 "larger output envelope should resume the same query: {error:?}, facts={:?}",
2569 error.diagnostic_facts(),
2570 )
2571 });
2572 assert_eq!(
2573 second.rows,
2574 vec![vec![OutputValue::Nat64(2)], vec![OutputValue::Nat64(1)]]
2575 );
2576 let second_continuation = second
2577 .continuation
2578 .as_deref()
2579 .expect("an exact-full page still needs to prove physical exhaustion");
2580 assert_ne!(first.work.envelope_identity, second.work.envelope_identity);
2581
2582 let terminal = session
2583 .execute_trusted_live_page_with_result_bytes_limit_for_tests(
2584 &query,
2585 Some(second_continuation),
2586 48,
2587 )
2588 .expect("a third finite envelope should prove exhaustion without replaying rows");
2589 assert!(terminal.rows.is_empty());
2590 assert_eq!(terminal.continuation, None);
2591 assert_ne!(
2592 second.work.envelope_identity,
2593 terminal.work.envelope_identity
2594 );
2595
2596 assert_eq!(
2597 [first.rows, second.rows, terminal.rows].concat(),
2598 vec![
2599 vec![OutputValue::Nat64(3)],
2600 vec![OutputValue::Nat64(2)],
2601 vec![OutputValue::Nat64(1)],
2602 ]
2603 );
2604 }
2605
2606 #[test]
2607 fn distinct_live_pages_resume_adjacent_groups_and_global_replay_end_to_end() {
2608 let session = initialize();
2609 session
2610 .execute_trusted_dynamic_mutation_batch(vec![
2611 insert(1, None),
2612 insert(2, None),
2613 insert(3, Some(1)),
2614 insert(4, Some(2)),
2615 insert(5, Some(1)),
2616 insert(6, Some(3)),
2617 insert(7, Some(2)),
2618 ])
2619 .expect("DISTINCT continuation rows should insert atomically");
2620
2621 let adjacent = DynamicQuery::new(ENTITY_NAME)
2622 .select(["parent_id"])
2623 .order_by(asc("parent_id"))
2624 .order_by(asc("id"))
2625 .distinct_for_internal_execution();
2626 let global = DynamicQuery::new(ENTITY_NAME)
2627 .select(["parent_id"])
2628 .order_by(asc("id"))
2629 .distinct_for_internal_execution();
2630
2631 let traverse = |query: &DynamicQuery, strategy: &str| {
2632 let mut continuation = None;
2633 let mut rows = Vec::new();
2634 let mut cursors = std::collections::BTreeSet::new();
2635 let mut pages = 0_u32;
2636 let mut entries_visited = 0_u64;
2637 loop {
2638 let page = session
2639 .execute_trusted_live_page(query, continuation.as_deref())
2640 .unwrap_or_else(|error| {
2641 panic!("{strategy} DISTINCT page should execute: {error:?}")
2642 });
2643 pages = pages.saturating_add(1);
2644 entries_visited = entries_visited.saturating_add(page.work.entries_visited);
2645 assert_eq!(page.row_count as usize, page.rows.len());
2646 assert_eq!(page.work.result_rows, page.row_count);
2647 rows.extend(page.rows);
2648 let Some(cursor) = page.continuation else {
2649 break;
2650 };
2651 assert!(
2652 cursors.insert(cursor.clone()),
2653 "{strategy} DISTINCT continuation must advance monotonically",
2654 );
2655 continuation = Some(cursor);
2656 assert!(pages < 8, "{strategy} DISTINCT traversal must terminate");
2657 }
2658
2659 (rows, pages, entries_visited)
2660 };
2661
2662 let expected = vec![
2663 vec![OutputValue::Null],
2664 vec![OutputValue::Nat64(1)],
2665 vec![OutputValue::Nat64(2)],
2666 vec![OutputValue::Nat64(3)],
2667 ];
2668 let (adjacent_rows, adjacent_pages, adjacent_entries) = traverse(&adjacent, "adjacent");
2669 let (global_rows, global_pages, global_entries) = traverse(&global, "global");
2670
2671 assert_eq!(adjacent_rows, expected);
2672 assert_eq!(global_rows, expected);
2673 assert_eq!(adjacent_pages, 2);
2674 assert_eq!(global_pages, 2);
2675 assert!(adjacent_entries > 0);
2676 assert!(global_entries > 0);
2677 }
2678
2679 #[test]
2680 fn selective_live_pages_publish_monotonic_empty_physical_progress() {
2681 let session = initialize();
2682 session
2683 .execute_trusted_dynamic_mutation_batch(
2684 (1..=9)
2685 .map(|id| {
2686 let parent = match id {
2687 1 => Some(2),
2688 9 => Some(1),
2689 _ => None,
2690 };
2691 insert(id, parent)
2692 })
2693 .collect(),
2694 )
2695 .expect("selective live-page rows should insert");
2696 let query = DynamicQuery::new(ENTITY_NAME)
2697 .select(["id"])
2698 .filter(FilterExpr::eq("parent_id", 1_u64))
2699 .order_by(asc("id"))
2700 .limit(1);
2701
2702 let first = session
2703 .execute_trusted_live_page(&query, None)
2704 .expect("first selective page should stop with physical progress");
2705 assert!(first.rows.is_empty());
2706 assert_eq!(first.work.entries_visited, 4);
2707 let first_cursor = first
2708 .continuation
2709 .expect("filtered physical progress must return a continuation");
2710
2711 let second = session
2712 .execute_trusted_live_page(&query, Some(first_cursor.as_str()))
2713 .expect("second selective page should resume after the first physical frontier");
2714 assert!(second.rows.is_empty());
2715 assert_eq!(second.work.entries_visited, 4);
2716 let second_cursor = second
2717 .continuation
2718 .expect("second filtered frontier must remain resumable");
2719 assert_ne!(second_cursor, first_cursor);
2720
2721 let third = session
2722 .execute_trusted_live_page(&query, Some(second_cursor.as_str()))
2723 .expect("final selective page should return the late match");
2724 assert_eq!(third.rows, vec![vec![OutputValue::Nat64(9)]]);
2725 assert_eq!(third.work.entries_visited, 1);
2726 assert_eq!(third.continuation, None);
2727
2728 let descending = DynamicQuery::new(ENTITY_NAME)
2729 .select(["id"])
2730 .filter(FilterExpr::eq("parent_id", 2_u64))
2731 .order_by(desc("id"))
2732 .limit(1);
2733 let descending_first = session
2734 .execute_trusted_live_page(&descending, None)
2735 .expect("descending selective page should stop with physical progress");
2736 assert!(descending_first.rows.is_empty());
2737 let descending_first_cursor = descending_first
2738 .continuation
2739 .expect("descending filtered progress must return a continuation");
2740 let descending_second = session
2741 .execute_trusted_live_page(&descending, Some(descending_first_cursor.as_str()))
2742 .expect("descending progress should resume after its physical frontier");
2743 assert!(descending_second.rows.is_empty());
2744 let descending_second_cursor = descending_second
2745 .continuation
2746 .expect("descending second frontier must remain resumable");
2747 assert_ne!(descending_second_cursor, descending_first_cursor);
2748 let descending_third = session
2749 .execute_trusted_live_page(&descending, Some(descending_second_cursor.as_str()))
2750 .expect("descending final page should return the late match");
2751 assert_eq!(descending_third.rows, vec![vec![OutputValue::Nat64(1)]]);
2752 assert_eq!(descending_third.continuation, None);
2753 }
2754
2755 #[test]
2756 fn accepted_relation_edges_drive_catalog_and_describe_introspection() {
2757 let session = initialize();
2758 let entities = session
2759 .show_entities()
2760 .expect("accepted entity catalog should resolve");
2761 let source = entities
2762 .iter()
2763 .find(|entity| entity.entity_name() == ENTITY_NAME)
2764 .expect("relation source should be listed");
2765 assert_eq!(source.relations(), 1);
2766
2767 let description = session
2768 .try_describe_entity_by_name(ENTITY_NAME)
2769 .expect("accepted relation source should describe");
2770 let [relation] = description.relations() else {
2771 panic!("accepted relation edge should produce one relation row");
2772 };
2773 assert_eq!(relation.field(), "parent_id");
2774 assert_eq!(relation.target_path(), ENTITY_SOURCE);
2775 assert_eq!(relation.target_entity_name(), ENTITY_NAME);
2776 assert_eq!(relation.target_store_path(), STORE_PATH);
2777 assert_eq!(
2778 relation.cardinality(),
2779 crate::db::EntityRelationCardinality::Single,
2780 );
2781 }
2782
2783 #[test]
2784 fn mixed_relation_validation_uses_the_complete_final_row_overlay() {
2785 let session = initialize();
2786 session
2787 .execute_trusted_dynamic_mutation_batch(vec![insert(1, None), insert(2, Some(1))])
2788 .expect("the initial relation should commit");
2789
2790 let blocked = session
2791 .execute_trusted_dynamic_mutation(&delete(1))
2792 .expect_err("an unaffected committed source must block target deletion");
2793 assert_relation_violation(&blocked);
2794
2795 let deleted = session
2796 .execute_trusted_dynamic_mutation_batch(vec![delete(2), delete(1)])
2797 .expect("a source and its target should delete atomically");
2798 assert_eq!(
2799 deleted.rows,
2800 vec![expected_row(2, Some(1)), expected_row(1, None)],
2801 );
2802
2803 session
2804 .execute_trusted_dynamic_mutation_batch(vec![insert(3, None), insert(4, Some(3))])
2805 .expect("the update-away fixture should commit");
2806 let updated_away = session
2807 .execute_trusted_dynamic_mutation_batch(vec![update_parent(4, None), delete(3)])
2808 .expect("an updated final source may release a deleted target");
2809 assert_eq!(
2810 updated_away.rows,
2811 vec![expected_row(4, None), expected_row(3, None)],
2812 );
2813
2814 session
2815 .execute_trusted_dynamic_mutation_batch(vec![insert(5, None), insert(6, Some(5))])
2816 .expect("the retained-reference fixture should commit");
2817 let retained = session
2818 .execute_trusted_dynamic_mutation_batch(vec![update_parent(6, Some(5)), delete(5)])
2819 .expect_err("a final updated source must still block target deletion");
2820 assert_relation_violation(&retained);
2821
2822 session
2823 .execute_trusted_dynamic_mutation(&insert(7, None))
2824 .expect("the inserted-reference fixture target should commit");
2825 let inserted_reference = session
2826 .execute_trusted_dynamic_mutation_batch(vec![insert(8, Some(7)), delete(7)])
2827 .expect_err("a final inserted source must not reference a deleted target");
2828 assert_relation_violation(&inserted_reference);
2829
2830 let inserted_target = session
2831 .execute_trusted_dynamic_mutation_batch(vec![insert(10, Some(9)), insert(9, None)])
2832 .expect("an inserted relation should see its batch-final target");
2833 assert_eq!(
2834 inserted_target.rows,
2835 vec![expected_row(10, Some(9)), expected_row(9, None)],
2836 );
2837
2838 session
2839 .execute_trusted_dynamic_mutation(&insert(11, None))
2840 .expect("the updated-reference fixture source should commit");
2841 let updated_target = session
2842 .execute_trusted_dynamic_mutation_batch(vec![
2843 update_parent(11, Some(12)),
2844 insert(12, None),
2845 ])
2846 .expect("an updated relation should see its batch-final target");
2847 assert_eq!(
2848 updated_target.rows,
2849 vec![expected_row(11, Some(12)), expected_row(12, None)],
2850 );
2851 }
2852
2853 #[test]
2854 fn mixed_batch_rejects_cross_entity_missing_and_collision_then_honors_replace() {
2855 let session = initialize();
2856 session
2857 .execute_trusted_dynamic_mutation(&insert(1, None))
2858 .expect("the primary mixed fixture row should commit");
2859 session
2860 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
2861 entity: OTHER_ENTITY_NAME.to_string(),
2862 patch: other_patch(Some(1), 10),
2863 })
2864 .expect("the secondary mixed fixture row should commit");
2865
2866 let mixed_entity = session
2867 .execute_trusted_dynamic_mutation_batch(vec![
2868 update_code(1, 11),
2869 DynamicMutation::Update {
2870 entity: OTHER_ENTITY_NAME.to_string(),
2871 key: InputValue::Nat64(1),
2872 patch: other_patch(None, 11),
2873 },
2874 ])
2875 .expect_err("one atomic batch must not cross accepted entities");
2876 assert_eq!(mixed_entity.class(), ErrorClass::Conflict);
2877 assert_eq!(
2878 mixed_entity.diagnostic_facts(),
2879 vec![
2880 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1,),
2881 (
2882 icydb_diagnostic_code::DiagnosticFactTag::ExpectedEntityTag,
2883 ENTITY_TAG.value(),
2884 ),
2885 (
2886 icydb_diagnostic_code::DiagnosticFactTag::ActualEntityTag,
2887 OTHER_ENTITY_TAG.value(),
2888 ),
2889 ],
2890 );
2891
2892 let missing = session
2893 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 12), delete(99)])
2894 .expect_err("a late missing delete must reject the earlier staged update");
2895 assert_eq!(missing.class(), ErrorClass::NotFound);
2896
2897 session
2898 .execute_trusted_dynamic_mutation(&insert(2, None))
2899 .expect("the collision fixture should commit");
2900 let collision = session
2901 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 13), insert(2, None)])
2902 .expect_err("an insert collision must reject the earlier staged update");
2903 assert_eq!(collision.class(), ErrorClass::Conflict);
2904 let failures_unchanged = session
2905 .execute_trusted_dynamic_mutation(&update_code(1, 1))
2906 .expect("failed batches must preserve the original unique value");
2907 assert_eq!(failures_unchanged.affected_rows, 0);
2908
2909 let replaced = session
2910 .execute_trusted_dynamic_mutation_batch(vec![
2911 update_code(1, 14),
2912 DynamicMutation::Replace {
2913 entity: ENTITY_NAME.to_string(),
2914 key: InputValue::Nat64(99),
2915 patch: patch(None, None, Some(99)),
2916 },
2917 ])
2918 .expect("ordinary caller-key replace should insert its absent final row");
2919 assert_eq!(
2920 replaced.rows,
2921 vec![
2922 expected_row_with_code(1, None, 14),
2923 expected_row_with_code(99, None, 99),
2924 ],
2925 );
2926
2927 let unchanged = session
2928 .execute_trusted_dynamic_mutation(&update_code(1, 14))
2929 .expect("the successful mixed replace must publish its preceding update");
2930 assert_eq!(unchanged.affected_rows, 0);
2931 let other_unchanged = session
2932 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
2933 entity: OTHER_ENTITY_NAME.to_string(),
2934 key: InputValue::Nat64(1),
2935 patch: other_patch(None, 10),
2936 })
2937 .expect("cross-entity rejection must preserve the secondary row");
2938 assert_eq!(other_unchanged.affected_rows, 0);
2939 }
2940
2941 #[test]
2942 fn mixed_batch_unique_swap_and_delete_release_use_the_final_overlay() {
2943 let session = initialize();
2944 session
2945 .execute_trusted_dynamic_mutation_batch(vec![
2946 insert_with_code(1, None, 10),
2947 insert_with_code(2, None, 20),
2948 ])
2949 .expect("the unique-overlay fixture should commit");
2950
2951 let swapped = session
2952 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 20), update_code(2, 10)])
2953 .expect("two final rows should atomically swap unique memberships");
2954 assert_eq!(
2955 swapped.rows,
2956 vec![
2957 expected_row_with_code(1, None, 20),
2958 expected_row_with_code(2, None, 10),
2959 ],
2960 );
2961
2962 let released = session
2963 .execute_trusted_dynamic_mutation_batch(vec![delete(1), insert_with_code(3, None, 20)])
2964 .expect("a delete should release unique membership to a final inserted row");
2965 assert_eq!(
2966 released.rows,
2967 vec![
2968 expected_row_with_code(1, None, 20),
2969 expected_row_with_code(3, None, 20),
2970 ],
2971 );
2972 }
2973}
2974
2975#[cfg(test)]
2976mod identity_pre_key_tests {
2977 #[cfg(all(feature = "sql", feature = "diagnostics"))]
2978 use super::DynamicTypedEntityBinding;
2979 use super::{
2980 AcceptedMutationIntentPatch, AcceptedRowLayoutRuntimeContract, AcceptedStructuralMutation,
2981 AcceptedStructuralMutationTarget, DbSession, DynamicMutation, DynamicStructuralPatch,
2982 DynamicTypedFieldBindingRequest, DynamicTypedFieldType, DynamicTypedMutation,
2983 DynamicWriteCell, FieldSlot, MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
2984 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
2985 MutationProgressRecordOp, add_structural_mutation_staged_bytes,
2986 checked_pre_key_candidate_count, insert_key_exists_after_generation,
2987 validate_structural_mutation_result_bytes,
2988 };
2989 #[cfg(all(feature = "sql", feature = "diagnostics"))]
2990 use crate::db::data::DecodedDataStoreKey;
2991 #[cfg(all(feature = "sql", feature = "diagnostics"))]
2992 use crate::db::executor::budget::{
2993 HardExecutionBudget, HardExecutionContext, HardExecutionFailureHeadroom,
2994 with_query_execution_budget_for_tests,
2995 };
2996 use crate::db::mutation_job::{MutationJobRecord, MutationJobTransition};
2997 #[cfg(all(feature = "sql", feature = "diagnostics"))]
2998 use crate::db::{
2999 CompareProofAndAdvanceError, DynamicQuery, ExhaustiveReadError, RawDataStoreKey,
3000 ReadSetRevisionError, ResumableJobAdvance, ResumableJobAdvanceRequest,
3001 ResumableJobAdvanceStatus, ResumableJobError, ResumableJobId, ResumableJobIdempotencyKey,
3002 ResumableJobStatus, asc,
3003 };
3004 use crate::{
3005 db::{
3006 MutationJobAdvanceRequest, MutationJobId, MutationJobIdempotencyKey, MutationJobPhase,
3007 MutationJobStatus,
3008 commit::{database_incarnation_id, forget_recovered_domain_for_tests},
3009 data::DataStore,
3010 executor::{MutationCommitInterruption, interrupt_next_mutation_commit_for_tests},
3011 index::IndexStore,
3012 integrity::{
3013 InsertMutationJobResult, PhysicalUnitCheckpoint, QuickIntegrityStatus,
3014 RowInspectionLimits, execute_quick_integrity, execute_row_integrity_page,
3015 with_mutation_progress_store,
3016 },
3017 journal::JournalTailStore,
3018 registry::{
3019 StoreAllocationIdentities, StoreAllocationIdentity, StoreRegistry,
3020 StoreRuntimeStorageCapabilities,
3021 },
3022 schema::{
3023 AcceptedFieldKind, AcceptedSchemaRevision, FieldId, FieldInsertGeneration,
3024 FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
3025 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
3026 PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy,
3027 SchemaIndexId, SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
3028 accepted_schema_candidate_with_field_bindings_for_tests,
3029 },
3030 write_context::MutationMode,
3031 },
3032 error::{ErrorClass, ErrorOrigin, InternalError},
3033 testing::test_memory,
3034 traits::{CanisterKind, Path},
3035 types::{EntityTag, Timestamp},
3036 value::{InputValue, OutputValue, Value},
3037 };
3038 use icydb_schema::{FieldSourceKey, ScalarType};
3039 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3040 use std::cell::Cell;
3041 use std::{cell::RefCell, collections::BTreeMap, time::Instant};
3042
3043 const STORE_PATH: &str = "session::write::identity_pre_key_tests::Store";
3044 const ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::Entity";
3045 const ID_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::id";
3046 const PAYLOAD_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::payload";
3047 const ENTITY_NAME: &str = "IdentityRow";
3048 const ENTITY_TAG: EntityTag = EntityTag::new(93);
3049 const JOURNALED_STORE_PATH: &str = "session::write::identity_pre_key_tests::JournaledStore";
3050 const UNRELATED_STORE_PATH: &str = "session::write::identity_pre_key_tests::UnrelatedStore";
3051
3052 struct TestCanister;
3053
3054 impl Path for TestCanister {
3055 const PATH: &'static str = "session::write::identity_pre_key_tests::Canister";
3056 }
3057
3058 impl CanisterKind for TestCanister {
3059 const COMMIT_MEMORY_ID: u8 = 45;
3060 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.commit.v1";
3061 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 46;
3062 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3063 "icydb.identity_pre_key_tests.integrity.progress.v1";
3064 }
3065
3066 thread_local! {
3067 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
3068 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
3069 static SCHEMA_STORE: RefCell<SchemaStore> =
3070 const { RefCell::new(SchemaStore::init_heap()) };
3071 static UNRELATED_DATA_STORE: RefCell<DataStore> =
3072 const { RefCell::new(DataStore::init_heap()) };
3073 static UNRELATED_INDEX_STORE: RefCell<IndexStore> =
3074 const { RefCell::new(IndexStore::init_heap()) };
3075 static UNRELATED_SCHEMA_STORE: RefCell<SchemaStore> =
3076 const { RefCell::new(SchemaStore::init_heap()) };
3077 static STORE_REGISTRY: StoreRegistry = {
3078 let mut registry = StoreRegistry::new();
3079 registry.register_store(
3080 STORE_PATH,
3081 &DATA_STORE,
3082 &INDEX_STORE,
3083 &SCHEMA_STORE,
3084 StoreAllocationIdentities::absent(),
3085 StoreRuntimeStorageCapabilities::heap(),
3086 ).expect("identity pre-key test store should register");
3087 registry.register_store(
3088 UNRELATED_STORE_PATH,
3089 &UNRELATED_DATA_STORE,
3090 &UNRELATED_INDEX_STORE,
3091 &UNRELATED_SCHEMA_STORE,
3092 StoreAllocationIdentities::absent(),
3093 StoreRuntimeStorageCapabilities::heap(),
3094 ).expect("unrelated identity test store should register");
3095 registry
3096 };
3097 static JOURNALED_DATA_STORE: RefCell<DataStore> =
3098 RefCell::new(DataStore::init_journaled(test_memory(186)));
3099 static JOURNALED_INDEX_STORE: RefCell<IndexStore> =
3100 RefCell::new(IndexStore::init_journaled(test_memory(187)));
3101 static JOURNALED_SCHEMA_STORE: RefCell<SchemaStore> =
3102 RefCell::new(SchemaStore::init_journaled(test_memory(188)));
3103 static JOURNALED_TAIL_STORE: RefCell<JournalTailStore> =
3104 RefCell::new(JournalTailStore::init(test_memory(189)));
3105 static JOURNALED_STORE_REGISTRY: StoreRegistry = {
3106 let mut registry = StoreRegistry::new();
3107 registry.register_journaled_store(
3108 JOURNALED_STORE_PATH,
3109 &JOURNALED_DATA_STORE,
3110 &JOURNALED_INDEX_STORE,
3111 &JOURNALED_SCHEMA_STORE,
3112 &JOURNALED_TAIL_STORE,
3113 StoreAllocationIdentities::new_journaled(
3114 StoreAllocationIdentity::new(186, "icydb.test.identity-range.data.v1"),
3115 StoreAllocationIdentity::new(187, "icydb.test.identity-range.index.v1"),
3116 StoreAllocationIdentity::new(188, "icydb.test.identity-range.schema.v1"),
3117 StoreAllocationIdentity::new(189, "icydb.test.identity-range.journal.v1"),
3118 ),
3119 StoreRuntimeStorageCapabilities::journaled(),
3120 ).expect("identity range journaled store should register");
3121 registry
3122 };
3123 }
3124
3125 struct JournaledTestCanister;
3126
3127 impl Path for JournaledTestCanister {
3128 const PATH: &'static str = "session::write::identity_pre_key_tests::JournaledCanister";
3129 }
3130
3131 impl CanisterKind for JournaledTestCanister {
3132 const COMMIT_MEMORY_ID: u8 = 190;
3133 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_range_tests.commit.v1";
3134 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 191;
3135 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3136 "icydb.identity_range_tests.integrity.progress.v1";
3137 }
3138
3139 fn source_key(source: &str) -> FieldSourceKey {
3140 FieldSourceKey::try_new(source).expect("identity test field source should admit")
3141 }
3142
3143 fn identity_snapshot(store_path: &str) -> PersistedSchemaSnapshot {
3144 let fields = vec![
3145 PersistedFieldSnapshot::new_initial_with_write_policy(
3146 FieldId::new(1),
3147 "id".to_string(),
3148 SchemaFieldSlot::new(0),
3149 AcceptedFieldKind::Nat64,
3150 Vec::new(),
3151 false,
3152 SchemaInsertDefault::None,
3153 SchemaFieldWritePolicy::from_model_policies(
3154 Some(FieldInsertGeneration::Identity),
3155 None,
3156 ),
3157 FieldStorageDecode::ByKind,
3158 LeafCodec::Scalar(ScalarCodec::Nat64),
3159 ),
3160 PersistedFieldSnapshot::new_initial(
3161 FieldId::new(2),
3162 "payload".to_string(),
3163 SchemaFieldSlot::new(1),
3164 AcceptedFieldKind::Nat64,
3165 Vec::new(),
3166 false,
3167 SchemaInsertDefault::None,
3168 FieldStorageDecode::ByKind,
3169 LeafCodec::Scalar(ScalarCodec::Nat64),
3170 ),
3171 ];
3172 PersistedSchemaSnapshot::new_with_indexes(
3173 SchemaVersion::initial(),
3174 ENTITY_SOURCE.to_string(),
3175 ENTITY_NAME.to_string(),
3176 FieldId::new(1),
3177 SchemaRowLayout::initial(
3178 fields
3179 .iter()
3180 .map(|field| (field.id(), field.slot()))
3181 .collect(),
3182 ),
3183 fields,
3184 vec![PersistedIndexSnapshot::new(
3185 SchemaIndexId::new(1).expect("identity test index ID should admit"),
3186 1,
3187 "by_payload".to_string(),
3188 store_path.to_string(),
3189 false,
3190 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
3191 FieldId::new(2),
3192 SchemaFieldSlot::new(1),
3193 vec!["payload".to_string()],
3194 AcceptedFieldKind::Nat64,
3195 false,
3196 )]),
3197 None,
3198 )],
3199 )
3200 }
3201
3202 fn initialize() -> DbSession<TestCanister> {
3203 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3204 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3205 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3206 UNRELATED_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3207 UNRELATED_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3208 UNRELATED_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3209 let session = DbSession::<TestCanister>::new(
3210 &STORE_REGISTRY,
3211 &crate::db::RequestExecutionRoot::__new_runtime_root(),
3212 );
3213 session
3214 .db
3215 .ensure_recovered_state()
3216 .expect("identity pre-key test database should initialize");
3217 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3218 STORE_PATH,
3219 AcceptedSchemaRevision::INITIAL,
3220 BTreeMap::from([(ENTITY_TAG, identity_snapshot(STORE_PATH))]),
3221 BTreeMap::from([
3222 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
3223 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
3224 ]),
3225 );
3226 let store = session
3227 .db
3228 .store_handle(STORE_PATH)
3229 .expect("identity pre-key test store should resolve");
3230 crate::db::commit::publish_accepted_schema_candidate(
3231 STORE_PATH,
3232 store,
3233 AcceptedSchemaRevision::NONE,
3234 &candidate,
3235 )
3236 .expect("identity candidate should publish with explicit zero state");
3237 session
3238 }
3239
3240 fn initialize_journaled_with_root() -> (
3241 DbSession<JournaledTestCanister>,
3242 crate::db::RequestExecutionRoot,
3243 ) {
3244 let root = crate::db::RequestExecutionRoot::__new_runtime_root();
3245 let session = DbSession::<JournaledTestCanister>::new(&JOURNALED_STORE_REGISTRY, &root);
3246 session
3247 .db
3248 .ensure_recovered_state()
3249 .expect("journaled identity database should initialize");
3250 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3251 JOURNALED_STORE_PATH,
3252 AcceptedSchemaRevision::INITIAL,
3253 BTreeMap::from([(ENTITY_TAG, identity_snapshot(JOURNALED_STORE_PATH))]),
3254 BTreeMap::from([
3255 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
3256 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
3257 ]),
3258 );
3259 let store = session
3260 .db
3261 .store_handle(JOURNALED_STORE_PATH)
3262 .expect("journaled identity store should resolve");
3263 crate::db::commit::publish_accepted_schema_candidate(
3264 JOURNALED_STORE_PATH,
3265 store,
3266 AcceptedSchemaRevision::NONE,
3267 &candidate,
3268 )
3269 .expect("journaled identity candidate should publish");
3270 (session, root)
3271 }
3272
3273 fn initialize_journaled() -> DbSession<JournaledTestCanister> {
3274 initialize_journaled_with_root().0
3275 }
3276
3277 fn payload_patch(value: u64) -> AcceptedMutationIntentPatch {
3278 AcceptedMutationIntentPatch::new()
3279 .set_authored(FieldSlot::from_validated_index(1), InputValue::Nat64(value))
3280 }
3281
3282 fn dynamic_payload_patch(value: u64) -> DynamicStructuralPatch {
3283 DynamicStructuralPatch::new(vec![(
3284 "payload".to_string(),
3285 DynamicWriteCell::Value(InputValue::Nat64(value)),
3286 )])
3287 }
3288
3289 fn expected_dynamic_row(id: u64, payload: u64) -> Vec<OutputValue> {
3290 vec![OutputValue::Nat64(id), OutputValue::Nat64(payload)]
3291 }
3292
3293 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3294 fn exact_key_binding<C: CanisterKind>(session: &DbSession<C>) -> DynamicTypedEntityBinding {
3295 session
3296 .issue_typed_entity_binding(
3297 ENTITY_SOURCE,
3298 &[
3299 DynamicTypedFieldBindingRequest::new(
3300 ID_SOURCE.to_string(),
3301 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3302 false,
3303 ),
3304 DynamicTypedFieldBindingRequest::new(
3305 PAYLOAD_SOURCE.to_string(),
3306 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3307 false,
3308 ),
3309 ],
3310 )
3311 .expect("exact-key test binding should issue")
3312 }
3313
3314 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3315 fn insert_exact_key_fixture<C: CanisterKind>(session: &DbSession<C>, payload: u64) -> u64 {
3316 let output = session
3317 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
3318 entity: ENTITY_NAME.to_string(),
3319 patch: dynamic_payload_patch(payload),
3320 })
3321 .expect("exact-key fixture insert should commit");
3322 match output.rows.as_slice() {
3323 [row] => match row.as_slice() {
3324 [OutputValue::Nat64(id), OutputValue::Nat64(actual_payload)]
3325 if *actual_payload == payload =>
3326 {
3327 *id
3328 }
3329 _ => panic!("exact-key fixture should return its identity and payload"),
3330 },
3331 _ => panic!("exact-key fixture insert should return one row"),
3332 }
3333 }
3334
3335 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3336 fn identity_row_stored_bytes<C: CanisterKind>(
3337 session: &DbSession<C>,
3338 store_path: &'static str,
3339 key: u64,
3340 ) -> u64 {
3341 let data_key = DecodedDataStoreKey::try_from_structural_key(ENTITY_TAG, &Value::Nat64(key))
3342 .expect("identity row key should encode");
3343 let raw_key = data_key.to_raw().expect("identity raw key should encode");
3344 let store = session
3345 .db
3346 .recovered_store(store_path)
3347 .expect("identity store should resolve");
3348 store.with_data(|data_store| {
3349 u64::try_from(
3350 data_store
3351 .get(&raw_key)
3352 .expect("inserted identity row should exist")
3353 .len(),
3354 )
3355 .expect("bounded row length should fit u64")
3356 })
3357 }
3358
3359 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3360 fn with_stored_bytes_limit<T>(
3361 limit: u64,
3362 shape_fingerprint_prefix: u64,
3363 operation: impl FnOnce() -> Result<T, crate::db::query::intent::QueryError>,
3364 ) -> Result<T, crate::db::query::intent::QueryError> {
3365 let budget = HardExecutionBudget::uniform_for_tests(
3366 u64::MAX,
3367 HardExecutionFailureHeadroom::new(500, 256),
3368 )
3369 .with_limit_for_tests(
3370 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::StoredBytesRead,
3371 limit,
3372 );
3373 let context = HardExecutionContext::new(
3374 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
3375 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
3376 shape_fingerprint_prefix,
3377 );
3378
3379 with_query_execution_budget_for_tests(budget, context, operation)
3380 }
3381
3382 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3383 fn assert_exact_key_batch<C: CanisterKind>(session: &DbSession<C>) {
3384 let first = insert_exact_key_fixture(session, 41);
3385 let second = insert_exact_key_fixture(session, 42);
3386 let missing = u64::MAX;
3387 let binding = exact_key_binding(session);
3388 let gets_before = DataStore::current_get_call_count();
3389 let result = session
3390 .execute_public_exact_key_batch_for_typed_binding(
3391 &binding,
3392 &[second, missing, first, second],
3393 )
3394 .expect("exact-key batch should execute")
3395 .expect("exact-key binding should remain current");
3396
3397 assert_eq!(result.positions, vec![0, 1, 2, 0]);
3398 assert_eq!(
3399 result.distinct_rows,
3400 vec![
3401 Some(expected_dynamic_row(second, 42)),
3402 None,
3403 Some(expected_dynamic_row(first, 41)),
3404 ],
3405 );
3406 assert_eq!(
3407 DataStore::current_get_call_count().saturating_sub(gets_before),
3408 3,
3409 "four input positions with one duplicate must perform three physical reads",
3410 );
3411 }
3412
3413 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3414 #[test]
3415 fn exact_key_batches_preserve_semantics_across_heap_and_journaled_stores() {
3416 assert_exact_key_batch(&initialize());
3417 assert_exact_key_batch(&initialize_journaled());
3418 }
3419
3420 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3421 fn assert_primary_range_materialization_fetches_once<C: CanisterKind>(
3422 session: &DbSession<C>,
3423 store_path: &'static str,
3424 ) {
3425 let key = insert_exact_key_fixture(session, 41);
3426 let stored_bytes = identity_row_stored_bytes(session, store_path, key);
3427
3428 let scalar = DynamicQuery::new(ENTITY_NAME)
3429 .select(["id", "payload"])
3430 .order_by(asc("id"))
3431 .limit(1);
3432 let gets_before = DataStore::current_get_call_count();
3433 let scalar_page = with_stored_bytes_limit(stored_bytes, 0x7072_696d_6172_792d, || {
3434 session.execute_trusted_live_page(&scalar, None)
3435 })
3436 .expect("one scalar primary-range row should fit one payload-read allowance");
3437 assert_eq!(scalar_page.row_count, 1);
3438 assert_eq!(
3439 DataStore::current_get_call_count().saturating_sub(gets_before),
3440 1,
3441 "scalar primary traversal should fetch its emitted row exactly once",
3442 );
3443
3444 let grouped = DynamicQuery::new(ENTITY_NAME)
3445 .group_by("payload")
3446 .aggregate(crate::db::count())
3447 .grouped_limits(10, 16 * 1_024)
3448 .limit(1);
3449 let gets_before = DataStore::current_get_call_count();
3450 let grouped_page = with_stored_bytes_limit(stored_bytes, 0x6772_6f75_7065_642d, || {
3451 session.execute_trusted_dynamic_grouped_query(&grouped)
3452 })
3453 .expect("one grouped primary-range row should fit one payload-read allowance");
3454 assert_eq!(grouped_page.row_count, 1);
3455 assert_eq!(
3456 DataStore::current_get_call_count().saturating_sub(gets_before),
3457 1,
3458 "grouped primary traversal should fetch its source row exactly once",
3459 );
3460 }
3461
3462 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3463 #[test]
3464 fn row_materialization_fetches_each_required_payload_at_most_once() {
3465 assert_primary_range_materialization_fetches_once(&initialize(), STORE_PATH);
3466 assert_primary_range_materialization_fetches_once(
3467 &initialize_journaled(),
3468 JOURNALED_STORE_PATH,
3469 );
3470 }
3471
3472 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3473 #[test]
3474 fn ordered_grouped_pages_close_a_group_spanning_physical_refills_before_resume() {
3475 let session = initialize();
3476 let mut patches = Vec::new();
3477 for _ in 0..70 {
3478 patches.push(dynamic_payload_patch(10));
3479 }
3480 for _ in 0..3 {
3481 patches.push(dynamic_payload_patch(20));
3482 }
3483 patches.push(dynamic_payload_patch(30));
3484 let inserted = session
3485 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, patches)
3486 .expect("ordered grouped continuation rows should insert");
3487 assert_eq!(inserted.rows.len(), 74);
3488
3489 let query = DynamicQuery::new(ENTITY_NAME)
3490 .group_by("payload")
3491 .aggregate(crate::db::count())
3492 .aggregate(crate::db::sum("id"))
3493 .order_by(asc("payload"))
3494 .grouped_limits(4, 16 * 1_024)
3495 .limit(1);
3496 let expected = [
3497 (10_u64, 70_u64, crate::types::Decimal::new(2_485, 0)),
3498 (20, 3, crate::types::Decimal::new(216, 0)),
3499 (30, 1, crate::types::Decimal::new(74, 0)),
3500 ];
3501 let mut continuation: Option<String> = None;
3502 let mut seen_cursors = std::collections::BTreeSet::new();
3503
3504 for (page_index, (group_key, row_count, id_sum)) in expected.into_iter().enumerate() {
3505 let request = continuation.as_ref().map_or_else(
3506 || query.clone(),
3507 |cursor| query.clone().cursor(cursor.clone()),
3508 );
3509 let entries_before = IndexStore::current_entry_read_count();
3510 let rows_before = DataStore::current_get_call_count();
3511 let page = session
3512 .execute_trusted_dynamic_grouped_query(&request)
3513 .unwrap_or_else(|error| {
3514 panic!("ordered grouped page {page_index} should execute: {error:?}")
3515 });
3516 let entries_read =
3517 IndexStore::current_entry_read_count().saturating_sub(entries_before);
3518 let rows_read = DataStore::current_get_call_count().saturating_sub(rows_before);
3519
3520 assert_eq!(page.row_count, 1);
3521 let [row] = page.rows.as_slice() else {
3522 panic!("ordered grouped page must contain exactly one closed group")
3523 };
3524 assert_eq!(row.group_key(), &[OutputValue::Nat64(group_key)]);
3525 assert_eq!(
3526 row.aggregate_values(),
3527 &[OutputValue::Nat64(row_count), OutputValue::Decimal(id_sum),],
3528 );
3529 if page_index == 0 {
3530 assert!(
3531 entries_read.saturating_add(rows_read) >= 70,
3532 "the first closed group must span the maintained 64-entry physical refill",
3533 );
3534 }
3535
3536 continuation = page.next_cursor;
3537 if page_index + 1 < expected.len() {
3538 let cursor = continuation
3539 .as_ref()
3540 .expect("another closed group should retain continuation");
3541 assert!(
3542 seen_cursors.insert(cursor.clone()),
3543 "ordered grouped continuation must advance monotonically",
3544 );
3545 } else {
3546 assert_eq!(continuation, None);
3547 }
3548 }
3549 }
3550
3551 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3552 #[test]
3553 fn exhaustive_pages_require_and_recompare_the_complete_source_proof() {
3554 let session = initialize();
3555 let first = insert_exact_key_fixture(&session, 41);
3556 let second = insert_exact_key_fixture(&session, 42);
3557 let third = insert_exact_key_fixture(&session, 43);
3558 let query = DynamicQuery::new(ENTITY_NAME)
3559 .select(["id", "payload"])
3560 .order_by(asc("id"));
3561
3562 let page = session
3563 .execute_trusted_exhaustive_page(&query, None, None)
3564 .expect("initial exhaustive page should capture its source proof");
3565 assert_eq!(
3566 page.rows,
3567 vec![
3568 expected_dynamic_row(first, 41),
3569 expected_dynamic_row(second, 42),
3570 ],
3571 );
3572 let continuation = page
3573 .continuation
3574 .as_deref()
3575 .expect("unreturned row should retain exhaustive continuation");
3576 assert!(matches!(
3577 session.execute_trusted_exhaustive_page(&query, Some(continuation), None),
3578 Err(ExhaustiveReadError::Revision(
3579 ReadSetRevisionError::ResumeProofRequired
3580 )),
3581 ));
3582 let resumed = session
3583 .execute_trusted_exhaustive_page(&query, Some(continuation), Some(&page.proof))
3584 .expect("unchanged proof should resume exhaustive traversal");
3585 assert_eq!(resumed.rows, vec![expected_dynamic_row(third, 43)]);
3586 assert_eq!(resumed.continuation, None);
3587
3588 let stale_page = session
3589 .execute_trusted_exhaustive_page(&query, None, None)
3590 .expect("fresh exhaustive page should capture current revision");
3591 let stale_continuation = stale_page
3592 .continuation
3593 .as_deref()
3594 .expect("fresh three-row traversal should retain continuation");
3595 let _ = insert_exact_key_fixture(&session, 44);
3596 assert!(matches!(
3597 session.execute_trusted_exhaustive_page(
3598 &query,
3599 Some(stale_continuation),
3600 Some(&stale_page.proof),
3601 ),
3602 Err(ExhaustiveReadError::Revision(
3603 ReadSetRevisionError::StoreDataChanged { .. }
3604 )),
3605 ));
3606 }
3607
3608 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3609 #[test]
3610 fn heap_sources_cannot_back_durable_resumable_jobs() {
3611 let session = initialize();
3612 let proof = session
3613 .capture_read_set_revision_proof(&[ENTITY_NAME])
3614 .expect("heap source proof should capture for one-call exhaustive reads");
3615 let job_id = ResumableJobId::try_from_bytes([70; 32])
3616 .expect("nonzero heap test job identity should admit");
3617
3618 assert!(matches!(
3619 session.start_resumable_job(job_id, proof, Vec::new()),
3620 Err(ResumableJobError::SourceProof(
3621 ReadSetRevisionError::DurableStoreRequired { .. }
3622 )),
3623 ));
3624 }
3625
3626 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3627 #[test]
3628 fn proof_and_progress_controls_charge_one_shared_request_scope() {
3629 let (session, root) = initialize_journaled_with_root();
3630 let resource = icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions;
3631 let before = root.observed(resource);
3632 let proof = session
3633 .capture_read_set_revision_proof(&[ENTITY_NAME])
3634 .expect("proof capture should use the retained request scope");
3635 let job_id = ResumableJobId::try_from_bytes([75; 32])
3636 .expect("nonzero accounting job identity should admit");
3637 session
3638 .start_resumable_job(job_id, proof, Vec::new())
3639 .expect("job start should use the same retained request scope");
3640 let _ = session
3641 .resumable_job_state(job_id)
3642 .expect("job load should use the same retained request scope");
3643
3644 assert_eq!(root.observed(resource).saturating_sub(before), 3);
3645 }
3646
3647 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3648 #[test]
3649 fn source_proofs_ignore_unrelated_stores_but_bind_access_state_changes() {
3650 let session = initialize();
3651 let proof = session
3652 .capture_read_set_revision_proof(&[ENTITY_NAME])
3653 .expect("source proof should cover only the entity's physical store");
3654 let shared_store_proof = session
3655 .capture_read_set_revision_proof(&[ENTITY_NAME, ENTITY_NAME])
3656 .expect("entities sharing one physical source should deduplicate");
3657 assert_eq!(shared_store_proof, proof);
3658 assert_eq!(shared_store_proof.stores().len(), 1);
3659 let unrelated = session
3660 .db
3661 .store_handle(UNRELATED_STORE_PATH)
3662 .expect("unrelated registered store should resolve");
3663 unrelated.with_data_mut(|store| {
3664 let _ = store.remove(&RawDataStoreKey::from_persisted_bytes(vec![1]));
3665 });
3666 session
3667 .verify_read_set_revision_proof(&proof)
3668 .expect("a nonparticipating store mutation must not invalidate the proof");
3669
3670 let source = session
3671 .db
3672 .store_handle(STORE_PATH)
3673 .expect("participating source store should resolve");
3674 source
3675 .mark_index_building()
3676 .expect("source access-state transition should advance its revision");
3677 assert!(matches!(
3678 session.verify_read_set_revision_proof(&proof),
3679 Err(ExhaustiveReadError::Revision(
3680 ReadSetRevisionError::StoreAccessChanged { .. }
3681 )),
3682 ));
3683 }
3684
3685 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3686 #[expect(
3687 clippy::too_many_lines,
3688 reason = "one lifecycle test proves successful replay plus pre-page and post-page source invalidation without sharing progress state across tests"
3689 )]
3690 #[test]
3691 fn journaled_job_advance_is_idempotent_and_revision_checked_on_both_sides() {
3692 let session = initialize_journaled();
3693 let proof = session
3694 .capture_read_set_revision_proof(&[ENTITY_NAME])
3695 .expect("journaled source proof should capture");
3696 let job_id =
3697 ResumableJobId::try_from_bytes([71; 32]).expect("nonzero job identity should admit");
3698 session
3699 .start_resumable_job(job_id, proof, vec![0])
3700 .expect("journaled job should start outside its protected source revision");
3701 let request = ResumableJobAdvanceRequest::new(
3702 job_id,
3703 0,
3704 ResumableJobIdempotencyKey::new("page-0")
3705 .expect("bounded idempotency key should admit"),
3706 );
3707 let calls = Cell::new(0_u8);
3708 let receipt = session
3709 .compare_proof_and_advance(&request, |state| {
3710 calls.set(calls.get() + 1);
3711 assert_eq!(state.application_state, vec![0]);
3712 Ok::<_, ()>(
3713 ResumableJobAdvance::new(Some("cursor-1".to_string()), vec![1], vec![9])
3714 .expect("bounded application advance should admit"),
3715 )
3716 })
3717 .expect("unchanged source should advance exactly once");
3718 assert_eq!(calls.get(), 1);
3719 assert_eq!(receipt.status, ResumableJobAdvanceStatus::Advanced);
3720 assert_eq!(receipt.committed_sequence, 1);
3721
3722 let replay = session
3723 .compare_proof_and_advance::<()>(&request, |_| {
3724 panic!("lost-response replay must not execute application work")
3725 })
3726 .expect("same request identity should return its persisted receipt");
3727 assert_eq!(replay, receipt);
3728 let retained = session
3729 .resumable_job_state(job_id)
3730 .expect("advanced state should remain durable");
3731 assert_eq!(retained.sequence, 1);
3732 assert_eq!(retained.application_state, vec![1]);
3733
3734 let _ = insert_exact_key_fixture(&session, 51);
3735 let pre_change_request = ResumableJobAdvanceRequest::new(
3736 job_id,
3737 1,
3738 ResumableJobIdempotencyKey::new("page-1")
3739 .expect("bounded idempotency key should admit"),
3740 );
3741 let pre_change_calls = Cell::new(0_u8);
3742 let invalidated = session
3743 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
3744 pre_change_calls.set(pre_change_calls.get() + 1);
3745 unreachable!("pre-page proof failure must reject before application work")
3746 })
3747 .expect("source drift should persist one replayable invalidation receipt");
3748 assert_eq!(pre_change_calls.get(), 0);
3749 assert_eq!(invalidated.status, ResumableJobAdvanceStatus::Invalidated);
3750 let invalidated_state = session
3751 .resumable_job_state(job_id)
3752 .expect("invalidated job should remain inspectable");
3753 assert_eq!(invalidated_state.status, ResumableJobStatus::Invalidated);
3754 assert_eq!(invalidated_state.continuation, None);
3755 assert_eq!(invalidated_state.application_state, vec![1]);
3756 assert_eq!(
3757 session
3758 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
3759 panic!("invalidation replay must not execute application work")
3760 })
3761 .expect("lost invalidation reply should replay exactly"),
3762 invalidated,
3763 );
3764
3765 let post_proof = session
3766 .capture_read_set_revision_proof(&[ENTITY_NAME])
3767 .expect("post-change journaled proof should capture");
3768 let post_job_id = ResumableJobId::try_from_bytes([72; 32])
3769 .expect("nonzero post-change job identity should admit");
3770 session
3771 .start_resumable_job(post_job_id, post_proof, vec![7])
3772 .expect("post-change journaled job should start");
3773 let post_request = ResumableJobAdvanceRequest::new(
3774 post_job_id,
3775 0,
3776 ResumableJobIdempotencyKey::new("post-page-0")
3777 .expect("bounded idempotency key should admit"),
3778 );
3779 let post_receipt = session
3780 .compare_proof_and_advance::<()>(&post_request, |_| {
3781 let _ = insert_exact_key_fixture(&session, 52);
3782 Ok(ResumableJobAdvance::new(None, vec![8], vec![10])
3783 .expect("bounded post-change candidate should admit"))
3784 })
3785 .expect("post-page drift should discard the candidate and persist invalidation");
3786 assert_eq!(post_receipt.status, ResumableJobAdvanceStatus::Invalidated);
3787 let post_state = session
3788 .resumable_job_state(post_job_id)
3789 .expect("post-page invalidation should remain inspectable");
3790 assert_eq!(post_state.status, ResumableJobStatus::Invalidated);
3791 assert_eq!(post_state.application_state, vec![7]);
3792 session
3793 .acknowledge_resumable_job(post_job_id, post_state.sequence)
3794 .expect("terminal job acknowledgement should remove retained progress");
3795 session
3796 .acknowledge_resumable_job(post_job_id, post_state.sequence)
3797 .expect("lost acknowledgement reply should be safely replayable");
3798 assert_eq!(
3799 session.resumable_job_state(post_job_id),
3800 Err(ResumableJobError::NotFound),
3801 );
3802
3803 let completed_job_id = ResumableJobId::try_from_bytes([74; 32])
3804 .expect("nonzero completed job identity should admit");
3805 let completed_proof = session
3806 .capture_read_set_revision_proof(&[ENTITY_NAME])
3807 .expect("completed-job source proof should capture");
3808 session
3809 .start_resumable_job(completed_job_id, completed_proof, Vec::new())
3810 .expect("completed-job fixture should start");
3811 let completed_request = ResumableJobAdvanceRequest::new(
3812 completed_job_id,
3813 0,
3814 ResumableJobIdempotencyKey::new("complete")
3815 .expect("bounded completion key should admit"),
3816 );
3817 let completed_receipt = session
3818 .compare_proof_and_advance::<()>(&completed_request, |_| {
3819 Ok(ResumableJobAdvance::new(None, vec![99], vec![100])
3820 .expect("bounded terminal advance should admit"))
3821 })
3822 .expect("null continuation should commit terminal completion");
3823 let completed_state = session
3824 .resumable_job_state(completed_job_id)
3825 .expect("completed state should remain replayable before acknowledgement");
3826 assert_eq!(completed_state.status, ResumableJobStatus::Completed);
3827 assert_eq!(
3828 session
3829 .compare_proof_and_advance::<()>(&completed_request, |_| {
3830 panic!("completed request replay must not execute application work")
3831 })
3832 .expect("completed request should replay until acknowledgement"),
3833 completed_receipt,
3834 );
3835 let after_completion = ResumableJobAdvanceRequest::new(
3836 completed_job_id,
3837 1,
3838 ResumableJobIdempotencyKey::new("after-complete")
3839 .expect("bounded post-completion key should admit"),
3840 );
3841 assert!(matches!(
3842 session.compare_proof_and_advance::<()>(&after_completion, |_| {
3843 panic!("completed jobs cannot execute another page")
3844 }),
3845 Err(CompareProofAndAdvanceError::Protocol(
3846 ResumableJobError::Completed
3847 )),
3848 ));
3849 session
3850 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
3851 .expect("completed job should acknowledge and free capacity");
3852 session
3853 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
3854 .expect("completion acknowledgement should be idempotent");
3855
3856 let stale_job_id = ResumableJobId::try_from_bytes([73; 32])
3857 .expect("nonzero stale-sequence job identity should admit");
3858 let stale_proof = session
3859 .capture_read_set_revision_proof(&[ENTITY_NAME])
3860 .expect("stale-sequence source proof should capture");
3861 session
3862 .start_resumable_job(stale_job_id, stale_proof, Vec::new())
3863 .expect("stale-sequence job should start");
3864 let stale_request = ResumableJobAdvanceRequest::new(
3865 stale_job_id,
3866 4,
3867 ResumableJobIdempotencyKey::new("stale").expect("bounded idempotency key should admit"),
3868 );
3869 assert!(matches!(
3870 session.compare_proof_and_advance::<()>(&stale_request, |_| {
3871 panic!("stale sequence must reject before application work")
3872 }),
3873 Err(CompareProofAndAdvanceError::Protocol(
3874 ResumableJobError::StaleSequence {
3875 expected: 4,
3876 actual: 0,
3877 }
3878 )),
3879 ));
3880 assert_eq!(
3881 session.acknowledge_resumable_job(stale_job_id, 0),
3882 Err(ResumableJobError::NotTerminal),
3883 );
3884 }
3885
3886 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3887 #[test]
3888 fn exact_key_batch_uses_typed_hard_execution_budget() {
3889 let session = initialize();
3890 let binding = exact_key_binding(&session);
3891 let budget =
3892 HardExecutionBudget::uniform_for_tests(0, HardExecutionFailureHeadroom::new(500, 256));
3893 let error = session
3894 .execute_exact_key_batch_with_hard_budget_for_tests(&binding, &[u64::MAX], &budget)
3895 .expect_err("zero query budget should reject the exact-key route");
3896
3897 assert!(matches!(
3898 error.diagnostic().detail(),
3899 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3900 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
3901 })
3902 ));
3903 let facts = error.diagnostic_facts();
3904 assert_eq!(
3905 &facts[..5],
3906 &[
3907 (
3908 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
3909 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions.raw(),
3910 ),
3911 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 0),
3912 (icydb_diagnostic_code::DiagnosticFactTag::Actual, 1),
3913 (
3914 icydb_diagnostic_code::DiagnosticFactTag::ExecutionBudgetScope,
3915 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution.raw(),
3916 ),
3917 (
3918 icydb_diagnostic_code::DiagnosticFactTag::ExecutionLane,
3919 icydb_diagnostic_code::DiagnosticExecutionLane::PublicRead.raw(),
3920 ),
3921 ],
3922 );
3923 assert_eq!(
3924 facts[5].0,
3925 icydb_diagnostic_code::DiagnosticFactTag::QueryShapeFingerprintPrefix,
3926 );
3927 assert_ne!(facts[5].1, 0);
3928 }
3929
3930 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3931 fn assert_planned_query_exhausts(
3932 session: &DbSession<TestCanister>,
3933 query: &crate::db::DynamicQuery,
3934 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
3935 ) {
3936 let budget = HardExecutionBudget::uniform_for_tests(
3937 u64::MAX,
3938 HardExecutionFailureHeadroom::new(500, 256),
3939 )
3940 .with_limit_for_tests(resource, 0);
3941 let context = HardExecutionContext::new(
3942 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
3943 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
3944 0x7068_7973_6963_616c,
3945 );
3946 let error = with_query_execution_budget_for_tests(budget, context, || {
3947 session.execute_trusted_live_page(query, None)
3948 })
3949 .expect_err("the injected zero resource allowance should reject planned execution");
3950
3951 assert!(matches!(
3952 error.diagnostic().detail(),
3953 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3954 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
3955 })
3956 ));
3957 assert_eq!(
3958 error.diagnostic_facts()[0],
3959 (
3960 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
3961 resource.raw(),
3962 ),
3963 );
3964 }
3965
3966 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3967 fn assert_grouped_query_exhausts(
3968 session: &DbSession<TestCanister>,
3969 query: &crate::db::DynamicQuery,
3970 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
3971 ) {
3972 let budget = HardExecutionBudget::uniform_for_tests(
3973 u64::MAX,
3974 HardExecutionFailureHeadroom::new(500, 256),
3975 )
3976 .with_limit_for_tests(resource, 0);
3977 let context = HardExecutionContext::new(
3978 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
3979 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
3980 0x6772_6f75_7065_642d,
3981 );
3982 let error = with_query_execution_budget_for_tests(budget, context, || {
3983 session.execute_trusted_dynamic_grouped_query(query)
3984 })
3985 .expect_err("the injected zero resource allowance should reject grouped execution");
3986
3987 assert!(matches!(
3988 error.diagnostic().detail(),
3989 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3990 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
3991 })
3992 ));
3993 assert_eq!(
3994 error.diagnostic_facts()[0],
3995 (
3996 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
3997 resource.raw(),
3998 ),
3999 );
4000 }
4001
4002 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4003 fn assert_sql_query_exhausts(
4004 session: &DbSession<TestCanister>,
4005 sql: &str,
4006 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
4007 ) {
4008 let budget = HardExecutionBudget::uniform_for_tests(
4009 u64::MAX,
4010 HardExecutionFailureHeadroom::new(500, 256),
4011 )
4012 .with_limit_for_tests(resource, 0);
4013 let context = HardExecutionContext::new(
4014 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
4015 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
4016 0x7371_6c2d_736f_7274,
4017 );
4018 let error = with_query_execution_budget_for_tests(budget, context, || {
4019 session.execute_trusted_sql_query(sql)
4020 })
4021 .expect_err("the injected zero resource allowance should reject SQL execution");
4022
4023 assert!(matches!(
4024 error.diagnostic().detail(),
4025 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4026 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
4027 })
4028 ));
4029 assert_eq!(
4030 error.diagnostic_facts()[0],
4031 (
4032 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
4033 resource.raw(),
4034 ),
4035 );
4036 }
4037
4038 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4039 #[test]
4040 fn planned_read_routes_share_physical_resource_accounting() {
4041 let session = initialize();
4042 let first = insert_exact_key_fixture(&session, 41);
4043 insert_exact_key_fixture(&session, 42);
4044
4045 let fallback = crate::db::DynamicQuery::new(ENTITY_NAME)
4046 .filter(crate::db::FieldRef::new("id").eq(first))
4047 .select(["id", "payload"])
4048 .order_by(crate::db::asc("id"))
4049 .limit(1);
4050 assert_eq!(
4051 session
4052 .execute_trusted_live_page(&fallback, None)
4053 .expect("bounded fallback execution should preserve its result")
4054 .row_count,
4055 1,
4056 );
4057 assert_planned_query_exhausts(
4058 &session,
4059 &fallback,
4060 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::RowsVisited,
4061 );
4062
4063 let covering = crate::db::DynamicQuery::new(ENTITY_NAME)
4064 .filter(crate::db::FieldRef::new("payload").eq(41_u64))
4065 .select(["payload"])
4066 .order_by(crate::db::asc("payload"))
4067 .limit(1);
4068 assert_eq!(
4069 session
4070 .execute_trusted_live_page(&covering, None)
4071 .expect("bounded covering execution should preserve its result")
4072 .row_count,
4073 1,
4074 );
4075 assert_planned_query_exhausts(
4076 &session,
4077 &covering,
4078 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
4079 );
4080
4081 let residual = crate::db::DynamicQuery::new(ENTITY_NAME)
4082 .filter(crate::db::FieldRef::new("payload").eq_field("id"))
4083 .select(["id"])
4084 .order_by(crate::db::asc("id"))
4085 .limit(1);
4086 assert_eq!(
4087 session
4088 .execute_trusted_live_page(&residual, None)
4089 .expect("bounded residual execution should preserve its result")
4090 .row_count,
4091 0,
4092 );
4093 assert_planned_query_exhausts(
4094 &session,
4095 &residual,
4096 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::PredicateExpressionSteps,
4097 );
4098
4099 assert_planned_query_exhausts(
4100 &session,
4101 &fallback,
4102 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::ResultBytes,
4103 );
4104
4105 let grouped = crate::db::DynamicQuery::new(ENTITY_NAME)
4106 .group_by("payload")
4107 .aggregate(crate::db::count())
4108 .order_by(crate::db::asc("payload"))
4109 .grouped_limits(10, 16 * 1_024)
4110 .limit(1);
4111 let grouped_result = session
4112 .execute_trusted_dynamic_grouped_query(&grouped)
4113 .expect("bounded grouped execution should preserve its result");
4114 assert_eq!(grouped_result.row_count, 1);
4115 assert!(grouped_result.next_cursor.is_some());
4116 assert_grouped_query_exhausts(
4117 &session,
4118 &grouped,
4119 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::GroupDistinctEntries,
4120 );
4121 assert_grouped_query_exhausts(
4122 &session,
4123 &grouped,
4124 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::CursorSteps,
4125 );
4126
4127 assert_sql_query_exhausts(
4128 &session,
4129 "SELECT payload, COUNT(*) AS row_count FROM IdentityRow \
4130 GROUP BY payload ORDER BY row_count DESC, payload ASC LIMIT 1",
4131 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::SortEntries,
4132 );
4133 }
4134
4135 fn assert_dynamic_payload<C: CanisterKind>(
4136 session: &DbSession<C>,
4137 key: u64,
4138 expected_payload: u64,
4139 ) {
4140 let unchanged = session
4141 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
4142 entity: ENTITY_NAME.to_string(),
4143 key: InputValue::Nat64(key),
4144 patch: dynamic_payload_patch(expected_payload),
4145 })
4146 .expect("the expected row should remain readable through a no-op update");
4147 assert_eq!(unchanged.affected_rows, 0);
4148 assert_eq!(
4149 unchanged.rows,
4150 vec![expected_dynamic_row(key, expected_payload)],
4151 );
4152 }
4153
4154 fn batch(values: &[u64]) -> Vec<AcceptedStructuralMutation> {
4155 values
4156 .iter()
4157 .map(|value| {
4158 AcceptedStructuralMutation::save(
4159 MutationMode::Insert,
4160 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
4161 payload_patch(*value),
4162 )
4163 })
4164 .collect()
4165 }
4166
4167 fn atomic_progress_fixture(
4168 identity_byte: u8,
4169 ) -> (
4170 MutationJobRecord,
4171 MutationJobRecord,
4172 MutationProgressRecordOp,
4173 ) {
4174 let job_id = MutationJobId::try_from_bytes([identity_byte; 32])
4175 .expect("nonzero atomic progress job id should admit");
4176 let before = MutationJobRecord::new(job_id, vec![1, identity_byte], vec![2])
4177 .expect("atomic progress predecessor should admit");
4178 let request = MutationJobAdvanceRequest::new(
4179 job_id,
4180 0,
4181 MutationJobIdempotencyKey::new(format!("atomic-{identity_byte}"))
4182 .expect("atomic progress replay key should admit"),
4183 );
4184 let (after, _) = before
4185 .apply_transition(
4186 &request,
4187 MutationJobTransition::new(
4188 MutationJobStatus::Active,
4189 MutationJobPhase::Forward,
4190 vec![3],
4191 1,
4192 1,
4193 0,
4194 ),
4195 )
4196 .expect("atomic progress successor should admit");
4197 let operation = MutationProgressRecordOp::replace(&before, &after)
4198 .expect("atomic progress replacement should admit");
4199 (before, after, operation)
4200 }
4201
4202 fn assert_identity_boundary(error: &InternalError) {
4203 assert_eq!(error.class(), ErrorClass::Unsupported);
4204 assert_eq!(error.origin(), ErrorOrigin::Identity);
4205 }
4206
4207 #[test]
4208 fn generated_candidate_collision_is_identity_corruption_before_generic_uniqueness() {
4209 let generated = insert_key_exists_after_generation(true);
4210 assert_eq!(generated.class(), ErrorClass::Corruption);
4211 assert_eq!(generated.origin(), ErrorOrigin::Identity);
4212
4213 let ordinary = insert_key_exists_after_generation(false);
4214 assert_ne!(ordinary.origin(), ErrorOrigin::Identity);
4215 }
4216
4217 #[cfg(target_pointer_width = "64")]
4218 #[test]
4219 fn pre_key_candidate_count_rejects_values_beyond_the_persisted_u32_bound() {
4220 let error = checked_pre_key_candidate_count(
4221 usize::try_from(u64::from(u32::MAX) + 1).expect("64-bit usize should hold u32 + 1"),
4222 )
4223 .expect_err("candidate counts beyond u32 must reject");
4224 assert_identity_boundary(&error);
4225 }
4226
4227 #[test]
4228 #[expect(
4229 clippy::too_many_lines,
4230 reason = "one holding lifecycle proves split, merge, transfer, late-failure neutrality, result order, and Identity state"
4231 )]
4232 fn mixed_structural_batch_preserves_holding_conservation_and_failure_atomicity() {
4233 let session = initialize();
4234 let seeded = session
4235 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
4236 .expect("seed rows should commit");
4237 assert_eq!(seeded.affected_rows, 1);
4238
4239 let split = session
4240 .execute_trusted_dynamic_mutation_batch(vec![
4241 DynamicMutation::Update {
4242 entity: ENTITY_NAME.to_string(),
4243 key: InputValue::Nat64(1),
4244 patch: dynamic_payload_patch(60),
4245 },
4246 DynamicMutation::Insert {
4247 entity: ENTITY_NAME.to_string(),
4248 patch: dynamic_payload_patch(40),
4249 },
4250 ])
4251 .expect("one holding should split atomically");
4252 assert_eq!(split.affected_rows, 2);
4253 assert_eq!(
4254 split.rows,
4255 vec![expected_dynamic_row(1, 60), expected_dynamic_row(2, 40),],
4256 "split after-images must retain input order and exact quantity",
4257 );
4258
4259 let rejected_split = session
4260 .execute_trusted_dynamic_mutation_batch(vec![
4261 DynamicMutation::Update {
4262 entity: ENTITY_NAME.to_string(),
4263 key: InputValue::Nat64(1),
4264 patch: dynamic_payload_patch(50),
4265 },
4266 DynamicMutation::Insert {
4267 entity: ENTITY_NAME.to_string(),
4268 patch: DynamicStructuralPatch::new(Vec::new()),
4269 },
4270 ])
4271 .expect_err("an invalid split output must reject the staged source update");
4272 assert_eq!(rejected_split.class(), ErrorClass::Unsupported);
4273 assert_eq!(rejected_split.origin(), ErrorOrigin::Executor);
4274 assert_eq!(
4275 rejected_split.diagnostic_facts(),
4276 vec![
4277 (
4278 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4279 ENTITY_TAG.value(),
4280 ),
4281 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 2),
4282 (
4283 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4284 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
4285 ),
4286 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1,),
4287 ],
4288 );
4289 assert_dynamic_payload(&session, 1, 60);
4290 assert_dynamic_payload(&session, 2, 40);
4291
4292 let transfer = session
4293 .execute_trusted_dynamic_mutation_batch(vec![
4294 DynamicMutation::Update {
4295 entity: ENTITY_NAME.to_string(),
4296 key: InputValue::Nat64(1),
4297 patch: dynamic_payload_patch(70),
4298 },
4299 DynamicMutation::Update {
4300 entity: ENTITY_NAME.to_string(),
4301 key: InputValue::Nat64(2),
4302 patch: dynamic_payload_patch(30),
4303 },
4304 ])
4305 .expect("distinct transfer patches should share one atomic batch");
4306 assert_eq!(
4307 transfer.rows,
4308 vec![expected_dynamic_row(1, 70), expected_dynamic_row(2, 30),],
4309 "the transfer must preserve the exact total quantity",
4310 );
4311
4312 let merge = session
4313 .execute_trusted_dynamic_mutation_batch(vec![
4314 DynamicMutation::Delete {
4315 entity: ENTITY_NAME.to_string(),
4316 key: InputValue::Nat64(2),
4317 },
4318 DynamicMutation::Update {
4319 entity: ENTITY_NAME.to_string(),
4320 key: InputValue::Nat64(1),
4321 patch: dynamic_payload_patch(100),
4322 },
4323 ])
4324 .expect("two holdings should merge atomically");
4325 assert_eq!(
4326 merge.rows,
4327 vec![expected_dynamic_row(2, 30), expected_dynamic_row(1, 100),],
4328 "delete before-images and update after-images must retain input order",
4329 );
4330
4331 let resplit = session
4332 .execute_trusted_dynamic_mutation_batch(vec![
4333 DynamicMutation::Update {
4334 entity: ENTITY_NAME.to_string(),
4335 key: InputValue::Nat64(1),
4336 patch: dynamic_payload_patch(60),
4337 },
4338 DynamicMutation::Insert {
4339 entity: ENTITY_NAME.to_string(),
4340 patch: dynamic_payload_patch(40),
4341 },
4342 ])
4343 .expect("the merged holding should split again");
4344 assert_eq!(
4345 resplit.rows,
4346 vec![expected_dynamic_row(1, 60), expected_dynamic_row(3, 40),],
4347 );
4348
4349 let rejected_merge = session
4350 .execute_trusted_dynamic_mutation_batch(vec![
4351 DynamicMutation::Delete {
4352 entity: ENTITY_NAME.to_string(),
4353 key: InputValue::Nat64(3),
4354 },
4355 DynamicMutation::Update {
4356 entity: ENTITY_NAME.to_string(),
4357 key: InputValue::Nat64(99),
4358 patch: dynamic_payload_patch(100),
4359 },
4360 ])
4361 .expect_err("a late missing merge target must preserve the earlier staged delete");
4362 assert_eq!(rejected_merge.class(), ErrorClass::NotFound);
4363 assert_dynamic_payload(&session, 1, 60);
4364 assert_dynamic_payload(&session, 3, 40);
4365
4366 SCHEMA_STORE.with(|store| {
4367 let cursor = store
4368 .borrow()
4369 .identity_statement_cursor(
4370 database_incarnation_id().expect("database incarnation should remain readable"),
4371 ENTITY_TAG,
4372 FieldId::new(1),
4373 &AcceptedFieldKind::Nat64,
4374 )
4375 .expect("mixed Identity state should remain readable");
4376 assert_eq!(cursor.expected_high_water(), 3);
4377 assert!(!cursor.has_allocations());
4378 });
4379 }
4380
4381 #[test]
4382 fn mixed_structural_batch_rejects_duplicate_holding_targets_without_mutation() {
4383 let session = initialize();
4384 session
4385 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
4386 .expect("the holding fixture should initialize");
4387
4388 let duplicate = session
4389 .execute_trusted_dynamic_mutation_batch(vec![
4390 DynamicMutation::Update {
4391 entity: ENTITY_NAME.to_string(),
4392 key: InputValue::Nat64(1),
4393 patch: dynamic_payload_patch(60),
4394 },
4395 DynamicMutation::Delete {
4396 entity: ENTITY_NAME.to_string(),
4397 key: InputValue::Nat64(1),
4398 },
4399 ])
4400 .expect_err("duplicate targets across operation kinds must reject");
4401 assert!(matches!(
4402 duplicate.diagnostic().detail(),
4403 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4404 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchDuplicateKey,
4405 }),
4406 ));
4407 assert_eq!(
4408 duplicate.diagnostic_facts(),
4409 vec![
4410 (
4411 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4412 ENTITY_TAG.value(),
4413 ),
4414 (
4415 icydb_diagnostic_code::DiagnosticFactTag::FirstBatchPosition,
4416 0,
4417 ),
4418 (
4419 icydb_diagnostic_code::DiagnosticFactTag::DuplicateBatchPosition,
4420 1,
4421 ),
4422 ],
4423 );
4424 assert_dynamic_payload(&session, 1, 100);
4425 }
4426
4427 #[test]
4428 fn mixed_structural_batch_rejects_empty_and_over_bound_before_resolution() {
4429 let session = initialize();
4430 let empty = session
4431 .execute_trusted_dynamic_mutation_batch(Vec::new())
4432 .expect_err("an empty public batch must reject");
4433 assert!(matches!(
4434 empty.diagnostic().detail(),
4435 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4436 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchEmpty,
4437 }),
4438 ));
4439 assert_eq!(
4440 empty.diagnostic_facts(),
4441 vec![(icydb_diagnostic_code::DiagnosticFactTag::ActualCount, 0,)],
4442 );
4443
4444 let requests = (0..=MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS)
4445 .map(|_| DynamicMutation::Delete {
4446 entity: ENTITY_NAME.to_string(),
4447 key: InputValue::Nat64(1),
4448 })
4449 .collect();
4450 let over_bound = session
4451 .execute_trusted_dynamic_mutation_batch(requests)
4452 .expect_err("operation cap plus one must reject before row resolution");
4453 assert!(matches!(
4454 over_bound.diagnostic().detail(),
4455 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4456 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchTooManyItems,
4457 }),
4458 ));
4459 assert_eq!(
4460 over_bound.diagnostic_facts(),
4461 vec![
4462 (
4463 icydb_diagnostic_code::DiagnosticFactTag::ActualCount,
4464 (MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS + 1) as u64,
4465 ),
4466 (
4467 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4468 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS as u64,
4469 ),
4470 ],
4471 );
4472 }
4473
4474 #[test]
4475 fn mixed_structural_batch_staged_byte_bound_uses_checked_exact_boundary() {
4476 let mut exact = 0;
4477 add_structural_mutation_staged_bytes(
4478 &mut exact,
4479 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES],
4480 )
4481 .expect("the exact staged-byte boundary should admit");
4482 assert_eq!(exact, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
4483
4484 let error = add_structural_mutation_staged_bytes(&mut exact, [1])
4485 .expect_err("one byte above the staged-byte boundary must reject");
4486 assert!(matches!(
4487 error.diagnostic().detail(),
4488 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4489 boundary:
4490 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchStagedBytesExceeded,
4491 }),
4492 ));
4493 assert_eq!(
4494 error.diagnostic_facts(),
4495 vec![
4496 (
4497 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
4498 (MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES + 1) as u64,
4499 ),
4500 (
4501 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4502 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES as u64,
4503 ),
4504 ],
4505 );
4506
4507 validate_structural_mutation_result_bytes(MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES)
4508 .expect("the exact result-byte boundary should admit");
4509 let error = validate_structural_mutation_result_bytes(
4510 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1,
4511 )
4512 .expect_err("one byte above the result-byte boundary must reject");
4513 assert!(matches!(
4514 error.diagnostic().detail(),
4515 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4516 boundary:
4517 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchResultBytesExceeded,
4518 }),
4519 ));
4520 assert_eq!(
4521 error.diagnostic_facts(),
4522 vec![
4523 (
4524 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
4525 (MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1) as u64,
4526 ),
4527 (
4528 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4529 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES as u64,
4530 ),
4531 ],
4532 );
4533 }
4534
4535 #[expect(
4536 clippy::too_many_lines,
4537 reason = "one lifecycle proves shared materialization and every maintained frontend against the same zero-state owner"
4538 )]
4539 #[test]
4540 fn identity_insert_frontends_share_one_committed_range_without_rejected_consumption() {
4541 let session = initialize();
4542 let catalog = session
4543 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
4544 .expect("identity catalog should resolve");
4545 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
4546 .expect("identity row layout should build");
4547 let initial_description = session
4548 .try_describe_entity_by_name(ENTITY_NAME)
4549 .expect("accepted Identity description should resolve");
4550 assert_eq!(
4551 initial_description.entity_tag(),
4552 catalog.identity().entity_tag().value()
4553 );
4554 assert_eq!(
4555 initial_description.accepted_schema_fingerprint_method(),
4556 catalog.fingerprint_method_version()
4557 );
4558 assert_eq!(
4559 initial_description.accepted_schema_fingerprint(),
4560 catalog.fingerprint()
4561 );
4562 let initial_identity = initial_description
4563 .identity()
4564 .expect("accepted Identity policy should be described");
4565 assert_eq!(initial_identity.field(), "id");
4566 assert_eq!(initial_identity.generator(), "Identity::next");
4567 assert_eq!(initial_identity.accepted_kind(), "nat64");
4568 assert_eq!(initial_identity.minimum(), 1);
4569 assert_eq!(initial_identity.maximum(), u128::from(u64::MAX));
4570 assert_eq!(initial_identity.high_water(), 0);
4571 assert_eq!(initial_identity.remaining(), u128::from(u64::MAX));
4572 assert!(!initial_identity.exhausted());
4573
4574 let rejected = session
4575 .execute_accepted_structural_save_batch(
4576 &catalog,
4577 &descriptor,
4578 batch(&[1_000, 2_000]),
4579 Timestamp::from_millis(6),
4580 |_| Err::<(), _>(InternalError::executor_unsupported()),
4581 )
4582 .expect_err("a rejected precommit result must not publish its tentative range");
4583 assert_eq!(rejected.class(), ErrorClass::Unsupported);
4584 assert_eq!(DATA_STORE.with(|store| store.borrow().len()), 0);
4585
4586 let rows = session
4587 .execute_accepted_structural_save_batch(
4588 &catalog,
4589 &descriptor,
4590 batch(&[10, 20, 30]),
4591 Timestamp::from_millis(7),
4592 Ok,
4593 )
4594 .expect("one accepted batch should commit rows and one identity range");
4595 assert_eq!(
4596 rows.into_iter().map(|row| row.values).collect::<Vec<_>>(),
4597 vec![
4598 vec![Value::Nat64(1), Value::Nat64(10)],
4599 vec![Value::Nat64(2), Value::Nat64(20)],
4600 vec![Value::Nat64(3), Value::Nat64(30)],
4601 ],
4602 );
4603
4604 let dynamic = session
4605 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4606 entity: ENTITY_NAME.to_string(),
4607 patch: DynamicStructuralPatch::new(vec![(
4608 "payload".to_string(),
4609 DynamicWriteCell::Value(InputValue::Nat64(40)),
4610 )]),
4611 })
4612 .expect("dynamic omission should commit through shared Identity generation");
4613 assert_eq!(dynamic.affected_rows, 1);
4614
4615 for (request, operation) in [
4616 (
4617 DynamicMutation::Insert {
4618 entity: ENTITY_NAME.to_string(),
4619 patch: DynamicStructuralPatch::new(vec![
4620 (
4621 "id".to_string(),
4622 DynamicWriteCell::Value(InputValue::Nat64(41)),
4623 ),
4624 (
4625 "payload".to_string(),
4626 DynamicWriteCell::Value(InputValue::Nat64(42)),
4627 ),
4628 ]),
4629 },
4630 icydb_diagnostic_code::DiagnosticMutationOperation::Insert,
4631 ),
4632 (
4633 DynamicMutation::Update {
4634 entity: ENTITY_NAME.to_string(),
4635 key: InputValue::Nat64(1),
4636 patch: DynamicStructuralPatch::new(vec![(
4637 "id".to_string(),
4638 DynamicWriteCell::Default,
4639 )]),
4640 },
4641 icydb_diagnostic_code::DiagnosticMutationOperation::Update,
4642 ),
4643 ] {
4644 let error = session
4645 .execute_trusted_dynamic_mutation(&request)
4646 .expect_err("structural Identity authorship and regeneration must reject");
4647 assert_eq!(error.class(), ErrorClass::Unsupported);
4648 assert_eq!(error.origin(), ErrorOrigin::Executor);
4649 assert_eq!(
4650 error.diagnostic_facts(),
4651 vec![
4652 (
4653 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4654 ENTITY_TAG.value(),
4655 ),
4656 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
4657 (
4658 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4659 operation.raw(),
4660 ),
4661 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,),
4662 ],
4663 );
4664 }
4665
4666 let binding = session
4667 .issue_typed_entity_binding(
4668 ENTITY_SOURCE,
4669 &[
4670 DynamicTypedFieldBindingRequest::new(
4671 ID_SOURCE.to_string(),
4672 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
4673 false,
4674 ),
4675 DynamicTypedFieldBindingRequest::new(
4676 PAYLOAD_SOURCE.to_string(),
4677 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
4678 false,
4679 ),
4680 ],
4681 )
4682 .expect("typed output should bind the Identity field");
4683 let typed_patch = binding
4684 .bind_write_fields(vec![(
4685 PAYLOAD_SOURCE.to_string(),
4686 DynamicWriteCell::Value(InputValue::Nat64(50)),
4687 )])
4688 .expect("typed payload should lower");
4689 let typed = session
4690 .execute_trusted_typed_mutation(
4691 &binding,
4692 &DynamicTypedMutation::Insert { patch: typed_patch },
4693 )
4694 .expect("typed omission should commit through shared Identity generation");
4695 assert_eq!(
4696 typed
4697 .expect("typed insert should return one mutation result")
4698 .affected_rows,
4699 1,
4700 );
4701 let explicit_typed_patch = binding
4702 .bind_write_fields(vec![
4703 (
4704 ID_SOURCE.to_string(),
4705 DynamicWriteCell::Value(InputValue::Nat64(51)),
4706 ),
4707 (
4708 PAYLOAD_SOURCE.to_string(),
4709 DynamicWriteCell::Value(InputValue::Nat64(52)),
4710 ),
4711 ])
4712 .expect("the low-level binding should retain exact authored intent");
4713 let explicit_typed_error = session
4714 .execute_trusted_typed_mutation(
4715 &binding,
4716 &DynamicTypedMutation::Insert {
4717 patch: explicit_typed_patch,
4718 },
4719 )
4720 .expect_err("typed Identity authorship must reject before allocation");
4721 assert_eq!(explicit_typed_error.class(), ErrorClass::Unsupported);
4722 assert_eq!(explicit_typed_error.origin(), ErrorOrigin::Executor);
4723 assert_eq!(
4724 explicit_typed_error.diagnostic_facts(),
4725 vec![
4726 (
4727 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4728 ENTITY_TAG.value(),
4729 ),
4730 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
4731 (
4732 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4733 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
4734 ),
4735 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,),
4736 ],
4737 );
4738
4739 let replace_error = session
4740 .execute_trusted_dynamic_mutation(&DynamicMutation::Replace {
4741 entity: ENTITY_NAME.to_string(),
4742 key: InputValue::Nat64(99),
4743 patch: DynamicStructuralPatch::new(vec![(
4744 "payload".to_string(),
4745 DynamicWriteCell::Value(InputValue::Nat64(60)),
4746 )]),
4747 })
4748 .expect_err("save-as-insert with a chosen Identity must reject");
4749 assert_eq!(replace_error.class(), ErrorClass::Unsupported);
4750 assert_eq!(replace_error.origin(), ErrorOrigin::Executor);
4751
4752 #[cfg(feature = "sql")]
4753 {
4754 for sql in [
4755 "INSERT INTO IdentityRow (payload) VALUES (70) RETURNING id, payload",
4756 "INSERT INTO IdentityRow (id, payload) VALUES (DEFAULT, 80) RETURNING id",
4757 ] {
4758 let _result = session
4759 .execute_trusted_sql_mutation(sql)
4760 .expect("SQL omission and DEFAULT should commit Identity generation");
4761 }
4762
4763 let error = session
4764 .execute_trusted_sql_mutation(
4765 "INSERT INTO IdentityRow (id, payload) VALUES (42, 90)",
4766 )
4767 .expect_err("an explicit SQL Identity value must reject before allocation");
4768 let diagnostic = error.diagnostic();
4769 assert_eq!(
4770 diagnostic.code(),
4771 icydb_diagnostic_code::DiagnosticCode::QuerySqlWriteBoundary,
4772 );
4773 assert!(matches!(
4774 diagnostic.detail(),
4775 Some(icydb_diagnostic_code::DiagnosticDetail::SqlWriteBoundary {
4776 boundary: icydb_diagnostic_code::SqlWriteBoundaryCode::ExplicitGeneratedField,
4777 }),
4778 ));
4779 }
4780
4781 let expected_committed = if cfg!(feature = "sql") { 7 } else { 5 };
4782 assert_eq!(
4783 DATA_STORE.with(|store| store.borrow().len()),
4784 expected_committed
4785 );
4786 SCHEMA_STORE.with(|store| {
4787 let cursor = store
4788 .borrow()
4789 .identity_statement_cursor(
4790 database_incarnation_id().expect("database incarnation should remain readable"),
4791 ENTITY_TAG,
4792 FieldId::new(1),
4793 &AcceptedFieldKind::Nat64,
4794 )
4795 .expect("committed writes must leave active state readable");
4796 assert_eq!(cursor.expected_high_water(), u128::from(expected_committed),);
4797 assert!(!cursor.has_allocations());
4798 });
4799 let committed_description = session
4800 .try_describe_entity_by_name(ENTITY_NAME)
4801 .expect("committed Identity description should resolve");
4802 let committed_identity = committed_description
4803 .identity()
4804 .expect("accepted Identity policy should remain described");
4805 assert_eq!(
4806 committed_identity.high_water(),
4807 u128::from(expected_committed),
4808 );
4809 assert_eq!(
4810 committed_identity.remaining(),
4811 u128::from(u64::MAX - expected_committed),
4812 );
4813 assert!(!committed_identity.exhausted());
4814 }
4815
4816 #[test]
4817 fn mutation_progress_and_target_rows_recover_as_one_marker_transition() {
4818 let session = initialize_journaled();
4819 let catalog = session
4820 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
4821 .expect("journaled atomic-progress catalog should resolve");
4822 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
4823 .expect("journaled atomic-progress row layout should build");
4824
4825 for (ordinal, interruption) in [
4826 MutationCommitInterruption::MarkerPersisted,
4827 MutationCommitInterruption::JournalPublished,
4828 MutationCommitInterruption::RowsPublished,
4829 MutationCommitInterruption::ProgressReplaced,
4830 ]
4831 .into_iter()
4832 .enumerate()
4833 {
4834 let identity_byte = 31 + u8::try_from(ordinal).expect("small ordinal should fit");
4835 let (before, after, operation) = atomic_progress_fixture(identity_byte);
4836 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4837 match store.insert_mutation(&before)? {
4838 InsertMutationJobResult::Inserted => Ok(()),
4839 InsertMutationJobResult::Occupied(_) => {
4840 Err(crate::db::MutationJobError::IdentityConflict)
4841 }
4842 }
4843 })
4844 .expect("atomic predecessor should insert once");
4845
4846 interrupt_next_mutation_commit_for_tests(interruption);
4847 let interrupted = session.execute_accepted_structural_update_with_mutation_progress(
4848 &catalog,
4849 &descriptor,
4850 batch(&[700 + u64::try_from(ordinal).expect("small ordinal should fit")]),
4851 Timestamp::from_millis(17),
4852 operation,
4853 );
4854 assert!(
4855 interrupted.is_err(),
4856 "selected atomic boundary should interrupt"
4857 );
4858
4859 forget_recovered_domain_for_tests(&session.db)
4860 .expect("interruption should reset volatile recovery ownership");
4861 session
4862 .db
4863 .ensure_recovered_state()
4864 .expect("marker recovery should finish target and progress together");
4865 let retained = with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4866 store.load_mutation(before.state().job_id)
4867 })
4868 .expect("recovered successor should load");
4869 assert_eq!(retained, after);
4870 assert_eq!(
4871 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
4872 u64::try_from(ordinal + 1).expect("small row count should fit"),
4873 );
4874 }
4875
4876 let (before, after, operation) = atomic_progress_fixture(39);
4877 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4878 match store.insert_mutation(&before)? {
4879 InsertMutationJobResult::Inserted => Ok(()),
4880 InsertMutationJobResult::Occupied(_) => {
4881 Err(crate::db::MutationJobError::IdentityConflict)
4882 }
4883 }
4884 })
4885 .expect("final predecessor should insert once");
4886 session
4887 .execute_accepted_structural_update_with_mutation_progress(
4888 &catalog,
4889 &descriptor,
4890 batch(&[799]),
4891 Timestamp::from_millis(18),
4892 operation,
4893 )
4894 .expect("uninterrupted atomic transition should clear its marker");
4895 let retained = with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4896 store.load_mutation(before.state().job_id)
4897 })
4898 .expect("final successor should load");
4899 assert_eq!(retained, after);
4900 forget_recovered_domain_for_tests(&session.db)
4901 .expect("post-clear recovery ownership should reset");
4902 session
4903 .db
4904 .ensure_recovered_state()
4905 .expect("post-clear recovery should remain a no-op");
4906 }
4907
4908 #[test]
4909 fn mutation_progress_neither_side_mismatch_blocks_recovery() {
4910 let session = initialize_journaled();
4911 let catalog = session
4912 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
4913 .expect("journaled corruption catalog should resolve");
4914 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
4915 .expect("journaled corruption row layout should build");
4916 let (before, _after, operation) = atomic_progress_fixture(41);
4917 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4918 match store.insert_mutation(&before)? {
4919 InsertMutationJobResult::Inserted => Ok(()),
4920 InsertMutationJobResult::Occupied(_) => {
4921 Err(crate::db::MutationJobError::IdentityConflict)
4922 }
4923 }
4924 })
4925 .expect("corruption predecessor should insert once");
4926
4927 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::MarkerPersisted);
4928 assert!(
4929 session
4930 .execute_accepted_structural_update_with_mutation_progress(
4931 &catalog,
4932 &descriptor,
4933 batch(&[811]),
4934 Timestamp::from_millis(19),
4935 operation,
4936 )
4937 .is_err(),
4938 "marker interruption should retain recovery authority",
4939 );
4940 let (unexpected, _) = before
4941 .apply_transition(
4942 &MutationJobAdvanceRequest::new(
4943 before.state().job_id,
4944 0,
4945 MutationJobIdempotencyKey::new("unexpected-third-state")
4946 .expect("unexpected replay key should admit"),
4947 ),
4948 MutationJobTransition::new(
4949 MutationJobStatus::Active,
4950 MutationJobPhase::Forward,
4951 vec![99],
4952 2,
4953 0,
4954 0,
4955 ),
4956 )
4957 .expect("unexpected but valid progress state should admit");
4958 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4959 store.replace_mutation(&unexpected)
4960 })
4961 .expect("test should install the neither-side state");
4962
4963 forget_recovered_domain_for_tests(&session.db)
4964 .expect("corrupt recovery ownership should reset");
4965 let error = session
4966 .db
4967 .ensure_recovered_state()
4968 .expect_err("neither-side progress must block recovery");
4969 assert_eq!(error.class(), ErrorClass::Corruption);
4970 assert_eq!(error.origin(), ErrorOrigin::Recovery);
4971 assert_eq!(
4972 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4973 store.load_mutation(before.state().job_id)
4974 })
4975 .expect("unexpected state should remain inspectable to the test"),
4976 unexpected,
4977 );
4978 assert!(
4979 session.db.ensure_recovered_state().is_err(),
4980 "a retained corrupt marker must continue blocking database access",
4981 );
4982 }
4983
4984 #[test]
4985 #[expect(
4986 clippy::too_many_lines,
4987 reason = "one ordered scenario exercises every durable interruption boundary, guarded recovery, derived rebuild, and both integrity tiers"
4988 )]
4989 fn journaled_identity_recovery_quiesces_every_publication_interruption_before_reallocation() {
4990 let session = initialize_journaled();
4991 let catalog = session
4992 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
4993 .expect("journaled identity catalog should resolve");
4994 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
4995 .expect("journaled identity row layout should build");
4996
4997 for (ordinal, interruption) in [
4998 MutationCommitInterruption::MarkerPersisted,
4999 MutationCommitInterruption::JournalPublished,
5000 MutationCommitInterruption::RowsPublished,
5001 MutationCommitInterruption::StateMaterialized,
5002 ]
5003 .into_iter()
5004 .enumerate()
5005 {
5006 interrupt_next_mutation_commit_for_tests(interruption);
5007 let interrupted = session.execute_accepted_structural_save_batch(
5008 &catalog,
5009 &descriptor,
5010 batch(&[u64::try_from(ordinal).expect("ordinal should fit")]),
5011 Timestamp::from_millis(8),
5012 Ok,
5013 );
5014 assert!(
5015 interrupted.is_err(),
5016 "the selected durable boundary should interrupt",
5017 );
5018
5019 let committed = session
5020 .execute_accepted_structural_save_batch(
5021 &catalog,
5022 &descriptor,
5023 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
5024 Timestamp::from_millis(9),
5025 Ok,
5026 )
5027 .expect("the next mutation must recover before allocating");
5028 let expected_high_water =
5029 u64::try_from((ordinal + 1) * 2).expect("small test high-water should fit");
5030 assert_eq!(
5031 committed
5032 .into_iter()
5033 .map(|row| row.values)
5034 .collect::<Vec<_>>(),
5035 vec![vec![
5036 Value::Nat64(expected_high_water),
5037 Value::Nat64(100 + u64::try_from(ordinal).expect("ordinal should fit")),
5038 ]],
5039 );
5040 assert_eq!(
5041 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
5042 expected_high_water,
5043 );
5044 JOURNALED_SCHEMA_STORE.with(|store| {
5045 let cursor = store
5046 .borrow()
5047 .identity_statement_cursor(
5048 database_incarnation_id()
5049 .expect("database incarnation should remain readable"),
5050 ENTITY_TAG,
5051 FieldId::new(1),
5052 &AcceptedFieldKind::Nat64,
5053 )
5054 .expect("guarded recovery must leave quiescent active state");
5055 assert_eq!(
5056 cursor.expected_high_water(),
5057 u128::from(expected_high_water),
5058 );
5059 assert!(!cursor.has_allocations());
5060 });
5061 }
5062
5063 for (ordinal, (interruption, deleted_key)) in [
5064 (MutationCommitInterruption::MarkerPersisted, 2),
5065 (MutationCommitInterruption::JournalPublished, 4),
5066 (MutationCommitInterruption::RowPrefixPublished, 6),
5067 (MutationCommitInterruption::RowsPublished, 8),
5068 (MutationCommitInterruption::StateMaterialized, 7),
5069 ]
5070 .into_iter()
5071 .enumerate()
5072 {
5073 let expected_payload =
5074 501 + u64::try_from(ordinal).expect("small interruption ordinal should fit");
5075 interrupt_next_mutation_commit_for_tests(interruption);
5076 let interrupted = session.execute_trusted_dynamic_mutation_batch(vec![
5077 DynamicMutation::Update {
5078 entity: ENTITY_NAME.to_string(),
5079 key: InputValue::Nat64(1),
5080 patch: dynamic_payload_patch(expected_payload),
5081 },
5082 DynamicMutation::Delete {
5083 entity: ENTITY_NAME.to_string(),
5084 key: InputValue::Nat64(deleted_key),
5085 },
5086 ]);
5087 assert!(
5088 interrupted.is_err(),
5089 "the selected caller-key mixed publication boundary should interrupt",
5090 );
5091 let recovered_update = session
5092 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
5093 entity: ENTITY_NAME.to_string(),
5094 key: InputValue::Nat64(1),
5095 patch: dynamic_payload_patch(expected_payload),
5096 })
5097 .expect("guarded reentry should complete the marker-authorized mixed batch");
5098 assert_eq!(
5099 recovered_update.affected_rows, 0,
5100 "the recovered update must already expose its admitted final image",
5101 );
5102 let recovered_delete = session
5103 .execute_trusted_dynamic_mutation(&DynamicMutation::Delete {
5104 entity: ENTITY_NAME.to_string(),
5105 key: InputValue::Nat64(deleted_key),
5106 })
5107 .expect_err("the recovered delete must already be materialized");
5108 assert_eq!(recovered_delete.class(), ErrorClass::NotFound);
5109 JOURNALED_SCHEMA_STORE.with(|store| {
5110 let cursor = store
5111 .borrow()
5112 .identity_statement_cursor(
5113 database_incarnation_id()
5114 .expect("database incarnation should remain readable"),
5115 ENTITY_TAG,
5116 FieldId::new(1),
5117 &AcceptedFieldKind::Nat64,
5118 )
5119 .expect("caller-key recovery must preserve active Identity state");
5120 assert_eq!(cursor.expected_high_water(), 8);
5121 assert!(!cursor.has_allocations());
5122 });
5123 }
5124
5125 forget_recovered_domain_for_tests(&session.db)
5126 .expect("the final journal tail should remain recoverable");
5127 session
5128 .db
5129 .ensure_recovered_state()
5130 .expect("derived rebuild must not allocate another identity");
5131
5132 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
5133 let index_generation = JOURNALED_INDEX_STORE.with(|store| store.borrow().generation());
5134 forget_recovered_domain_for_tests(&session.db)
5135 .expect("an empty-tail upgrade should reset recovery ownership");
5136 session
5137 .db
5138 .ensure_recovered_state()
5139 .expect("an empty-tail upgrade should admit without rebuilding stored rows or indexes");
5140 assert_eq!(
5141 JOURNALED_DATA_STORE.with(|store| store.borrow().generation()),
5142 data_generation,
5143 "empty-tail recovery must not traverse or rewrite authoritative rows",
5144 );
5145 assert_eq!(
5146 JOURNALED_INDEX_STORE.with(|store| store.borrow().generation()),
5147 index_generation,
5148 "empty-tail recovery must not clear or rebuild secondary indexes",
5149 );
5150
5151 let quick = execute_quick_integrity(&session.db, catalog.inspection_plan())
5152 .expect("quiescent Identity control inventory should be inspectable");
5153 assert_eq!(quick.status(), &QuickIntegrityStatus::CompleteClean);
5154 let row_page = execute_row_integrity_page(
5155 &session.db,
5156 catalog.inspection_plan(),
5157 PhysicalUnitCheckpoint::BeforeFirst,
5158 RowInspectionLimits::standard(),
5159 )
5160 .expect("Identity rows should remain within committed high-water");
5161 assert!(row_page.exhausted());
5162 assert!(row_page.findings().is_empty());
5163
5164 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 3);
5165 assert!(
5166 JOURNALED_INDEX_STORE.with(|store| !store.borrow().is_empty()),
5167 "derived index rebuild should restore witnesses without allocating identities",
5168 );
5169 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5170 JOURNALED_SCHEMA_STORE.with(|store| {
5171 let cursor = store
5172 .borrow()
5173 .identity_statement_cursor(
5174 database_incarnation_id().expect("database incarnation should remain readable"),
5175 ENTITY_TAG,
5176 FieldId::new(1),
5177 &AcceptedFieldKind::Nat64,
5178 )
5179 .expect("folded identity state should reopen without allocating");
5180 assert_eq!(cursor.expected_high_water(), 8);
5181 assert!(!cursor.has_allocations());
5182 });
5183 }
5184
5185 #[test]
5186 fn journaled_startup_recovery_resumes_by_durable_pages_without_reallocating_ids() {
5187 let session = initialize_journaled();
5188 let catalog = session
5189 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5190 .expect("journaled identity catalog should resolve");
5191 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5192 .expect("journaled identity row layout should build");
5193
5194 for payload in 0_u64..129 {
5195 session
5196 .execute_accepted_structural_save_batch(
5197 &catalog,
5198 &descriptor,
5199 batch(&[payload]),
5200 Timestamp::from_millis(8),
5201 Ok,
5202 )
5203 .expect("journaled identity fixture row should commit");
5204 }
5205
5206 forget_recovered_domain_for_tests(&session.db)
5207 .expect("upgrade should reset recovery ownership");
5208 assert!(
5209 !session
5210 .db
5211 .continue_startup_recovery()
5212 .expect("the first bounded recovery page should commit"),
5213 "one page must not consume a tail larger than the production page bound",
5214 );
5215 assert!(JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5216 let mut pages = 1;
5217 while !session
5218 .db
5219 .continue_startup_recovery()
5220 .expect("each bounded recovery page should commit")
5221 {
5222 pages += 1;
5223 assert!(pages <= 4, "the small fixture should finish promptly");
5224 }
5225 assert!(pages >= 2);
5226
5227 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 129);
5228 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5229 assert_dynamic_payload(&session, 1, 0);
5230 assert_dynamic_payload(&session, 129, 128);
5231 JOURNALED_SCHEMA_STORE.with(|store| {
5232 let cursor = store
5233 .borrow()
5234 .identity_statement_cursor(
5235 database_incarnation_id().expect("database incarnation should remain readable"),
5236 ENTITY_TAG,
5237 FieldId::new(1),
5238 &AcceptedFieldKind::Nat64,
5239 )
5240 .expect("paged recovery must preserve active Identity state");
5241 assert_eq!(cursor.expected_high_water(), 129);
5242 assert!(!cursor.has_allocations());
5243 });
5244 }
5245
5246 #[test]
5247 fn journaled_startup_recovery_resumes_within_one_large_batch() {
5248 let session = initialize_journaled();
5249 let catalog = session
5250 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5251 .expect("journaled identity catalog should resolve");
5252 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5253 .expect("journaled identity row layout should build");
5254 let payloads = (0_u64..129).collect::<Vec<_>>();
5255 session
5256 .execute_accepted_structural_save_batch(
5257 &catalog,
5258 &descriptor,
5259 batch(&payloads),
5260 Timestamp::from_millis(9),
5261 Ok,
5262 )
5263 .expect("one large journal batch should commit");
5264
5265 forget_recovered_domain_for_tests(&session.db)
5266 .expect("upgrade should reset recovery ownership");
5267 assert!(
5268 !session
5269 .db
5270 .continue_startup_recovery()
5271 .expect("the first record-bounded recovery page should commit"),
5272 "a single batch larger than the record bound must remain resumable",
5273 );
5274 JOURNALED_TAIL_STORE.with(|tail| {
5275 let tail = tail.borrow();
5276 let cursor = tail
5277 .fold_record_cursor()
5278 .expect("the fold cursor should decode")
5279 .expect("the incomplete batch should retain a fold cursor");
5280 assert_eq!(cursor.next_record_ordinal(), 128);
5281 assert!(tail.has_stored_batch());
5282 });
5283 assert!(
5284 session
5285 .db
5286 .continue_startup_recovery()
5287 .expect("the terminal record-bounded recovery page should commit"),
5288 );
5289
5290 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 129);
5291 JOURNALED_TAIL_STORE.with(|tail| {
5292 let tail = tail.borrow();
5293 assert!(!tail.has_stored_batch());
5294 assert!(!tail.has_fold_record_cursor());
5295 });
5296 assert_dynamic_payload(&session, 1, 0);
5297 assert_dynamic_payload(&session, 129, 128);
5298 }
5299
5300 #[test]
5301 #[ignore = "release-closeout native timing probe for one marker-authorized Identity recovery"]
5302 fn identity_recovery_closeout_reports_guarded_reentry_time() {
5303 let session = initialize_journaled();
5304 let catalog = session
5305 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5306 .expect("journaled identity catalog should resolve");
5307 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5308 .expect("journaled identity row layout should build");
5309
5310 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::RowsPublished);
5311 let interrupted = session.execute_accepted_structural_save_batch(
5312 &catalog,
5313 &descriptor,
5314 batch(&[1]),
5315 Timestamp::from_millis(10),
5316 Ok,
5317 );
5318 assert!(
5319 interrupted.is_err(),
5320 "the selected publication boundary should interrupt",
5321 );
5322
5323 let start = Instant::now();
5324 let committed = session
5325 .execute_accepted_structural_save_batch(
5326 &catalog,
5327 &descriptor,
5328 batch(&[2]),
5329 Timestamp::from_millis(11),
5330 Ok,
5331 )
5332 .expect("guarded reentry should recover before allocation");
5333 let elapsed = start.elapsed();
5334 assert_eq!(
5335 committed
5336 .into_iter()
5337 .map(|row| row.values)
5338 .collect::<Vec<_>>(),
5339 vec![vec![Value::Nat64(2), Value::Nat64(2)]],
5340 );
5341
5342 println!(
5343 "identity recovery closeout: guarded_reentry_nanos={}",
5344 elapsed.as_nanos(),
5345 );
5346 }
5347}
5348
5349#[cfg(test)]
5350mod targeted_rule_mutation_tests {
5351 use super::{
5352 DbSession, DynamicMutation, DynamicStructuralPatch, DynamicTypedFieldBindingRequest,
5353 DynamicTypedFieldType, DynamicTypedMutation, DynamicWriteCell,
5354 };
5355 use crate::{
5356 db::{
5357 data::{DataStore, encode_input_value_for_candidate_field_contract},
5358 index::IndexStore,
5359 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
5360 schema::{
5361 AcceptedCheckLiteralV1, AcceptedCompositeCatalog, AcceptedFieldDecodeContract,
5362 AcceptedFieldKind, AcceptedNamedTypeIdentity, AcceptedRuleOperation,
5363 AcceptedRuleTarget, AcceptedSchemaRevision, AcceptedSourceBindingCatalog,
5364 ConstraintOrigin, FieldId, FieldStorageDecode, FieldWriteManagement, LeafCodec,
5365 PersistedFieldSnapshot, PersistedNestedLeafSnapshot, PersistedSchemaSnapshot,
5366 ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy, SchemaInsertDefault,
5367 SchemaRowLayout, SchemaStore, SchemaVersion,
5368 accepted_schema_candidate_with_catalogs_for_tests,
5369 build_record_newtype_composite_catalog_for_tests,
5370 empty_accepted_enum_catalog_for_tests, enum_catalog::ValueAdmissionBudget,
5371 },
5372 },
5373 error::InternalError,
5374 traits::{CanisterKind, Path},
5375 types::EntityTag,
5376 value::InputValue,
5377 };
5378 use icydb_schema::{
5379 ConstraintSourceKey, EntitySourceKey, FieldSourceKey, ScalarType, TypeSourceKey,
5380 };
5381 use std::{cell::RefCell, collections::BTreeMap};
5382
5383 const STORE_PATH: &str = "session::write::targeted_rule_mutation_tests::Store";
5384 const ENTITY_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity";
5385 const ID_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::id";
5386 const PROFILE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::profile";
5387 const UPDATED_AT_SOURCE: &str =
5388 "session::write::targeted_rule_mutation_tests::Entity::updated_at";
5389 const PROFILE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Profile";
5390 const DEGREE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Degree";
5391 const DEGREE_MEMBER_SOURCE: &str =
5392 "session::write::targeted_rule_mutation_tests::Profile::degree";
5393 const DEGREE_RULE_SOURCE: &str =
5394 "session::write::targeted_rule_mutation_tests::Profile::degree_multiple";
5395
5396 struct TestCanister;
5397
5398 impl Path for TestCanister {
5399 const PATH: &'static str = "session::write::targeted_rule_mutation_tests::Canister";
5400 }
5401
5402 impl CanisterKind for TestCanister {
5403 const COMMIT_MEMORY_ID: u8 = 43;
5404 const COMMIT_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.commit.v1";
5405 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 44;
5406 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
5407 "icydb.targeted_mutation_tests.integrity.progress.v1";
5408 }
5409
5410 thread_local! {
5411 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
5412 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
5413 static SCHEMA_STORE: RefCell<SchemaStore> =
5414 const { RefCell::new(SchemaStore::init_heap()) };
5415 static STORE_REGISTRY: StoreRegistry = {
5416 let mut registry = StoreRegistry::new();
5417 registry.register_store(
5418 STORE_PATH,
5419 &DATA_STORE,
5420 &INDEX_STORE,
5421 &SCHEMA_STORE,
5422 StoreAllocationIdentities::absent(),
5423 StoreRuntimeStorageCapabilities::heap(),
5424 ).expect("targeted mutation test store should register");
5425 registry
5426 };
5427 }
5428
5429 fn source<T, E: std::fmt::Debug>(raw: &str, parse: impl FnOnce(String) -> Result<T, E>) -> T {
5430 parse(raw.to_string()).expect("test source identity should admit")
5431 }
5432
5433 fn profile_input(degree: u64) -> InputValue {
5434 InputValue::Map(vec![(
5435 InputValue::Text("degree".to_string()),
5436 InputValue::Nat64(degree),
5437 )])
5438 }
5439
5440 fn structural_patch(id: u64, degree: u64) -> DynamicStructuralPatch {
5441 DynamicStructuralPatch::new(vec![
5442 (
5443 "id".to_string(),
5444 DynamicWriteCell::Value(InputValue::Nat64(id)),
5445 ),
5446 (
5447 "profile".to_string(),
5448 DynamicWriteCell::Value(profile_input(degree)),
5449 ),
5450 ])
5451 }
5452
5453 fn encoded_value(
5454 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
5455 composite_catalog: &AcceptedCompositeCatalog,
5456 name: &str,
5457 kind: &AcceptedFieldKind,
5458 storage_decode: FieldStorageDecode,
5459 leaf_codec: LeafCodec,
5460 value: InputValue,
5461 ) -> Vec<u8> {
5462 let field = AcceptedFieldDecodeContract::new(name, kind, false, storage_decode, leaf_codec);
5463 encode_input_value_for_candidate_field_contract(
5464 enum_catalog,
5465 composite_catalog,
5466 field,
5467 value,
5468 &mut ValueAdmissionBudget::standard(),
5469 )
5470 .expect("test accepted value should encode")
5471 }
5472
5473 fn nat64_literal(
5474 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
5475 composite_catalog: &AcceptedCompositeCatalog,
5476 value: u64,
5477 ) -> AcceptedCheckLiteralV1 {
5478 let kind = AcceptedFieldKind::Nat64;
5479 AcceptedCheckLiteralV1::from_accepted_parts(
5480 kind.clone(),
5481 FieldStorageDecode::ByKind,
5482 LeafCodec::Scalar(ScalarCodec::Nat64),
5483 encoded_value(
5484 enum_catalog,
5485 composite_catalog,
5486 "degree_bound",
5487 &kind,
5488 FieldStorageDecode::ByKind,
5489 LeafCodec::Scalar(ScalarCodec::Nat64),
5490 InputValue::Nat64(value),
5491 ),
5492 )
5493 }
5494
5495 fn targeted_constraint_id(error: &InternalError) -> u32 {
5496 let facts = error.diagnostic_facts();
5497 assert!(facts.contains(&(
5498 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
5499 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
5500 )));
5501 assert!(facts.contains(&(icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,)));
5502 assert!(facts.contains(&(
5503 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
5504 icydb_diagnostic_code::DiagnosticConstraintKind::TargetedRule.raw(),
5505 )));
5506 assert_eq!(
5507 facts
5508 .iter()
5509 .filter(|(tag, _)| matches!(
5510 tag,
5511 icydb_diagnostic_code::DiagnosticFactTag::RootField
5512 | icydb_diagnostic_code::DiagnosticFactTag::RecordMember
5513 ))
5514 .copied()
5515 .collect::<Vec<_>>(),
5516 vec![
5517 (icydb_diagnostic_code::DiagnosticFactTag::RootField, 2),
5518 (
5519 icydb_diagnostic_code::DiagnosticFactTag::RecordMember,
5520 icydb_diagnostic_code::pack_u32_pair(1, 1),
5521 ),
5522 ]
5523 );
5524 let value = facts
5525 .iter()
5526 .find_map(|(tag, value)| {
5527 (*tag == icydb_diagnostic_code::DiagnosticFactTag::ConstraintId).then_some(*value)
5528 })
5529 .expect("targeted mutation should retain its accepted constraint ID");
5530 u32::try_from(value).expect("accepted constraint ID fits u32")
5531 }
5532
5533 #[expect(
5534 clippy::too_many_lines,
5535 reason = "one end-to-end fixture proves every maintained write frontend converges on the same accepted targeted-rule schedule"
5536 )]
5537 #[test]
5538 fn targeted_rules_converge_across_dynamic_typed_sql_default_timestamp_and_batch_writes() {
5539 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
5540 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
5541 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
5542
5543 let entity_tag = EntityTag::new(93);
5544 let enum_catalog = empty_accepted_enum_catalog_for_tests();
5545 let (composite_catalog, profile_type, degree_type, degree_member) =
5546 build_record_newtype_composite_catalog_for_tests(
5547 "tests::TargetedProfile".to_string(),
5548 "degree".to_string(),
5549 "tests::TargetedDegree".to_string(),
5550 AcceptedFieldKind::Nat64,
5551 &enum_catalog,
5552 )
5553 .expect("targeted mutation composites should close");
5554 let profile_kind = AcceptedFieldKind::Composite {
5555 type_id: profile_type,
5556 };
5557 let profile_default = encoded_value(
5558 &enum_catalog,
5559 &composite_catalog,
5560 "profile",
5561 &profile_kind,
5562 FieldStorageDecode::CatalogValue,
5563 LeafCodec::Structural,
5564 profile_input(12),
5565 );
5566 let fields = vec![
5567 PersistedFieldSnapshot::new_initial(
5568 FieldId::new(1),
5569 "id".to_string(),
5570 SchemaFieldSlot::new(0),
5571 AcceptedFieldKind::Nat64,
5572 Vec::new(),
5573 false,
5574 SchemaInsertDefault::None,
5575 FieldStorageDecode::ByKind,
5576 LeafCodec::Scalar(ScalarCodec::Nat64),
5577 ),
5578 PersistedFieldSnapshot::new_initial(
5579 FieldId::new(2),
5580 "profile".to_string(),
5581 SchemaFieldSlot::new(1),
5582 profile_kind,
5583 vec![PersistedNestedLeafSnapshot::new(
5584 vec!["degree".to_string()],
5585 AcceptedFieldKind::Composite {
5586 type_id: degree_type,
5587 },
5588 false,
5589 )],
5590 false,
5591 SchemaInsertDefault::SlotPayload(profile_default),
5592 FieldStorageDecode::CatalogValue,
5593 LeafCodec::Structural,
5594 ),
5595 PersistedFieldSnapshot::new_initial_with_write_policy(
5596 FieldId::new(3),
5597 "updated_at".to_string(),
5598 SchemaFieldSlot::new(2),
5599 AcceptedFieldKind::Timestamp,
5600 Vec::new(),
5601 false,
5602 SchemaInsertDefault::None,
5603 SchemaFieldWritePolicy::from_model_policies(
5604 None,
5605 Some(FieldWriteManagement::UpdatedAt),
5606 ),
5607 FieldStorageDecode::ByKind,
5608 LeafCodec::Scalar(ScalarCodec::Timestamp),
5609 ),
5610 ];
5611 let mut snapshot = PersistedSchemaSnapshot::new(
5612 SchemaVersion::initial(),
5613 ENTITY_SOURCE.to_string(),
5614 "TargetedMutation".to_string(),
5615 FieldId::new(1),
5616 SchemaRowLayout::initial(
5617 fields
5618 .iter()
5619 .map(|field| (field.id(), field.slot()))
5620 .collect(),
5621 ),
5622 fields,
5623 );
5624 let constraint_catalog = snapshot
5625 .constraint_catalog()
5626 .clone()
5627 .with_added_targeted_rule(
5628 "profile_degree_multiple".to_string(),
5629 ConstraintOrigin::Generated,
5630 AcceptedRuleTarget::new(
5631 FieldId::new(2),
5632 AcceptedNamedTypeIdentity::Composite(degree_type),
5633 ),
5634 AcceptedRuleOperation::MultipleOf {
5635 divisor: nat64_literal(&enum_catalog, &composite_catalog, 5),
5636 },
5637 )
5638 .expect("targeted mutation rule should allocate");
5639 let targeted_rule_id = constraint_catalog
5640 .constraints()
5641 .last()
5642 .expect("targeted mutation rule should persist")
5643 .id();
5644 snapshot = snapshot.with_constraint_catalog(constraint_catalog);
5645
5646 let entity_source = source(ENTITY_SOURCE, EntitySourceKey::try_new);
5647 let id_source = source(ID_SOURCE, FieldSourceKey::try_new);
5648 let profile_source = source(PROFILE_SOURCE, FieldSourceKey::try_new);
5649 let updated_at_source = source(UPDATED_AT_SOURCE, FieldSourceKey::try_new);
5650 let profile_type_source = source(PROFILE_TYPE_SOURCE, TypeSourceKey::try_new);
5651 let degree_type_source = source(DEGREE_TYPE_SOURCE, TypeSourceKey::try_new);
5652 let degree_member_source = source(DEGREE_MEMBER_SOURCE, FieldSourceKey::try_new);
5653 let degree_rule_source = source(DEGREE_RULE_SOURCE, ConstraintSourceKey::try_new);
5654 let source_bindings = AcceptedSourceBindingCatalog::initial_for_tests(
5655 BTreeMap::from([(entity_source, entity_tag)]),
5656 BTreeMap::from([
5657 ((entity_tag, id_source), FieldId::new(1)),
5658 ((entity_tag, profile_source), FieldId::new(2)),
5659 ((entity_tag, updated_at_source), FieldId::new(3)),
5660 ]),
5661 BTreeMap::from([((entity_tag, degree_rule_source), targeted_rule_id)]),
5662 BTreeMap::new(),
5663 BTreeMap::new(),
5664 )
5665 .with_initial_named_types_for_tests(
5666 BTreeMap::from([
5667 (
5668 profile_type_source,
5669 AcceptedNamedTypeIdentity::Composite(profile_type),
5670 ),
5671 (
5672 degree_type_source,
5673 AcceptedNamedTypeIdentity::Composite(degree_type),
5674 ),
5675 ]),
5676 BTreeMap::new(),
5677 BTreeMap::from([((profile_type, degree_member_source), degree_member)]),
5678 );
5679 let candidate = accepted_schema_candidate_with_catalogs_for_tests(
5680 STORE_PATH,
5681 AcceptedSchemaRevision::INITIAL,
5682 enum_catalog,
5683 composite_catalog,
5684 source_bindings,
5685 BTreeMap::from([(entity_tag, snapshot)]),
5686 );
5687
5688 let session = DbSession::<TestCanister>::new(
5689 &STORE_REGISTRY,
5690 &crate::db::RequestExecutionRoot::__new_runtime_root(),
5691 );
5692 session
5693 .db
5694 .ensure_recovered_state()
5695 .expect("targeted mutation test database should initialize");
5696 let store = session
5697 .db
5698 .store_handle(STORE_PATH)
5699 .expect("targeted mutation test store should resolve");
5700 crate::db::commit::publish_accepted_schema_candidate(
5701 STORE_PATH,
5702 store,
5703 AcceptedSchemaRevision::NONE,
5704 &candidate,
5705 )
5706 .expect("targeted mutation candidate should publish");
5707
5708 let dynamic_error = session
5709 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5710 entity: "TargetedMutation".to_string(),
5711 patch: structural_patch(1, 12),
5712 })
5713 .expect_err("dynamic write must enforce the targeted rule");
5714 assert_eq!(
5715 targeted_constraint_id(&dynamic_error),
5716 targeted_rule_id.get()
5717 );
5718
5719 let binding = session
5720 .issue_typed_entity_binding(
5721 ENTITY_SOURCE,
5722 &[
5723 DynamicTypedFieldBindingRequest::new(
5724 ID_SOURCE.to_string(),
5725 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
5726 false,
5727 ),
5728 DynamicTypedFieldBindingRequest::new(
5729 PROFILE_SOURCE.to_string(),
5730 DynamicTypedFieldType::Named(PROFILE_TYPE_SOURCE.to_string()),
5731 false,
5732 ),
5733 DynamicTypedFieldBindingRequest::new(
5734 UPDATED_AT_SOURCE.to_string(),
5735 DynamicTypedFieldType::Scalar(ScalarType::Timestamp),
5736 false,
5737 ),
5738 ],
5739 )
5740 .expect("targeted typed binding should issue");
5741 let typed_patch = binding
5742 .bind_write_fields(vec![
5743 (
5744 ID_SOURCE.to_string(),
5745 DynamicWriteCell::Value(InputValue::Nat64(2)),
5746 ),
5747 (
5748 PROFILE_SOURCE.to_string(),
5749 DynamicWriteCell::Value(profile_input(12)),
5750 ),
5751 ])
5752 .expect("targeted typed patch should bind");
5753 let typed_error = session
5754 .execute_trusted_typed_mutation(
5755 &binding,
5756 &DynamicTypedMutation::Insert { patch: typed_patch },
5757 )
5758 .expect_err("typed write must enforce the targeted rule");
5759 assert_eq!(targeted_constraint_id(&typed_error), targeted_rule_id.get());
5760
5761 #[cfg(feature = "sql")]
5762 {
5763 let sql_error = session
5764 .execute_trusted_sql_mutation("INSERT INTO TargetedMutation (id) VALUES (3)")
5765 .expect_err("SQL default resolution must enforce the targeted rule");
5766 let crate::db::QueryError::Execute(execute) = sql_error else {
5767 panic!("targeted SQL write should fail at shared execution admission");
5768 };
5769 assert_eq!(
5770 targeted_constraint_id(execute.as_internal()),
5771 targeted_rule_id.get()
5772 );
5773 }
5774
5775 session
5776 .execute_trusted_dynamic_mutation_batch(vec![
5777 DynamicMutation::Insert {
5778 entity: "TargetedMutation".to_string(),
5779 patch: structural_patch(4, 5),
5780 },
5781 DynamicMutation::Insert {
5782 entity: "TargetedMutation".to_string(),
5783 patch: structural_patch(5, 12),
5784 },
5785 ])
5786 .expect_err("one invalid targeted value must reject the whole batch");
5787 assert_eq!(
5788 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
5789 Some(0),
5790 "no frontend or earlier valid batch row may escape targeted admission",
5791 );
5792
5793 let admitted = session
5794 .execute_trusted_dynamic_mutation_batch(vec![
5795 DynamicMutation::Insert {
5796 entity: "TargetedMutation".to_string(),
5797 patch: structural_patch(6, 5),
5798 },
5799 DynamicMutation::Insert {
5800 entity: "TargetedMutation".to_string(),
5801 patch: structural_patch(7, 10),
5802 },
5803 ])
5804 .expect("compliant targeted values should share one accepted batch");
5805 let [first, second] = admitted.rows.as_slice() else {
5806 panic!("the mixed targeted batch should return two rows");
5807 };
5808 let first_timestamp = first
5809 .get(2)
5810 .expect("the first mixed row should contain its managed timestamp");
5811 assert!(matches!(
5812 first_timestamp,
5813 crate::value::OutputValue::Timestamp(_)
5814 ));
5815 assert_eq!(
5816 second.get(2),
5817 Some(first_timestamp),
5818 "one accepted mixed batch must materialize one managed timestamp",
5819 );
5820 assert_eq!(
5821 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
5822 Some(2),
5823 );
5824 }
5825}