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