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_across_changed_output_work_envelopes() {
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 first = session
2484 .execute_trusted_live_page_with_result_bytes_limit_for_tests(&query, None, 32)
2485 .expect("small output envelope should publish the first bounded page");
2486 assert_eq!(first.rows, vec![vec![OutputValue::Nat64(3)]]);
2487 let continuation = first
2488 .continuation
2489 .expect("small output envelope should leave authenticated progress");
2490
2491 let second = session
2492 .execute_trusted_live_page_with_result_bytes_limit_for_tests(
2493 &query,
2494 Some(continuation.as_str()),
2495 64,
2496 )
2497 .unwrap_or_else(|error| {
2498 panic!(
2499 "larger output envelope should resume the same query: {error:?}, facts={:?}",
2500 error.diagnostic_facts(),
2501 )
2502 });
2503 assert_eq!(
2504 second.rows,
2505 vec![vec![OutputValue::Nat64(2)], vec![OutputValue::Nat64(1)]]
2506 );
2507 let second_continuation = second
2508 .continuation
2509 .as_deref()
2510 .expect("an exact-full page still needs to prove physical exhaustion");
2511 assert_ne!(first.work.envelope_identity, second.work.envelope_identity);
2512
2513 let terminal = session
2514 .execute_trusted_live_page_with_result_bytes_limit_for_tests(
2515 &query,
2516 Some(second_continuation),
2517 48,
2518 )
2519 .expect("a third finite envelope should prove exhaustion without replaying rows");
2520 assert!(terminal.rows.is_empty());
2521 assert_eq!(terminal.continuation, None);
2522 assert_ne!(
2523 second.work.envelope_identity,
2524 terminal.work.envelope_identity
2525 );
2526
2527 assert_eq!(
2528 [first.rows, second.rows, terminal.rows].concat(),
2529 vec![
2530 vec![OutputValue::Nat64(3)],
2531 vec![OutputValue::Nat64(2)],
2532 vec![OutputValue::Nat64(1)],
2533 ]
2534 );
2535 }
2536
2537 #[test]
2538 fn distinct_live_pages_resume_adjacent_groups_and_global_replay_end_to_end() {
2539 let session = initialize();
2540 session
2541 .execute_trusted_dynamic_mutation_batch(vec![
2542 insert(1, None),
2543 insert(2, None),
2544 insert(3, Some(1)),
2545 insert(4, Some(2)),
2546 insert(5, Some(1)),
2547 insert(6, Some(3)),
2548 insert(7, Some(2)),
2549 ])
2550 .expect("DISTINCT continuation rows should insert atomically");
2551
2552 let adjacent = DynamicQuery::new(ENTITY_NAME)
2553 .select(["parent_id"])
2554 .order_by(asc("parent_id"))
2555 .order_by(asc("id"))
2556 .distinct_for_internal_execution();
2557 let global = DynamicQuery::new(ENTITY_NAME)
2558 .select(["parent_id"])
2559 .order_by(asc("id"))
2560 .distinct_for_internal_execution();
2561
2562 let traverse = |query: &DynamicQuery, strategy: &str| {
2563 let mut continuation = None;
2564 let mut rows = Vec::new();
2565 let mut cursors = std::collections::BTreeSet::new();
2566 let mut pages = 0_u32;
2567 let mut entries_visited = 0_u64;
2568 loop {
2569 let page = session
2570 .execute_trusted_live_page(query, continuation.as_deref())
2571 .unwrap_or_else(|error| {
2572 panic!("{strategy} DISTINCT page should execute: {error:?}")
2573 });
2574 pages = pages.saturating_add(1);
2575 entries_visited = entries_visited.saturating_add(page.work.entries_visited);
2576 assert_eq!(page.row_count as usize, page.rows.len());
2577 assert_eq!(page.work.result_rows, page.row_count);
2578 rows.extend(page.rows);
2579 let Some(cursor) = page.continuation else {
2580 break;
2581 };
2582 assert!(
2583 cursors.insert(cursor.clone()),
2584 "{strategy} DISTINCT continuation must advance monotonically",
2585 );
2586 continuation = Some(cursor);
2587 assert!(pages < 8, "{strategy} DISTINCT traversal must terminate");
2588 }
2589
2590 (rows, pages, entries_visited)
2591 };
2592
2593 let expected = vec![
2594 vec![OutputValue::Null],
2595 vec![OutputValue::Nat64(1)],
2596 vec![OutputValue::Nat64(2)],
2597 vec![OutputValue::Nat64(3)],
2598 ];
2599 let (adjacent_rows, adjacent_pages, adjacent_entries) = traverse(&adjacent, "adjacent");
2600 let (global_rows, global_pages, global_entries) = traverse(&global, "global");
2601
2602 assert_eq!(adjacent_rows, expected);
2603 assert_eq!(global_rows, expected);
2604 assert_eq!(adjacent_pages, 2);
2605 assert_eq!(global_pages, 2);
2606 assert!(adjacent_entries > 0);
2607 assert!(global_entries > 0);
2608 }
2609
2610 #[test]
2611 fn selective_live_pages_publish_monotonic_empty_physical_progress() {
2612 let session = initialize();
2613 session
2614 .execute_trusted_dynamic_mutation_batch(
2615 (1..=9)
2616 .map(|id| {
2617 let parent = match id {
2618 1 => Some(2),
2619 9 => Some(1),
2620 _ => None,
2621 };
2622 insert(id, parent)
2623 })
2624 .collect(),
2625 )
2626 .expect("selective live-page rows should insert");
2627 let query = DynamicQuery::new(ENTITY_NAME)
2628 .select(["id"])
2629 .filter(FilterExpr::eq("parent_id", 1_u64))
2630 .order_by(asc("id"))
2631 .limit(1);
2632
2633 let first = session
2634 .execute_trusted_live_page(&query, None)
2635 .expect("first selective page should stop with physical progress");
2636 assert!(first.rows.is_empty());
2637 assert_eq!(first.work.entries_visited, 4);
2638 let first_cursor = first
2639 .continuation
2640 .expect("filtered physical progress must return a continuation");
2641
2642 let second = session
2643 .execute_trusted_live_page(&query, Some(first_cursor.as_str()))
2644 .expect("second selective page should resume after the first physical frontier");
2645 assert!(second.rows.is_empty());
2646 assert_eq!(second.work.entries_visited, 4);
2647 let second_cursor = second
2648 .continuation
2649 .expect("second filtered frontier must remain resumable");
2650 assert_ne!(second_cursor, first_cursor);
2651
2652 let third = session
2653 .execute_trusted_live_page(&query, Some(second_cursor.as_str()))
2654 .expect("final selective page should return the late match");
2655 assert_eq!(third.rows, vec![vec![OutputValue::Nat64(9)]]);
2656 assert_eq!(third.work.entries_visited, 1);
2657 assert_eq!(third.continuation, None);
2658
2659 let descending = DynamicQuery::new(ENTITY_NAME)
2660 .select(["id"])
2661 .filter(FilterExpr::eq("parent_id", 2_u64))
2662 .order_by(desc("id"))
2663 .limit(1);
2664 let descending_first = session
2665 .execute_trusted_live_page(&descending, None)
2666 .expect("descending selective page should stop with physical progress");
2667 assert!(descending_first.rows.is_empty());
2668 let descending_first_cursor = descending_first
2669 .continuation
2670 .expect("descending filtered progress must return a continuation");
2671 let descending_second = session
2672 .execute_trusted_live_page(&descending, Some(descending_first_cursor.as_str()))
2673 .expect("descending progress should resume after its physical frontier");
2674 assert!(descending_second.rows.is_empty());
2675 let descending_second_cursor = descending_second
2676 .continuation
2677 .expect("descending second frontier must remain resumable");
2678 assert_ne!(descending_second_cursor, descending_first_cursor);
2679 let descending_third = session
2680 .execute_trusted_live_page(&descending, Some(descending_second_cursor.as_str()))
2681 .expect("descending final page should return the late match");
2682 assert_eq!(descending_third.rows, vec![vec![OutputValue::Nat64(1)]]);
2683 assert_eq!(descending_third.continuation, None);
2684 }
2685
2686 #[test]
2687 fn accepted_relation_edges_drive_catalog_and_describe_introspection() {
2688 let session = initialize();
2689 let entities = session
2690 .show_entities()
2691 .expect("accepted entity catalog should resolve");
2692 let source = entities
2693 .iter()
2694 .find(|entity| entity.entity_name() == ENTITY_NAME)
2695 .expect("relation source should be listed");
2696 assert_eq!(source.relations(), 1);
2697
2698 let description = session
2699 .try_describe_entity_by_name(ENTITY_NAME)
2700 .expect("accepted relation source should describe");
2701 let [relation] = description.relations() else {
2702 panic!("accepted relation edge should produce one relation row");
2703 };
2704 assert_eq!(relation.field(), "parent_id");
2705 assert_eq!(relation.target_path(), ENTITY_SOURCE);
2706 assert_eq!(relation.target_entity_name(), ENTITY_NAME);
2707 assert_eq!(relation.target_store_path(), STORE_PATH);
2708 assert_eq!(
2709 relation.cardinality(),
2710 crate::db::EntityRelationCardinality::Single,
2711 );
2712 }
2713
2714 #[test]
2715 fn mixed_relation_validation_uses_the_complete_final_row_overlay() {
2716 let session = initialize();
2717 session
2718 .execute_trusted_dynamic_mutation_batch(vec![insert(1, None), insert(2, Some(1))])
2719 .expect("the initial relation should commit");
2720
2721 let blocked = session
2722 .execute_trusted_dynamic_mutation(&delete(1))
2723 .expect_err("an unaffected committed source must block target deletion");
2724 assert_relation_violation(&blocked);
2725
2726 let deleted = session
2727 .execute_trusted_dynamic_mutation_batch(vec![delete(2), delete(1)])
2728 .expect("a source and its target should delete atomically");
2729 assert_eq!(
2730 deleted.rows,
2731 vec![expected_row(2, Some(1)), expected_row(1, None)],
2732 );
2733
2734 session
2735 .execute_trusted_dynamic_mutation_batch(vec![insert(3, None), insert(4, Some(3))])
2736 .expect("the update-away fixture should commit");
2737 let updated_away = session
2738 .execute_trusted_dynamic_mutation_batch(vec![update_parent(4, None), delete(3)])
2739 .expect("an updated final source may release a deleted target");
2740 assert_eq!(
2741 updated_away.rows,
2742 vec![expected_row(4, None), expected_row(3, None)],
2743 );
2744
2745 session
2746 .execute_trusted_dynamic_mutation_batch(vec![insert(5, None), insert(6, Some(5))])
2747 .expect("the retained-reference fixture should commit");
2748 let retained = session
2749 .execute_trusted_dynamic_mutation_batch(vec![update_parent(6, Some(5)), delete(5)])
2750 .expect_err("a final updated source must still block target deletion");
2751 assert_relation_violation(&retained);
2752
2753 session
2754 .execute_trusted_dynamic_mutation(&insert(7, None))
2755 .expect("the inserted-reference fixture target should commit");
2756 let inserted_reference = session
2757 .execute_trusted_dynamic_mutation_batch(vec![insert(8, Some(7)), delete(7)])
2758 .expect_err("a final inserted source must not reference a deleted target");
2759 assert_relation_violation(&inserted_reference);
2760
2761 let inserted_target = session
2762 .execute_trusted_dynamic_mutation_batch(vec![insert(10, Some(9)), insert(9, None)])
2763 .expect("an inserted relation should see its batch-final target");
2764 assert_eq!(
2765 inserted_target.rows,
2766 vec![expected_row(10, Some(9)), expected_row(9, None)],
2767 );
2768
2769 session
2770 .execute_trusted_dynamic_mutation(&insert(11, None))
2771 .expect("the updated-reference fixture source should commit");
2772 let updated_target = session
2773 .execute_trusted_dynamic_mutation_batch(vec![
2774 update_parent(11, Some(12)),
2775 insert(12, None),
2776 ])
2777 .expect("an updated relation should see its batch-final target");
2778 assert_eq!(
2779 updated_target.rows,
2780 vec![expected_row(11, Some(12)), expected_row(12, None)],
2781 );
2782 }
2783
2784 #[test]
2785 fn mixed_batch_rejects_cross_entity_missing_and_collision_then_honors_replace() {
2786 let session = initialize();
2787 session
2788 .execute_trusted_dynamic_mutation(&insert(1, None))
2789 .expect("the primary mixed fixture row should commit");
2790 session
2791 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
2792 entity: OTHER_ENTITY_NAME.to_string(),
2793 patch: other_patch(Some(1), 10),
2794 })
2795 .expect("the secondary mixed fixture row should commit");
2796
2797 let mixed_entity = session
2798 .execute_trusted_dynamic_mutation_batch(vec![
2799 update_code(1, 11),
2800 DynamicMutation::Update {
2801 entity: OTHER_ENTITY_NAME.to_string(),
2802 key: InputValue::Nat64(1),
2803 patch: other_patch(None, 11),
2804 },
2805 ])
2806 .expect_err("one atomic batch must not cross accepted entities");
2807 assert_eq!(mixed_entity.class(), ErrorClass::Conflict);
2808 assert_eq!(
2809 mixed_entity.diagnostic_facts(),
2810 vec![
2811 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1,),
2812 (
2813 icydb_diagnostic_code::DiagnosticFactTag::ExpectedEntityTag,
2814 ENTITY_TAG.value(),
2815 ),
2816 (
2817 icydb_diagnostic_code::DiagnosticFactTag::ActualEntityTag,
2818 OTHER_ENTITY_TAG.value(),
2819 ),
2820 ],
2821 );
2822
2823 let missing = session
2824 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 12), delete(99)])
2825 .expect_err("a late missing delete must reject the earlier staged update");
2826 assert_eq!(missing.class(), ErrorClass::NotFound);
2827
2828 session
2829 .execute_trusted_dynamic_mutation(&insert(2, None))
2830 .expect("the collision fixture should commit");
2831 let collision = session
2832 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 13), insert(2, None)])
2833 .expect_err("an insert collision must reject the earlier staged update");
2834 assert_eq!(collision.class(), ErrorClass::Conflict);
2835 let failures_unchanged = session
2836 .execute_trusted_dynamic_mutation(&update_code(1, 1))
2837 .expect("failed batches must preserve the original unique value");
2838 assert_eq!(failures_unchanged.affected_rows, 0);
2839
2840 let replaced = session
2841 .execute_trusted_dynamic_mutation_batch(vec![
2842 update_code(1, 14),
2843 DynamicMutation::Replace {
2844 entity: ENTITY_NAME.to_string(),
2845 key: InputValue::Nat64(99),
2846 patch: patch(None, None, Some(99)),
2847 },
2848 ])
2849 .expect("ordinary caller-key replace should insert its absent final row");
2850 assert_eq!(
2851 replaced.rows,
2852 vec![
2853 expected_row_with_code(1, None, 14),
2854 expected_row_with_code(99, None, 99),
2855 ],
2856 );
2857
2858 let unchanged = session
2859 .execute_trusted_dynamic_mutation(&update_code(1, 14))
2860 .expect("the successful mixed replace must publish its preceding update");
2861 assert_eq!(unchanged.affected_rows, 0);
2862 let other_unchanged = session
2863 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
2864 entity: OTHER_ENTITY_NAME.to_string(),
2865 key: InputValue::Nat64(1),
2866 patch: other_patch(None, 10),
2867 })
2868 .expect("cross-entity rejection must preserve the secondary row");
2869 assert_eq!(other_unchanged.affected_rows, 0);
2870 }
2871
2872 #[test]
2873 fn mixed_batch_unique_swap_and_delete_release_use_the_final_overlay() {
2874 let session = initialize();
2875 session
2876 .execute_trusted_dynamic_mutation_batch(vec![
2877 insert_with_code(1, None, 10),
2878 insert_with_code(2, None, 20),
2879 ])
2880 .expect("the unique-overlay fixture should commit");
2881
2882 let swapped = session
2883 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 20), update_code(2, 10)])
2884 .expect("two final rows should atomically swap unique memberships");
2885 assert_eq!(
2886 swapped.rows,
2887 vec![
2888 expected_row_with_code(1, None, 20),
2889 expected_row_with_code(2, None, 10),
2890 ],
2891 );
2892
2893 let released = session
2894 .execute_trusted_dynamic_mutation_batch(vec![delete(1), insert_with_code(3, None, 20)])
2895 .expect("a delete should release unique membership to a final inserted row");
2896 assert_eq!(
2897 released.rows,
2898 vec![
2899 expected_row_with_code(1, None, 20),
2900 expected_row_with_code(3, None, 20),
2901 ],
2902 );
2903 }
2904}
2905
2906#[cfg(test)]
2907mod identity_pre_key_tests {
2908 #[cfg(all(feature = "sql", feature = "diagnostics"))]
2909 use super::DynamicTypedEntityBinding;
2910 use super::{
2911 AcceptedMutationIntentPatch, AcceptedRowLayoutRuntimeContract, AcceptedStructuralMutation,
2912 AcceptedStructuralMutationTarget, DbSession, DynamicMutation, DynamicStructuralPatch,
2913 DynamicTypedFieldBindingRequest, DynamicTypedFieldType, DynamicTypedMutation,
2914 DynamicWriteCell, FieldSlot, MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
2915 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
2916 add_structural_mutation_staged_bytes, checked_pre_key_candidate_count,
2917 insert_key_exists_after_generation, validate_structural_mutation_result_bytes,
2918 };
2919 #[cfg(all(feature = "sql", feature = "diagnostics"))]
2920 use crate::db::data::DecodedDataStoreKey;
2921 #[cfg(all(feature = "sql", feature = "diagnostics"))]
2922 use crate::db::executor::budget::{
2923 HardExecutionBudget, HardExecutionContext, HardExecutionFailureHeadroom,
2924 with_query_execution_budget_for_tests,
2925 };
2926 #[cfg(all(feature = "sql", feature = "diagnostics"))]
2927 use crate::db::{
2928 CompareProofAndAdvanceError, DynamicQuery, ExhaustiveReadError, RawDataStoreKey,
2929 ReadSetRevisionError, ResumableJobAdvance, ResumableJobAdvanceRequest,
2930 ResumableJobAdvanceStatus, ResumableJobError, ResumableJobId, ResumableJobIdempotencyKey,
2931 ResumableJobStatus, asc,
2932 };
2933 use crate::{
2934 db::{
2935 commit::{database_incarnation_id, forget_recovered_domain_for_tests},
2936 data::DataStore,
2937 executor::{MutationCommitInterruption, interrupt_next_mutation_commit_for_tests},
2938 index::IndexStore,
2939 integrity::{
2940 PhysicalUnitCheckpoint, QuickIntegrityStatus, RowInspectionLimits,
2941 execute_quick_integrity, execute_row_integrity_page,
2942 },
2943 journal::JournalTailStore,
2944 registry::{
2945 StoreAllocationIdentities, StoreAllocationIdentity, StoreRegistry,
2946 StoreRuntimeStorageCapabilities,
2947 },
2948 schema::{
2949 AcceptedFieldKind, AcceptedSchemaRevision, FieldId, FieldInsertGeneration,
2950 FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
2951 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
2952 PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy,
2953 SchemaIndexId, SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
2954 accepted_schema_candidate_with_field_bindings_for_tests,
2955 },
2956 write_context::MutationMode,
2957 },
2958 error::{ErrorClass, ErrorOrigin, InternalError},
2959 testing::test_memory,
2960 traits::{CanisterKind, Path},
2961 types::{EntityTag, Timestamp},
2962 value::{InputValue, OutputValue, Value},
2963 };
2964 use icydb_schema::{FieldSourceKey, ScalarType};
2965 #[cfg(all(feature = "sql", feature = "diagnostics"))]
2966 use std::cell::Cell;
2967 use std::{cell::RefCell, collections::BTreeMap, time::Instant};
2968
2969 const STORE_PATH: &str = "session::write::identity_pre_key_tests::Store";
2970 const ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::Entity";
2971 const ID_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::id";
2972 const PAYLOAD_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::payload";
2973 const ENTITY_NAME: &str = "IdentityRow";
2974 const ENTITY_TAG: EntityTag = EntityTag::new(93);
2975 const JOURNALED_STORE_PATH: &str = "session::write::identity_pre_key_tests::JournaledStore";
2976 const UNRELATED_STORE_PATH: &str = "session::write::identity_pre_key_tests::UnrelatedStore";
2977
2978 struct TestCanister;
2979
2980 impl Path for TestCanister {
2981 const PATH: &'static str = "session::write::identity_pre_key_tests::Canister";
2982 }
2983
2984 impl CanisterKind for TestCanister {
2985 const COMMIT_MEMORY_ID: u8 = 45;
2986 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.commit.v1";
2987 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 46;
2988 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
2989 "icydb.identity_pre_key_tests.integrity.progress.v1";
2990 }
2991
2992 thread_local! {
2993 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
2994 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
2995 static SCHEMA_STORE: RefCell<SchemaStore> =
2996 const { RefCell::new(SchemaStore::init_heap()) };
2997 static UNRELATED_DATA_STORE: RefCell<DataStore> =
2998 const { RefCell::new(DataStore::init_heap()) };
2999 static UNRELATED_INDEX_STORE: RefCell<IndexStore> =
3000 const { RefCell::new(IndexStore::init_heap()) };
3001 static UNRELATED_SCHEMA_STORE: RefCell<SchemaStore> =
3002 const { RefCell::new(SchemaStore::init_heap()) };
3003 static STORE_REGISTRY: StoreRegistry = {
3004 let mut registry = StoreRegistry::new();
3005 registry.register_store(
3006 STORE_PATH,
3007 &DATA_STORE,
3008 &INDEX_STORE,
3009 &SCHEMA_STORE,
3010 StoreAllocationIdentities::absent(),
3011 StoreRuntimeStorageCapabilities::heap(),
3012 ).expect("identity pre-key test store should register");
3013 registry.register_store(
3014 UNRELATED_STORE_PATH,
3015 &UNRELATED_DATA_STORE,
3016 &UNRELATED_INDEX_STORE,
3017 &UNRELATED_SCHEMA_STORE,
3018 StoreAllocationIdentities::absent(),
3019 StoreRuntimeStorageCapabilities::heap(),
3020 ).expect("unrelated identity test store should register");
3021 registry
3022 };
3023 static JOURNALED_DATA_STORE: RefCell<DataStore> =
3024 RefCell::new(DataStore::init_journaled(test_memory(186)));
3025 static JOURNALED_INDEX_STORE: RefCell<IndexStore> =
3026 RefCell::new(IndexStore::init_journaled(test_memory(187)));
3027 static JOURNALED_SCHEMA_STORE: RefCell<SchemaStore> =
3028 RefCell::new(SchemaStore::init_journaled(test_memory(188)));
3029 static JOURNALED_TAIL_STORE: RefCell<JournalTailStore> =
3030 RefCell::new(JournalTailStore::init(test_memory(189)));
3031 static JOURNALED_STORE_REGISTRY: StoreRegistry = {
3032 let mut registry = StoreRegistry::new();
3033 registry.register_journaled_store(
3034 JOURNALED_STORE_PATH,
3035 &JOURNALED_DATA_STORE,
3036 &JOURNALED_INDEX_STORE,
3037 &JOURNALED_SCHEMA_STORE,
3038 &JOURNALED_TAIL_STORE,
3039 StoreAllocationIdentities::new_journaled(
3040 StoreAllocationIdentity::new(186, "icydb.test.identity-range.data.v1"),
3041 StoreAllocationIdentity::new(187, "icydb.test.identity-range.index.v1"),
3042 StoreAllocationIdentity::new(188, "icydb.test.identity-range.schema.v1"),
3043 StoreAllocationIdentity::new(189, "icydb.test.identity-range.journal.v1"),
3044 ),
3045 StoreRuntimeStorageCapabilities::journaled(),
3046 ).expect("identity range journaled store should register");
3047 registry
3048 };
3049 }
3050
3051 struct JournaledTestCanister;
3052
3053 impl Path for JournaledTestCanister {
3054 const PATH: &'static str = "session::write::identity_pre_key_tests::JournaledCanister";
3055 }
3056
3057 impl CanisterKind for JournaledTestCanister {
3058 const COMMIT_MEMORY_ID: u8 = 190;
3059 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_range_tests.commit.v1";
3060 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 191;
3061 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3062 "icydb.identity_range_tests.integrity.progress.v1";
3063 }
3064
3065 fn source_key(source: &str) -> FieldSourceKey {
3066 FieldSourceKey::try_new(source).expect("identity test field source should admit")
3067 }
3068
3069 fn identity_snapshot(store_path: &str) -> PersistedSchemaSnapshot {
3070 let fields = vec![
3071 PersistedFieldSnapshot::new_initial_with_write_policy(
3072 FieldId::new(1),
3073 "id".to_string(),
3074 SchemaFieldSlot::new(0),
3075 AcceptedFieldKind::Nat64,
3076 Vec::new(),
3077 false,
3078 SchemaInsertDefault::None,
3079 SchemaFieldWritePolicy::from_model_policies(
3080 Some(FieldInsertGeneration::Identity),
3081 None,
3082 ),
3083 FieldStorageDecode::ByKind,
3084 LeafCodec::Scalar(ScalarCodec::Nat64),
3085 ),
3086 PersistedFieldSnapshot::new_initial(
3087 FieldId::new(2),
3088 "payload".to_string(),
3089 SchemaFieldSlot::new(1),
3090 AcceptedFieldKind::Nat64,
3091 Vec::new(),
3092 false,
3093 SchemaInsertDefault::None,
3094 FieldStorageDecode::ByKind,
3095 LeafCodec::Scalar(ScalarCodec::Nat64),
3096 ),
3097 ];
3098 PersistedSchemaSnapshot::new_with_indexes(
3099 SchemaVersion::initial(),
3100 ENTITY_SOURCE.to_string(),
3101 ENTITY_NAME.to_string(),
3102 FieldId::new(1),
3103 SchemaRowLayout::initial(
3104 fields
3105 .iter()
3106 .map(|field| (field.id(), field.slot()))
3107 .collect(),
3108 ),
3109 fields,
3110 vec![PersistedIndexSnapshot::new(
3111 SchemaIndexId::new(1).expect("identity test index ID should admit"),
3112 1,
3113 "by_payload".to_string(),
3114 store_path.to_string(),
3115 false,
3116 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
3117 FieldId::new(2),
3118 SchemaFieldSlot::new(1),
3119 vec!["payload".to_string()],
3120 AcceptedFieldKind::Nat64,
3121 false,
3122 )]),
3123 None,
3124 )],
3125 )
3126 }
3127
3128 fn initialize() -> DbSession<TestCanister> {
3129 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3130 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3131 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3132 UNRELATED_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3133 UNRELATED_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3134 UNRELATED_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3135 let session = DbSession::<TestCanister>::new(
3136 &STORE_REGISTRY,
3137 &crate::db::RequestExecutionRoot::__new_runtime_root(),
3138 );
3139 session
3140 .db
3141 .ensure_recovered_state()
3142 .expect("identity pre-key test database should initialize");
3143 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3144 STORE_PATH,
3145 AcceptedSchemaRevision::INITIAL,
3146 BTreeMap::from([(ENTITY_TAG, identity_snapshot(STORE_PATH))]),
3147 BTreeMap::from([
3148 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
3149 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
3150 ]),
3151 );
3152 let store = session
3153 .db
3154 .store_handle(STORE_PATH)
3155 .expect("identity pre-key test store should resolve");
3156 crate::db::commit::publish_accepted_schema_candidate(
3157 STORE_PATH,
3158 store,
3159 AcceptedSchemaRevision::NONE,
3160 &candidate,
3161 )
3162 .expect("identity candidate should publish with explicit zero state");
3163 session
3164 }
3165
3166 fn initialize_journaled_with_root() -> (
3167 DbSession<JournaledTestCanister>,
3168 crate::db::RequestExecutionRoot,
3169 ) {
3170 let root = crate::db::RequestExecutionRoot::__new_runtime_root();
3171 let session = DbSession::<JournaledTestCanister>::new(&JOURNALED_STORE_REGISTRY, &root);
3172 session
3173 .db
3174 .ensure_recovered_state()
3175 .expect("journaled identity database should initialize");
3176 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3177 JOURNALED_STORE_PATH,
3178 AcceptedSchemaRevision::INITIAL,
3179 BTreeMap::from([(ENTITY_TAG, identity_snapshot(JOURNALED_STORE_PATH))]),
3180 BTreeMap::from([
3181 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
3182 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
3183 ]),
3184 );
3185 let store = session
3186 .db
3187 .store_handle(JOURNALED_STORE_PATH)
3188 .expect("journaled identity store should resolve");
3189 crate::db::commit::publish_accepted_schema_candidate(
3190 JOURNALED_STORE_PATH,
3191 store,
3192 AcceptedSchemaRevision::NONE,
3193 &candidate,
3194 )
3195 .expect("journaled identity candidate should publish");
3196 (session, root)
3197 }
3198
3199 fn initialize_journaled() -> DbSession<JournaledTestCanister> {
3200 initialize_journaled_with_root().0
3201 }
3202
3203 fn payload_patch(value: u64) -> AcceptedMutationIntentPatch {
3204 AcceptedMutationIntentPatch::new()
3205 .set_authored(FieldSlot::from_validated_index(1), InputValue::Nat64(value))
3206 }
3207
3208 fn dynamic_payload_patch(value: u64) -> DynamicStructuralPatch {
3209 DynamicStructuralPatch::new(vec![(
3210 "payload".to_string(),
3211 DynamicWriteCell::Value(InputValue::Nat64(value)),
3212 )])
3213 }
3214
3215 fn expected_dynamic_row(id: u64, payload: u64) -> Vec<OutputValue> {
3216 vec![OutputValue::Nat64(id), OutputValue::Nat64(payload)]
3217 }
3218
3219 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3220 fn exact_key_binding<C: CanisterKind>(session: &DbSession<C>) -> DynamicTypedEntityBinding {
3221 session
3222 .issue_typed_entity_binding(
3223 ENTITY_SOURCE,
3224 &[
3225 DynamicTypedFieldBindingRequest::new(
3226 ID_SOURCE.to_string(),
3227 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3228 false,
3229 ),
3230 DynamicTypedFieldBindingRequest::new(
3231 PAYLOAD_SOURCE.to_string(),
3232 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3233 false,
3234 ),
3235 ],
3236 )
3237 .expect("exact-key test binding should issue")
3238 }
3239
3240 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3241 fn insert_exact_key_fixture<C: CanisterKind>(session: &DbSession<C>, payload: u64) -> u64 {
3242 let output = session
3243 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
3244 entity: ENTITY_NAME.to_string(),
3245 patch: dynamic_payload_patch(payload),
3246 })
3247 .expect("exact-key fixture insert should commit");
3248 match output.rows.as_slice() {
3249 [row] => match row.as_slice() {
3250 [OutputValue::Nat64(id), OutputValue::Nat64(actual_payload)]
3251 if *actual_payload == payload =>
3252 {
3253 *id
3254 }
3255 _ => panic!("exact-key fixture should return its identity and payload"),
3256 },
3257 _ => panic!("exact-key fixture insert should return one row"),
3258 }
3259 }
3260
3261 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3262 fn identity_row_stored_bytes<C: CanisterKind>(
3263 session: &DbSession<C>,
3264 store_path: &'static str,
3265 key: u64,
3266 ) -> u64 {
3267 let data_key = DecodedDataStoreKey::try_from_structural_key(ENTITY_TAG, &Value::Nat64(key))
3268 .expect("identity row key should encode");
3269 let raw_key = data_key.to_raw().expect("identity raw key should encode");
3270 let store = session
3271 .db
3272 .recovered_store(store_path)
3273 .expect("identity store should resolve");
3274 store.with_data(|data_store| {
3275 u64::try_from(
3276 data_store
3277 .get(&raw_key)
3278 .expect("inserted identity row should exist")
3279 .len(),
3280 )
3281 .expect("bounded row length should fit u64")
3282 })
3283 }
3284
3285 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3286 fn with_stored_bytes_limit<T>(
3287 limit: u64,
3288 shape_fingerprint_prefix: u64,
3289 operation: impl FnOnce() -> Result<T, crate::db::query::intent::QueryError>,
3290 ) -> Result<T, crate::db::query::intent::QueryError> {
3291 let budget = HardExecutionBudget::uniform_for_tests(
3292 u64::MAX,
3293 HardExecutionFailureHeadroom::new(500, 256),
3294 )
3295 .with_limit_for_tests(
3296 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::StoredBytesRead,
3297 limit,
3298 );
3299 let context = HardExecutionContext::new(
3300 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
3301 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
3302 shape_fingerprint_prefix,
3303 );
3304
3305 with_query_execution_budget_for_tests(budget, context, operation)
3306 }
3307
3308 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3309 fn assert_exact_key_batch<C: CanisterKind>(session: &DbSession<C>) {
3310 let first = insert_exact_key_fixture(session, 41);
3311 let second = insert_exact_key_fixture(session, 42);
3312 let missing = u64::MAX;
3313 let binding = exact_key_binding(session);
3314 let gets_before = DataStore::current_get_call_count();
3315 let result = session
3316 .execute_public_exact_key_batch_for_typed_binding(
3317 &binding,
3318 &[second, missing, first, second],
3319 )
3320 .expect("exact-key batch should execute")
3321 .expect("exact-key binding should remain current");
3322
3323 assert_eq!(result.positions, vec![0, 1, 2, 0]);
3324 assert_eq!(
3325 result.distinct_rows,
3326 vec![
3327 Some(expected_dynamic_row(second, 42)),
3328 None,
3329 Some(expected_dynamic_row(first, 41)),
3330 ],
3331 );
3332 assert_eq!(
3333 DataStore::current_get_call_count().saturating_sub(gets_before),
3334 3,
3335 "four input positions with one duplicate must perform three physical reads",
3336 );
3337 }
3338
3339 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3340 #[test]
3341 fn exact_key_batches_preserve_semantics_across_heap_and_journaled_stores() {
3342 assert_exact_key_batch(&initialize());
3343 assert_exact_key_batch(&initialize_journaled());
3344 }
3345
3346 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3347 fn assert_primary_range_materialization_fetches_once<C: CanisterKind>(
3348 session: &DbSession<C>,
3349 store_path: &'static str,
3350 ) {
3351 let key = insert_exact_key_fixture(session, 41);
3352 let stored_bytes = identity_row_stored_bytes(session, store_path, key);
3353
3354 let scalar = DynamicQuery::new(ENTITY_NAME)
3355 .select(["id", "payload"])
3356 .order_by(asc("id"))
3357 .limit(1);
3358 let gets_before = DataStore::current_get_call_count();
3359 let scalar_page = with_stored_bytes_limit(stored_bytes, 0x7072_696d_6172_792d, || {
3360 session.execute_trusted_live_page(&scalar, None)
3361 })
3362 .expect("one scalar primary-range row should fit one payload-read allowance");
3363 assert_eq!(scalar_page.row_count, 1);
3364 assert_eq!(
3365 DataStore::current_get_call_count().saturating_sub(gets_before),
3366 1,
3367 "scalar primary traversal should fetch its emitted row exactly once",
3368 );
3369
3370 let grouped = DynamicQuery::new(ENTITY_NAME)
3371 .group_by("payload")
3372 .aggregate(crate::db::count())
3373 .grouped_limits(10, 16 * 1_024)
3374 .limit(1);
3375 let gets_before = DataStore::current_get_call_count();
3376 let grouped_page = with_stored_bytes_limit(stored_bytes, 0x6772_6f75_7065_642d, || {
3377 session.execute_trusted_dynamic_grouped_query(&grouped)
3378 })
3379 .expect("one grouped primary-range row should fit one payload-read allowance");
3380 assert_eq!(grouped_page.row_count, 1);
3381 assert_eq!(
3382 DataStore::current_get_call_count().saturating_sub(gets_before),
3383 1,
3384 "grouped primary traversal should fetch its source row exactly once",
3385 );
3386 }
3387
3388 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3389 #[test]
3390 fn row_materialization_fetches_each_required_payload_at_most_once() {
3391 assert_primary_range_materialization_fetches_once(&initialize(), STORE_PATH);
3392 assert_primary_range_materialization_fetches_once(
3393 &initialize_journaled(),
3394 JOURNALED_STORE_PATH,
3395 );
3396 }
3397
3398 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3399 #[test]
3400 fn ordered_grouped_pages_close_a_group_spanning_physical_refills_before_resume() {
3401 let session = initialize();
3402 let mut patches = Vec::new();
3403 for _ in 0..70 {
3404 patches.push(dynamic_payload_patch(10));
3405 }
3406 for _ in 0..3 {
3407 patches.push(dynamic_payload_patch(20));
3408 }
3409 patches.push(dynamic_payload_patch(30));
3410 let inserted = session
3411 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, patches)
3412 .expect("ordered grouped continuation rows should insert");
3413 assert_eq!(inserted.rows.len(), 74);
3414
3415 let query = DynamicQuery::new(ENTITY_NAME)
3416 .group_by("payload")
3417 .aggregate(crate::db::count())
3418 .aggregate(crate::db::sum("id"))
3419 .order_by(asc("payload"))
3420 .grouped_limits(4, 16 * 1_024)
3421 .limit(1);
3422 let expected = [
3423 (10_u64, 70_u64, crate::types::Decimal::new(2_485, 0)),
3424 (20, 3, crate::types::Decimal::new(216, 0)),
3425 (30, 1, crate::types::Decimal::new(74, 0)),
3426 ];
3427 let mut continuation: Option<String> = None;
3428 let mut seen_cursors = std::collections::BTreeSet::new();
3429
3430 for (page_index, (group_key, row_count, id_sum)) in expected.into_iter().enumerate() {
3431 let request = continuation.as_ref().map_or_else(
3432 || query.clone(),
3433 |cursor| query.clone().cursor(cursor.clone()),
3434 );
3435 let entries_before = IndexStore::current_entry_read_count();
3436 let rows_before = DataStore::current_get_call_count();
3437 let page = session
3438 .execute_trusted_dynamic_grouped_query(&request)
3439 .unwrap_or_else(|error| {
3440 panic!("ordered grouped page {page_index} should execute: {error:?}")
3441 });
3442 let entries_read =
3443 IndexStore::current_entry_read_count().saturating_sub(entries_before);
3444 let rows_read = DataStore::current_get_call_count().saturating_sub(rows_before);
3445
3446 assert_eq!(page.row_count, 1);
3447 let [row] = page.rows.as_slice() else {
3448 panic!("ordered grouped page must contain exactly one closed group")
3449 };
3450 assert_eq!(row.group_key(), &[OutputValue::Nat64(group_key)]);
3451 assert_eq!(
3452 row.aggregate_values(),
3453 &[OutputValue::Nat64(row_count), OutputValue::Decimal(id_sum),],
3454 );
3455 if page_index == 0 {
3456 assert!(
3457 entries_read.saturating_add(rows_read) >= 70,
3458 "the first closed group must span the maintained 64-entry physical refill",
3459 );
3460 }
3461
3462 continuation = page.next_cursor;
3463 if page_index + 1 < expected.len() {
3464 let cursor = continuation
3465 .as_ref()
3466 .expect("another closed group should retain continuation");
3467 assert!(
3468 seen_cursors.insert(cursor.clone()),
3469 "ordered grouped continuation must advance monotonically",
3470 );
3471 } else {
3472 assert_eq!(continuation, None);
3473 }
3474 }
3475 }
3476
3477 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3478 #[test]
3479 fn exhaustive_pages_require_and_recompare_the_complete_source_proof() {
3480 let session = initialize();
3481 let first = insert_exact_key_fixture(&session, 41);
3482 let second = insert_exact_key_fixture(&session, 42);
3483 let third = insert_exact_key_fixture(&session, 43);
3484 let query = DynamicQuery::new(ENTITY_NAME)
3485 .select(["id", "payload"])
3486 .order_by(asc("id"));
3487
3488 let page = session
3489 .execute_trusted_exhaustive_page(&query, None, None)
3490 .expect("initial exhaustive page should capture its source proof");
3491 assert_eq!(
3492 page.rows,
3493 vec![
3494 expected_dynamic_row(first, 41),
3495 expected_dynamic_row(second, 42),
3496 ],
3497 );
3498 let continuation = page
3499 .continuation
3500 .as_deref()
3501 .expect("unreturned row should retain exhaustive continuation");
3502 assert!(matches!(
3503 session.execute_trusted_exhaustive_page(&query, Some(continuation), None),
3504 Err(ExhaustiveReadError::Revision(
3505 ReadSetRevisionError::ResumeProofRequired
3506 )),
3507 ));
3508 let resumed = session
3509 .execute_trusted_exhaustive_page(&query, Some(continuation), Some(&page.proof))
3510 .expect("unchanged proof should resume exhaustive traversal");
3511 assert_eq!(resumed.rows, vec![expected_dynamic_row(third, 43)]);
3512 assert_eq!(resumed.continuation, None);
3513
3514 let stale_page = session
3515 .execute_trusted_exhaustive_page(&query, None, None)
3516 .expect("fresh exhaustive page should capture current revision");
3517 let stale_continuation = stale_page
3518 .continuation
3519 .as_deref()
3520 .expect("fresh three-row traversal should retain continuation");
3521 let _ = insert_exact_key_fixture(&session, 44);
3522 assert!(matches!(
3523 session.execute_trusted_exhaustive_page(
3524 &query,
3525 Some(stale_continuation),
3526 Some(&stale_page.proof),
3527 ),
3528 Err(ExhaustiveReadError::Revision(
3529 ReadSetRevisionError::StoreDataChanged { .. }
3530 )),
3531 ));
3532 }
3533
3534 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3535 #[test]
3536 fn heap_sources_cannot_back_durable_resumable_jobs() {
3537 let session = initialize();
3538 let proof = session
3539 .capture_read_set_revision_proof(&[ENTITY_NAME])
3540 .expect("heap source proof should capture for one-call exhaustive reads");
3541 let job_id = ResumableJobId::try_from_bytes([70; 32])
3542 .expect("nonzero heap test job identity should admit");
3543
3544 assert!(matches!(
3545 session.start_resumable_job(job_id, proof, Vec::new()),
3546 Err(ResumableJobError::SourceProof(
3547 ReadSetRevisionError::DurableStoreRequired { .. }
3548 )),
3549 ));
3550 }
3551
3552 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3553 #[test]
3554 fn proof_and_progress_controls_charge_one_shared_request_scope() {
3555 let (session, root) = initialize_journaled_with_root();
3556 let resource = icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions;
3557 let before = root.observed(resource);
3558 let proof = session
3559 .capture_read_set_revision_proof(&[ENTITY_NAME])
3560 .expect("proof capture should use the retained request scope");
3561 let job_id = ResumableJobId::try_from_bytes([75; 32])
3562 .expect("nonzero accounting job identity should admit");
3563 session
3564 .start_resumable_job(job_id, proof, Vec::new())
3565 .expect("job start should use the same retained request scope");
3566 let _ = session
3567 .resumable_job_state(job_id)
3568 .expect("job load should use the same retained request scope");
3569
3570 assert_eq!(root.observed(resource).saturating_sub(before), 3);
3571 }
3572
3573 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3574 #[test]
3575 fn source_proofs_ignore_unrelated_stores_but_bind_access_state_changes() {
3576 let session = initialize();
3577 let proof = session
3578 .capture_read_set_revision_proof(&[ENTITY_NAME])
3579 .expect("source proof should cover only the entity's physical store");
3580 let shared_store_proof = session
3581 .capture_read_set_revision_proof(&[ENTITY_NAME, ENTITY_NAME])
3582 .expect("entities sharing one physical source should deduplicate");
3583 assert_eq!(shared_store_proof, proof);
3584 assert_eq!(shared_store_proof.stores().len(), 1);
3585 let unrelated = session
3586 .db
3587 .store_handle(UNRELATED_STORE_PATH)
3588 .expect("unrelated registered store should resolve");
3589 unrelated.with_data_mut(|store| {
3590 let _ = store.remove(&RawDataStoreKey::from_persisted_bytes(vec![1]));
3591 });
3592 session
3593 .verify_read_set_revision_proof(&proof)
3594 .expect("a nonparticipating store mutation must not invalidate the proof");
3595
3596 let source = session
3597 .db
3598 .store_handle(STORE_PATH)
3599 .expect("participating source store should resolve");
3600 source
3601 .mark_index_building()
3602 .expect("source access-state transition should advance its revision");
3603 assert!(matches!(
3604 session.verify_read_set_revision_proof(&proof),
3605 Err(ExhaustiveReadError::Revision(
3606 ReadSetRevisionError::StoreAccessChanged { .. }
3607 )),
3608 ));
3609 }
3610
3611 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3612 #[expect(
3613 clippy::too_many_lines,
3614 reason = "one lifecycle test proves successful replay plus pre-page and post-page source invalidation without sharing progress state across tests"
3615 )]
3616 #[test]
3617 fn journaled_job_advance_is_idempotent_and_revision_checked_on_both_sides() {
3618 let session = initialize_journaled();
3619 let proof = session
3620 .capture_read_set_revision_proof(&[ENTITY_NAME])
3621 .expect("journaled source proof should capture");
3622 let job_id =
3623 ResumableJobId::try_from_bytes([71; 32]).expect("nonzero job identity should admit");
3624 session
3625 .start_resumable_job(job_id, proof, vec![0])
3626 .expect("journaled job should start outside its protected source revision");
3627 let request = ResumableJobAdvanceRequest::new(
3628 job_id,
3629 0,
3630 ResumableJobIdempotencyKey::new("page-0")
3631 .expect("bounded idempotency key should admit"),
3632 );
3633 let calls = Cell::new(0_u8);
3634 let receipt = session
3635 .compare_proof_and_advance(&request, |state| {
3636 calls.set(calls.get() + 1);
3637 assert_eq!(state.application_state, vec![0]);
3638 Ok::<_, ()>(
3639 ResumableJobAdvance::new(Some("cursor-1".to_string()), vec![1], vec![9])
3640 .expect("bounded application advance should admit"),
3641 )
3642 })
3643 .expect("unchanged source should advance exactly once");
3644 assert_eq!(calls.get(), 1);
3645 assert_eq!(receipt.status, ResumableJobAdvanceStatus::Advanced);
3646 assert_eq!(receipt.committed_sequence, 1);
3647
3648 let replay = session
3649 .compare_proof_and_advance::<()>(&request, |_| {
3650 panic!("lost-response replay must not execute application work")
3651 })
3652 .expect("same request identity should return its persisted receipt");
3653 assert_eq!(replay, receipt);
3654 let retained = session
3655 .resumable_job_state(job_id)
3656 .expect("advanced state should remain durable");
3657 assert_eq!(retained.sequence, 1);
3658 assert_eq!(retained.application_state, vec![1]);
3659
3660 let _ = insert_exact_key_fixture(&session, 51);
3661 let pre_change_request = ResumableJobAdvanceRequest::new(
3662 job_id,
3663 1,
3664 ResumableJobIdempotencyKey::new("page-1")
3665 .expect("bounded idempotency key should admit"),
3666 );
3667 let pre_change_calls = Cell::new(0_u8);
3668 let invalidated = session
3669 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
3670 pre_change_calls.set(pre_change_calls.get() + 1);
3671 unreachable!("pre-page proof failure must reject before application work")
3672 })
3673 .expect("source drift should persist one replayable invalidation receipt");
3674 assert_eq!(pre_change_calls.get(), 0);
3675 assert_eq!(invalidated.status, ResumableJobAdvanceStatus::Invalidated);
3676 let invalidated_state = session
3677 .resumable_job_state(job_id)
3678 .expect("invalidated job should remain inspectable");
3679 assert_eq!(invalidated_state.status, ResumableJobStatus::Invalidated);
3680 assert_eq!(invalidated_state.continuation, None);
3681 assert_eq!(invalidated_state.application_state, vec![1]);
3682 assert_eq!(
3683 session
3684 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
3685 panic!("invalidation replay must not execute application work")
3686 })
3687 .expect("lost invalidation reply should replay exactly"),
3688 invalidated,
3689 );
3690
3691 let post_proof = session
3692 .capture_read_set_revision_proof(&[ENTITY_NAME])
3693 .expect("post-change journaled proof should capture");
3694 let post_job_id = ResumableJobId::try_from_bytes([72; 32])
3695 .expect("nonzero post-change job identity should admit");
3696 session
3697 .start_resumable_job(post_job_id, post_proof, vec![7])
3698 .expect("post-change journaled job should start");
3699 let post_request = ResumableJobAdvanceRequest::new(
3700 post_job_id,
3701 0,
3702 ResumableJobIdempotencyKey::new("post-page-0")
3703 .expect("bounded idempotency key should admit"),
3704 );
3705 let post_receipt = session
3706 .compare_proof_and_advance::<()>(&post_request, |_| {
3707 let _ = insert_exact_key_fixture(&session, 52);
3708 Ok(ResumableJobAdvance::new(None, vec![8], vec![10])
3709 .expect("bounded post-change candidate should admit"))
3710 })
3711 .expect("post-page drift should discard the candidate and persist invalidation");
3712 assert_eq!(post_receipt.status, ResumableJobAdvanceStatus::Invalidated);
3713 let post_state = session
3714 .resumable_job_state(post_job_id)
3715 .expect("post-page invalidation should remain inspectable");
3716 assert_eq!(post_state.status, ResumableJobStatus::Invalidated);
3717 assert_eq!(post_state.application_state, vec![7]);
3718 session
3719 .acknowledge_resumable_job(post_job_id, post_state.sequence)
3720 .expect("terminal job acknowledgement should remove retained progress");
3721 session
3722 .acknowledge_resumable_job(post_job_id, post_state.sequence)
3723 .expect("lost acknowledgement reply should be safely replayable");
3724 assert_eq!(
3725 session.resumable_job_state(post_job_id),
3726 Err(ResumableJobError::NotFound),
3727 );
3728
3729 let completed_job_id = ResumableJobId::try_from_bytes([74; 32])
3730 .expect("nonzero completed job identity should admit");
3731 let completed_proof = session
3732 .capture_read_set_revision_proof(&[ENTITY_NAME])
3733 .expect("completed-job source proof should capture");
3734 session
3735 .start_resumable_job(completed_job_id, completed_proof, Vec::new())
3736 .expect("completed-job fixture should start");
3737 let completed_request = ResumableJobAdvanceRequest::new(
3738 completed_job_id,
3739 0,
3740 ResumableJobIdempotencyKey::new("complete")
3741 .expect("bounded completion key should admit"),
3742 );
3743 let completed_receipt = session
3744 .compare_proof_and_advance::<()>(&completed_request, |_| {
3745 Ok(ResumableJobAdvance::new(None, vec![99], vec![100])
3746 .expect("bounded terminal advance should admit"))
3747 })
3748 .expect("null continuation should commit terminal completion");
3749 let completed_state = session
3750 .resumable_job_state(completed_job_id)
3751 .expect("completed state should remain replayable before acknowledgement");
3752 assert_eq!(completed_state.status, ResumableJobStatus::Completed);
3753 assert_eq!(
3754 session
3755 .compare_proof_and_advance::<()>(&completed_request, |_| {
3756 panic!("completed request replay must not execute application work")
3757 })
3758 .expect("completed request should replay until acknowledgement"),
3759 completed_receipt,
3760 );
3761 let after_completion = ResumableJobAdvanceRequest::new(
3762 completed_job_id,
3763 1,
3764 ResumableJobIdempotencyKey::new("after-complete")
3765 .expect("bounded post-completion key should admit"),
3766 );
3767 assert!(matches!(
3768 session.compare_proof_and_advance::<()>(&after_completion, |_| {
3769 panic!("completed jobs cannot execute another page")
3770 }),
3771 Err(CompareProofAndAdvanceError::Protocol(
3772 ResumableJobError::Completed
3773 )),
3774 ));
3775 session
3776 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
3777 .expect("completed job should acknowledge and free capacity");
3778 session
3779 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
3780 .expect("completion acknowledgement should be idempotent");
3781
3782 let stale_job_id = ResumableJobId::try_from_bytes([73; 32])
3783 .expect("nonzero stale-sequence job identity should admit");
3784 let stale_proof = session
3785 .capture_read_set_revision_proof(&[ENTITY_NAME])
3786 .expect("stale-sequence source proof should capture");
3787 session
3788 .start_resumable_job(stale_job_id, stale_proof, Vec::new())
3789 .expect("stale-sequence job should start");
3790 let stale_request = ResumableJobAdvanceRequest::new(
3791 stale_job_id,
3792 4,
3793 ResumableJobIdempotencyKey::new("stale").expect("bounded idempotency key should admit"),
3794 );
3795 assert!(matches!(
3796 session.compare_proof_and_advance::<()>(&stale_request, |_| {
3797 panic!("stale sequence must reject before application work")
3798 }),
3799 Err(CompareProofAndAdvanceError::Protocol(
3800 ResumableJobError::StaleSequence {
3801 expected: 4,
3802 actual: 0,
3803 }
3804 )),
3805 ));
3806 assert_eq!(
3807 session.acknowledge_resumable_job(stale_job_id, 0),
3808 Err(ResumableJobError::NotTerminal),
3809 );
3810 }
3811
3812 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3813 #[test]
3814 fn exact_key_batch_uses_typed_hard_execution_budget() {
3815 let session = initialize();
3816 let binding = exact_key_binding(&session);
3817 let budget =
3818 HardExecutionBudget::uniform_for_tests(0, HardExecutionFailureHeadroom::new(500, 256));
3819 let error = session
3820 .execute_exact_key_batch_with_hard_budget_for_tests(&binding, &[u64::MAX], &budget)
3821 .expect_err("zero query budget should reject the exact-key route");
3822
3823 assert!(matches!(
3824 error.diagnostic().detail(),
3825 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3826 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
3827 })
3828 ));
3829 let facts = error.diagnostic_facts();
3830 assert_eq!(
3831 &facts[..5],
3832 &[
3833 (
3834 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
3835 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions.raw(),
3836 ),
3837 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 0),
3838 (icydb_diagnostic_code::DiagnosticFactTag::Actual, 1),
3839 (
3840 icydb_diagnostic_code::DiagnosticFactTag::ExecutionBudgetScope,
3841 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution.raw(),
3842 ),
3843 (
3844 icydb_diagnostic_code::DiagnosticFactTag::ExecutionLane,
3845 icydb_diagnostic_code::DiagnosticExecutionLane::PublicRead.raw(),
3846 ),
3847 ],
3848 );
3849 assert_eq!(
3850 facts[5].0,
3851 icydb_diagnostic_code::DiagnosticFactTag::QueryShapeFingerprintPrefix,
3852 );
3853 assert_ne!(facts[5].1, 0);
3854 }
3855
3856 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3857 fn assert_planned_query_exhausts(
3858 session: &DbSession<TestCanister>,
3859 query: &crate::db::DynamicQuery,
3860 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
3861 ) {
3862 let budget = HardExecutionBudget::uniform_for_tests(
3863 u64::MAX,
3864 HardExecutionFailureHeadroom::new(500, 256),
3865 )
3866 .with_limit_for_tests(resource, 0);
3867 let context = HardExecutionContext::new(
3868 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
3869 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
3870 0x7068_7973_6963_616c,
3871 );
3872 let error = with_query_execution_budget_for_tests(budget, context, || {
3873 session.execute_trusted_live_page(query, None)
3874 })
3875 .expect_err("the injected zero resource allowance should reject planned execution");
3876
3877 assert!(matches!(
3878 error.diagnostic().detail(),
3879 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3880 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
3881 })
3882 ));
3883 assert_eq!(
3884 error.diagnostic_facts()[0],
3885 (
3886 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
3887 resource.raw(),
3888 ),
3889 );
3890 }
3891
3892 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3893 fn assert_grouped_query_exhausts(
3894 session: &DbSession<TestCanister>,
3895 query: &crate::db::DynamicQuery,
3896 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
3897 ) {
3898 let budget = HardExecutionBudget::uniform_for_tests(
3899 u64::MAX,
3900 HardExecutionFailureHeadroom::new(500, 256),
3901 )
3902 .with_limit_for_tests(resource, 0);
3903 let context = HardExecutionContext::new(
3904 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
3905 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
3906 0x6772_6f75_7065_642d,
3907 );
3908 let error = with_query_execution_budget_for_tests(budget, context, || {
3909 session.execute_trusted_dynamic_grouped_query(query)
3910 })
3911 .expect_err("the injected zero resource allowance should reject grouped execution");
3912
3913 assert!(matches!(
3914 error.diagnostic().detail(),
3915 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3916 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
3917 })
3918 ));
3919 assert_eq!(
3920 error.diagnostic_facts()[0],
3921 (
3922 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
3923 resource.raw(),
3924 ),
3925 );
3926 }
3927
3928 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3929 fn assert_sql_query_exhausts(
3930 session: &DbSession<TestCanister>,
3931 sql: &str,
3932 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
3933 ) {
3934 let budget = HardExecutionBudget::uniform_for_tests(
3935 u64::MAX,
3936 HardExecutionFailureHeadroom::new(500, 256),
3937 )
3938 .with_limit_for_tests(resource, 0);
3939 let context = HardExecutionContext::new(
3940 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
3941 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
3942 0x7371_6c2d_736f_7274,
3943 );
3944 let error = with_query_execution_budget_for_tests(budget, context, || {
3945 session.execute_trusted_sql_query(sql)
3946 })
3947 .expect_err("the injected zero resource allowance should reject SQL execution");
3948
3949 assert!(matches!(
3950 error.diagnostic().detail(),
3951 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3952 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
3953 })
3954 ));
3955 assert_eq!(
3956 error.diagnostic_facts()[0],
3957 (
3958 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
3959 resource.raw(),
3960 ),
3961 );
3962 }
3963
3964 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3965 #[test]
3966 fn planned_read_routes_share_physical_resource_accounting() {
3967 let session = initialize();
3968 let first = insert_exact_key_fixture(&session, 41);
3969 insert_exact_key_fixture(&session, 42);
3970
3971 let fallback = crate::db::DynamicQuery::new(ENTITY_NAME)
3972 .filter(crate::db::FieldRef::new("id").eq(first))
3973 .select(["id", "payload"])
3974 .order_by(crate::db::asc("id"))
3975 .limit(1);
3976 assert_eq!(
3977 session
3978 .execute_trusted_live_page(&fallback, None)
3979 .expect("bounded fallback execution should preserve its result")
3980 .row_count,
3981 1,
3982 );
3983 assert_planned_query_exhausts(
3984 &session,
3985 &fallback,
3986 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::RowsVisited,
3987 );
3988
3989 let covering = crate::db::DynamicQuery::new(ENTITY_NAME)
3990 .filter(crate::db::FieldRef::new("payload").eq(41_u64))
3991 .select(["payload"])
3992 .order_by(crate::db::asc("payload"))
3993 .limit(1);
3994 assert_eq!(
3995 session
3996 .execute_trusted_live_page(&covering, None)
3997 .expect("bounded covering execution should preserve its result")
3998 .row_count,
3999 1,
4000 );
4001 assert_planned_query_exhausts(
4002 &session,
4003 &covering,
4004 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
4005 );
4006
4007 let residual = crate::db::DynamicQuery::new(ENTITY_NAME)
4008 .filter(crate::db::FieldRef::new("payload").eq_field("id"))
4009 .select(["id"])
4010 .order_by(crate::db::asc("id"))
4011 .limit(1);
4012 assert_eq!(
4013 session
4014 .execute_trusted_live_page(&residual, None)
4015 .expect("bounded residual execution should preserve its result")
4016 .row_count,
4017 0,
4018 );
4019 assert_planned_query_exhausts(
4020 &session,
4021 &residual,
4022 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::PredicateExpressionSteps,
4023 );
4024
4025 assert_planned_query_exhausts(
4026 &session,
4027 &fallback,
4028 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::ResultBytes,
4029 );
4030
4031 let grouped = crate::db::DynamicQuery::new(ENTITY_NAME)
4032 .group_by("payload")
4033 .aggregate(crate::db::count())
4034 .order_by(crate::db::asc("payload"))
4035 .grouped_limits(10, 16 * 1_024)
4036 .limit(1);
4037 let grouped_result = session
4038 .execute_trusted_dynamic_grouped_query(&grouped)
4039 .expect("bounded grouped execution should preserve its result");
4040 assert_eq!(grouped_result.row_count, 1);
4041 assert!(grouped_result.next_cursor.is_some());
4042 assert_grouped_query_exhausts(
4043 &session,
4044 &grouped,
4045 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::GroupDistinctEntries,
4046 );
4047 assert_grouped_query_exhausts(
4048 &session,
4049 &grouped,
4050 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::CursorSteps,
4051 );
4052
4053 assert_sql_query_exhausts(
4054 &session,
4055 "SELECT payload, COUNT(*) AS row_count FROM IdentityRow \
4056 GROUP BY payload ORDER BY row_count DESC, payload ASC LIMIT 1",
4057 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::SortEntries,
4058 );
4059 }
4060
4061 fn assert_dynamic_payload<C: CanisterKind>(
4062 session: &DbSession<C>,
4063 key: u64,
4064 expected_payload: u64,
4065 ) {
4066 let unchanged = session
4067 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
4068 entity: ENTITY_NAME.to_string(),
4069 key: InputValue::Nat64(key),
4070 patch: dynamic_payload_patch(expected_payload),
4071 })
4072 .expect("the expected row should remain readable through a no-op update");
4073 assert_eq!(unchanged.affected_rows, 0);
4074 assert_eq!(
4075 unchanged.rows,
4076 vec![expected_dynamic_row(key, expected_payload)],
4077 );
4078 }
4079
4080 fn batch(values: &[u64]) -> Vec<AcceptedStructuralMutation> {
4081 values
4082 .iter()
4083 .map(|value| {
4084 AcceptedStructuralMutation::save(
4085 MutationMode::Insert,
4086 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
4087 payload_patch(*value),
4088 )
4089 })
4090 .collect()
4091 }
4092
4093 fn assert_identity_boundary(error: &InternalError) {
4094 assert_eq!(error.class(), ErrorClass::Unsupported);
4095 assert_eq!(error.origin(), ErrorOrigin::Identity);
4096 }
4097
4098 #[test]
4099 fn generated_candidate_collision_is_identity_corruption_before_generic_uniqueness() {
4100 let generated = insert_key_exists_after_generation(true);
4101 assert_eq!(generated.class(), ErrorClass::Corruption);
4102 assert_eq!(generated.origin(), ErrorOrigin::Identity);
4103
4104 let ordinary = insert_key_exists_after_generation(false);
4105 assert_ne!(ordinary.origin(), ErrorOrigin::Identity);
4106 }
4107
4108 #[cfg(target_pointer_width = "64")]
4109 #[test]
4110 fn pre_key_candidate_count_rejects_values_beyond_the_persisted_u32_bound() {
4111 let error = checked_pre_key_candidate_count(
4112 usize::try_from(u64::from(u32::MAX) + 1).expect("64-bit usize should hold u32 + 1"),
4113 )
4114 .expect_err("candidate counts beyond u32 must reject");
4115 assert_identity_boundary(&error);
4116 }
4117
4118 #[test]
4119 #[expect(
4120 clippy::too_many_lines,
4121 reason = "one holding lifecycle proves split, merge, transfer, late-failure neutrality, result order, and Identity state"
4122 )]
4123 fn mixed_structural_batch_preserves_holding_conservation_and_failure_atomicity() {
4124 let session = initialize();
4125 let seeded = session
4126 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
4127 .expect("seed rows should commit");
4128 assert_eq!(seeded.affected_rows, 1);
4129
4130 let split = session
4131 .execute_trusted_dynamic_mutation_batch(vec![
4132 DynamicMutation::Update {
4133 entity: ENTITY_NAME.to_string(),
4134 key: InputValue::Nat64(1),
4135 patch: dynamic_payload_patch(60),
4136 },
4137 DynamicMutation::Insert {
4138 entity: ENTITY_NAME.to_string(),
4139 patch: dynamic_payload_patch(40),
4140 },
4141 ])
4142 .expect("one holding should split atomically");
4143 assert_eq!(split.affected_rows, 2);
4144 assert_eq!(
4145 split.rows,
4146 vec![expected_dynamic_row(1, 60), expected_dynamic_row(2, 40),],
4147 "split after-images must retain input order and exact quantity",
4148 );
4149
4150 let rejected_split = session
4151 .execute_trusted_dynamic_mutation_batch(vec![
4152 DynamicMutation::Update {
4153 entity: ENTITY_NAME.to_string(),
4154 key: InputValue::Nat64(1),
4155 patch: dynamic_payload_patch(50),
4156 },
4157 DynamicMutation::Insert {
4158 entity: ENTITY_NAME.to_string(),
4159 patch: DynamicStructuralPatch::new(Vec::new()),
4160 },
4161 ])
4162 .expect_err("an invalid split output must reject the staged source update");
4163 assert_eq!(rejected_split.class(), ErrorClass::Unsupported);
4164 assert_eq!(rejected_split.origin(), ErrorOrigin::Executor);
4165 assert_eq!(
4166 rejected_split.diagnostic_facts(),
4167 vec![
4168 (
4169 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4170 ENTITY_TAG.value(),
4171 ),
4172 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 2),
4173 (
4174 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4175 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
4176 ),
4177 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1,),
4178 ],
4179 );
4180 assert_dynamic_payload(&session, 1, 60);
4181 assert_dynamic_payload(&session, 2, 40);
4182
4183 let transfer = session
4184 .execute_trusted_dynamic_mutation_batch(vec![
4185 DynamicMutation::Update {
4186 entity: ENTITY_NAME.to_string(),
4187 key: InputValue::Nat64(1),
4188 patch: dynamic_payload_patch(70),
4189 },
4190 DynamicMutation::Update {
4191 entity: ENTITY_NAME.to_string(),
4192 key: InputValue::Nat64(2),
4193 patch: dynamic_payload_patch(30),
4194 },
4195 ])
4196 .expect("distinct transfer patches should share one atomic batch");
4197 assert_eq!(
4198 transfer.rows,
4199 vec![expected_dynamic_row(1, 70), expected_dynamic_row(2, 30),],
4200 "the transfer must preserve the exact total quantity",
4201 );
4202
4203 let merge = session
4204 .execute_trusted_dynamic_mutation_batch(vec![
4205 DynamicMutation::Delete {
4206 entity: ENTITY_NAME.to_string(),
4207 key: InputValue::Nat64(2),
4208 },
4209 DynamicMutation::Update {
4210 entity: ENTITY_NAME.to_string(),
4211 key: InputValue::Nat64(1),
4212 patch: dynamic_payload_patch(100),
4213 },
4214 ])
4215 .expect("two holdings should merge atomically");
4216 assert_eq!(
4217 merge.rows,
4218 vec![expected_dynamic_row(2, 30), expected_dynamic_row(1, 100),],
4219 "delete before-images and update after-images must retain input order",
4220 );
4221
4222 let resplit = session
4223 .execute_trusted_dynamic_mutation_batch(vec![
4224 DynamicMutation::Update {
4225 entity: ENTITY_NAME.to_string(),
4226 key: InputValue::Nat64(1),
4227 patch: dynamic_payload_patch(60),
4228 },
4229 DynamicMutation::Insert {
4230 entity: ENTITY_NAME.to_string(),
4231 patch: dynamic_payload_patch(40),
4232 },
4233 ])
4234 .expect("the merged holding should split again");
4235 assert_eq!(
4236 resplit.rows,
4237 vec![expected_dynamic_row(1, 60), expected_dynamic_row(3, 40),],
4238 );
4239
4240 let rejected_merge = session
4241 .execute_trusted_dynamic_mutation_batch(vec![
4242 DynamicMutation::Delete {
4243 entity: ENTITY_NAME.to_string(),
4244 key: InputValue::Nat64(3),
4245 },
4246 DynamicMutation::Update {
4247 entity: ENTITY_NAME.to_string(),
4248 key: InputValue::Nat64(99),
4249 patch: dynamic_payload_patch(100),
4250 },
4251 ])
4252 .expect_err("a late missing merge target must preserve the earlier staged delete");
4253 assert_eq!(rejected_merge.class(), ErrorClass::NotFound);
4254 assert_dynamic_payload(&session, 1, 60);
4255 assert_dynamic_payload(&session, 3, 40);
4256
4257 SCHEMA_STORE.with(|store| {
4258 let cursor = store
4259 .borrow()
4260 .identity_statement_cursor(
4261 database_incarnation_id().expect("database incarnation should remain readable"),
4262 ENTITY_TAG,
4263 FieldId::new(1),
4264 &AcceptedFieldKind::Nat64,
4265 )
4266 .expect("mixed Identity state should remain readable");
4267 assert_eq!(cursor.expected_high_water(), 3);
4268 assert!(!cursor.has_allocations());
4269 });
4270 }
4271
4272 #[test]
4273 fn mixed_structural_batch_rejects_duplicate_holding_targets_without_mutation() {
4274 let session = initialize();
4275 session
4276 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
4277 .expect("the holding fixture should initialize");
4278
4279 let duplicate = session
4280 .execute_trusted_dynamic_mutation_batch(vec![
4281 DynamicMutation::Update {
4282 entity: ENTITY_NAME.to_string(),
4283 key: InputValue::Nat64(1),
4284 patch: dynamic_payload_patch(60),
4285 },
4286 DynamicMutation::Delete {
4287 entity: ENTITY_NAME.to_string(),
4288 key: InputValue::Nat64(1),
4289 },
4290 ])
4291 .expect_err("duplicate targets across operation kinds must reject");
4292 assert!(matches!(
4293 duplicate.diagnostic().detail(),
4294 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4295 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchDuplicateKey,
4296 }),
4297 ));
4298 assert_eq!(
4299 duplicate.diagnostic_facts(),
4300 vec![
4301 (
4302 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4303 ENTITY_TAG.value(),
4304 ),
4305 (
4306 icydb_diagnostic_code::DiagnosticFactTag::FirstBatchPosition,
4307 0,
4308 ),
4309 (
4310 icydb_diagnostic_code::DiagnosticFactTag::DuplicateBatchPosition,
4311 1,
4312 ),
4313 ],
4314 );
4315 assert_dynamic_payload(&session, 1, 100);
4316 }
4317
4318 #[test]
4319 fn mixed_structural_batch_rejects_empty_and_over_bound_before_resolution() {
4320 let session = initialize();
4321 let empty = session
4322 .execute_trusted_dynamic_mutation_batch(Vec::new())
4323 .expect_err("an empty public batch must reject");
4324 assert!(matches!(
4325 empty.diagnostic().detail(),
4326 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4327 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchEmpty,
4328 }),
4329 ));
4330 assert_eq!(
4331 empty.diagnostic_facts(),
4332 vec![(icydb_diagnostic_code::DiagnosticFactTag::ActualCount, 0,)],
4333 );
4334
4335 let requests = (0..=MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS)
4336 .map(|_| DynamicMutation::Delete {
4337 entity: ENTITY_NAME.to_string(),
4338 key: InputValue::Nat64(1),
4339 })
4340 .collect();
4341 let over_bound = session
4342 .execute_trusted_dynamic_mutation_batch(requests)
4343 .expect_err("operation cap plus one must reject before row resolution");
4344 assert!(matches!(
4345 over_bound.diagnostic().detail(),
4346 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4347 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchTooManyItems,
4348 }),
4349 ));
4350 assert_eq!(
4351 over_bound.diagnostic_facts(),
4352 vec![
4353 (
4354 icydb_diagnostic_code::DiagnosticFactTag::ActualCount,
4355 (MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS + 1) as u64,
4356 ),
4357 (
4358 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4359 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS as u64,
4360 ),
4361 ],
4362 );
4363 }
4364
4365 #[test]
4366 fn mixed_structural_batch_staged_byte_bound_uses_checked_exact_boundary() {
4367 let mut exact = 0;
4368 add_structural_mutation_staged_bytes(
4369 &mut exact,
4370 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES],
4371 )
4372 .expect("the exact staged-byte boundary should admit");
4373 assert_eq!(exact, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
4374
4375 let error = add_structural_mutation_staged_bytes(&mut exact, [1])
4376 .expect_err("one byte above the staged-byte boundary must reject");
4377 assert!(matches!(
4378 error.diagnostic().detail(),
4379 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4380 boundary:
4381 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchStagedBytesExceeded,
4382 }),
4383 ));
4384 assert_eq!(
4385 error.diagnostic_facts(),
4386 vec![
4387 (
4388 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
4389 (MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES + 1) as u64,
4390 ),
4391 (
4392 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4393 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES as u64,
4394 ),
4395 ],
4396 );
4397
4398 validate_structural_mutation_result_bytes(MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES)
4399 .expect("the exact result-byte boundary should admit");
4400 let error = validate_structural_mutation_result_bytes(
4401 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1,
4402 )
4403 .expect_err("one byte above the result-byte boundary must reject");
4404 assert!(matches!(
4405 error.diagnostic().detail(),
4406 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4407 boundary:
4408 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchResultBytesExceeded,
4409 }),
4410 ));
4411 assert_eq!(
4412 error.diagnostic_facts(),
4413 vec![
4414 (
4415 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
4416 (MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1) as u64,
4417 ),
4418 (
4419 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4420 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES as u64,
4421 ),
4422 ],
4423 );
4424 }
4425
4426 #[expect(
4427 clippy::too_many_lines,
4428 reason = "one lifecycle proves shared materialization and every maintained frontend against the same zero-state owner"
4429 )]
4430 #[test]
4431 fn identity_insert_frontends_share_one_committed_range_without_rejected_consumption() {
4432 let session = initialize();
4433 let catalog = session
4434 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
4435 .expect("identity catalog should resolve");
4436 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
4437 .expect("identity row layout should build");
4438 let initial_description = session
4439 .try_describe_entity_by_name(ENTITY_NAME)
4440 .expect("accepted Identity description should resolve");
4441 assert_eq!(
4442 initial_description.entity_tag(),
4443 catalog.identity().entity_tag().value()
4444 );
4445 assert_eq!(
4446 initial_description.accepted_schema_fingerprint_method(),
4447 catalog.fingerprint_method_version()
4448 );
4449 assert_eq!(
4450 initial_description.accepted_schema_fingerprint(),
4451 catalog.fingerprint()
4452 );
4453 let initial_identity = initial_description
4454 .identity()
4455 .expect("accepted Identity policy should be described");
4456 assert_eq!(initial_identity.field(), "id");
4457 assert_eq!(initial_identity.generator(), "Identity::next");
4458 assert_eq!(initial_identity.accepted_kind(), "nat64");
4459 assert_eq!(initial_identity.minimum(), 1);
4460 assert_eq!(initial_identity.maximum(), u128::from(u64::MAX));
4461 assert_eq!(initial_identity.high_water(), 0);
4462 assert_eq!(initial_identity.remaining(), u128::from(u64::MAX));
4463 assert!(!initial_identity.exhausted());
4464
4465 let rejected = session
4466 .execute_accepted_structural_save_batch(
4467 &catalog,
4468 &descriptor,
4469 batch(&[1_000, 2_000]),
4470 Timestamp::from_millis(6),
4471 |_| Err::<(), _>(InternalError::executor_unsupported()),
4472 )
4473 .expect_err("a rejected precommit result must not publish its tentative range");
4474 assert_eq!(rejected.class(), ErrorClass::Unsupported);
4475 assert_eq!(DATA_STORE.with(|store| store.borrow().len()), 0);
4476
4477 let rows = session
4478 .execute_accepted_structural_save_batch(
4479 &catalog,
4480 &descriptor,
4481 batch(&[10, 20, 30]),
4482 Timestamp::from_millis(7),
4483 Ok,
4484 )
4485 .expect("one accepted batch should commit rows and one identity range");
4486 assert_eq!(
4487 rows.into_iter().map(|row| row.values).collect::<Vec<_>>(),
4488 vec![
4489 vec![Value::Nat64(1), Value::Nat64(10)],
4490 vec![Value::Nat64(2), Value::Nat64(20)],
4491 vec![Value::Nat64(3), Value::Nat64(30)],
4492 ],
4493 );
4494
4495 let dynamic = session
4496 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4497 entity: ENTITY_NAME.to_string(),
4498 patch: DynamicStructuralPatch::new(vec![(
4499 "payload".to_string(),
4500 DynamicWriteCell::Value(InputValue::Nat64(40)),
4501 )]),
4502 })
4503 .expect("dynamic omission should commit through shared Identity generation");
4504 assert_eq!(dynamic.affected_rows, 1);
4505
4506 for (request, operation) in [
4507 (
4508 DynamicMutation::Insert {
4509 entity: ENTITY_NAME.to_string(),
4510 patch: DynamicStructuralPatch::new(vec![
4511 (
4512 "id".to_string(),
4513 DynamicWriteCell::Value(InputValue::Nat64(41)),
4514 ),
4515 (
4516 "payload".to_string(),
4517 DynamicWriteCell::Value(InputValue::Nat64(42)),
4518 ),
4519 ]),
4520 },
4521 icydb_diagnostic_code::DiagnosticMutationOperation::Insert,
4522 ),
4523 (
4524 DynamicMutation::Update {
4525 entity: ENTITY_NAME.to_string(),
4526 key: InputValue::Nat64(1),
4527 patch: DynamicStructuralPatch::new(vec![(
4528 "id".to_string(),
4529 DynamicWriteCell::Default,
4530 )]),
4531 },
4532 icydb_diagnostic_code::DiagnosticMutationOperation::Update,
4533 ),
4534 ] {
4535 let error = session
4536 .execute_trusted_dynamic_mutation(&request)
4537 .expect_err("structural Identity authorship and regeneration must reject");
4538 assert_eq!(error.class(), ErrorClass::Unsupported);
4539 assert_eq!(error.origin(), ErrorOrigin::Executor);
4540 assert_eq!(
4541 error.diagnostic_facts(),
4542 vec![
4543 (
4544 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4545 ENTITY_TAG.value(),
4546 ),
4547 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
4548 (
4549 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4550 operation.raw(),
4551 ),
4552 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,),
4553 ],
4554 );
4555 }
4556
4557 let binding = session
4558 .issue_typed_entity_binding(
4559 ENTITY_SOURCE,
4560 &[
4561 DynamicTypedFieldBindingRequest::new(
4562 ID_SOURCE.to_string(),
4563 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
4564 false,
4565 ),
4566 DynamicTypedFieldBindingRequest::new(
4567 PAYLOAD_SOURCE.to_string(),
4568 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
4569 false,
4570 ),
4571 ],
4572 )
4573 .expect("typed output should bind the Identity field");
4574 let typed_patch = binding
4575 .bind_write_fields(vec![(
4576 PAYLOAD_SOURCE.to_string(),
4577 DynamicWriteCell::Value(InputValue::Nat64(50)),
4578 )])
4579 .expect("typed payload should lower");
4580 let typed = session
4581 .execute_trusted_typed_mutation(
4582 &binding,
4583 &DynamicTypedMutation::Insert { patch: typed_patch },
4584 )
4585 .expect("typed omission should commit through shared Identity generation");
4586 assert_eq!(
4587 typed
4588 .expect("typed insert should return one mutation result")
4589 .affected_rows,
4590 1,
4591 );
4592 let explicit_typed_patch = binding
4593 .bind_write_fields(vec![
4594 (
4595 ID_SOURCE.to_string(),
4596 DynamicWriteCell::Value(InputValue::Nat64(51)),
4597 ),
4598 (
4599 PAYLOAD_SOURCE.to_string(),
4600 DynamicWriteCell::Value(InputValue::Nat64(52)),
4601 ),
4602 ])
4603 .expect("the low-level binding should retain exact authored intent");
4604 let explicit_typed_error = session
4605 .execute_trusted_typed_mutation(
4606 &binding,
4607 &DynamicTypedMutation::Insert {
4608 patch: explicit_typed_patch,
4609 },
4610 )
4611 .expect_err("typed Identity authorship must reject before allocation");
4612 assert_eq!(explicit_typed_error.class(), ErrorClass::Unsupported);
4613 assert_eq!(explicit_typed_error.origin(), ErrorOrigin::Executor);
4614 assert_eq!(
4615 explicit_typed_error.diagnostic_facts(),
4616 vec![
4617 (
4618 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4619 ENTITY_TAG.value(),
4620 ),
4621 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
4622 (
4623 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4624 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
4625 ),
4626 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,),
4627 ],
4628 );
4629
4630 let replace_error = session
4631 .execute_trusted_dynamic_mutation(&DynamicMutation::Replace {
4632 entity: ENTITY_NAME.to_string(),
4633 key: InputValue::Nat64(99),
4634 patch: DynamicStructuralPatch::new(vec![(
4635 "payload".to_string(),
4636 DynamicWriteCell::Value(InputValue::Nat64(60)),
4637 )]),
4638 })
4639 .expect_err("save-as-insert with a chosen Identity must reject");
4640 assert_eq!(replace_error.class(), ErrorClass::Unsupported);
4641 assert_eq!(replace_error.origin(), ErrorOrigin::Executor);
4642
4643 #[cfg(feature = "sql")]
4644 {
4645 for sql in [
4646 "INSERT INTO IdentityRow (payload) VALUES (70) RETURNING id, payload",
4647 "INSERT INTO IdentityRow (id, payload) VALUES (DEFAULT, 80) RETURNING id",
4648 ] {
4649 let _result = session
4650 .execute_trusted_sql_mutation(sql)
4651 .expect("SQL omission and DEFAULT should commit Identity generation");
4652 }
4653
4654 let error = session
4655 .execute_trusted_sql_mutation(
4656 "INSERT INTO IdentityRow (id, payload) VALUES (42, 90)",
4657 )
4658 .expect_err("an explicit SQL Identity value must reject before allocation");
4659 let diagnostic = error.diagnostic();
4660 assert_eq!(
4661 diagnostic.code(),
4662 icydb_diagnostic_code::DiagnosticCode::QuerySqlWriteBoundary,
4663 );
4664 assert!(matches!(
4665 diagnostic.detail(),
4666 Some(icydb_diagnostic_code::DiagnosticDetail::SqlWriteBoundary {
4667 boundary: icydb_diagnostic_code::SqlWriteBoundaryCode::ExplicitGeneratedField,
4668 }),
4669 ));
4670 }
4671
4672 let expected_committed = if cfg!(feature = "sql") { 7 } else { 5 };
4673 assert_eq!(
4674 DATA_STORE.with(|store| store.borrow().len()),
4675 expected_committed
4676 );
4677 SCHEMA_STORE.with(|store| {
4678 let cursor = store
4679 .borrow()
4680 .identity_statement_cursor(
4681 database_incarnation_id().expect("database incarnation should remain readable"),
4682 ENTITY_TAG,
4683 FieldId::new(1),
4684 &AcceptedFieldKind::Nat64,
4685 )
4686 .expect("committed writes must leave active state readable");
4687 assert_eq!(cursor.expected_high_water(), u128::from(expected_committed),);
4688 assert!(!cursor.has_allocations());
4689 });
4690 let committed_description = session
4691 .try_describe_entity_by_name(ENTITY_NAME)
4692 .expect("committed Identity description should resolve");
4693 let committed_identity = committed_description
4694 .identity()
4695 .expect("accepted Identity policy should remain described");
4696 assert_eq!(
4697 committed_identity.high_water(),
4698 u128::from(expected_committed),
4699 );
4700 assert_eq!(
4701 committed_identity.remaining(),
4702 u128::from(u64::MAX - expected_committed),
4703 );
4704 assert!(!committed_identity.exhausted());
4705 }
4706
4707 #[test]
4708 #[expect(
4709 clippy::too_many_lines,
4710 reason = "one ordered scenario exercises every durable interruption boundary, guarded recovery, derived rebuild, and both integrity tiers"
4711 )]
4712 fn journaled_identity_recovery_quiesces_every_publication_interruption_before_reallocation() {
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 for (ordinal, interruption) in [
4721 MutationCommitInterruption::MarkerPersisted,
4722 MutationCommitInterruption::JournalPublished,
4723 MutationCommitInterruption::RowsPublished,
4724 MutationCommitInterruption::StateMaterialized,
4725 ]
4726 .into_iter()
4727 .enumerate()
4728 {
4729 interrupt_next_mutation_commit_for_tests(interruption);
4730 let interrupted = session.execute_accepted_structural_save_batch(
4731 &catalog,
4732 &descriptor,
4733 batch(&[u64::try_from(ordinal).expect("ordinal should fit")]),
4734 Timestamp::from_millis(8),
4735 Ok,
4736 );
4737 assert!(
4738 interrupted.is_err(),
4739 "the selected durable boundary should interrupt",
4740 );
4741
4742 let committed = session
4743 .execute_accepted_structural_save_batch(
4744 &catalog,
4745 &descriptor,
4746 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
4747 Timestamp::from_millis(9),
4748 Ok,
4749 )
4750 .expect("the next mutation must recover before allocating");
4751 let expected_high_water =
4752 u64::try_from((ordinal + 1) * 2).expect("small test high-water should fit");
4753 assert_eq!(
4754 committed
4755 .into_iter()
4756 .map(|row| row.values)
4757 .collect::<Vec<_>>(),
4758 vec![vec![
4759 Value::Nat64(expected_high_water),
4760 Value::Nat64(100 + u64::try_from(ordinal).expect("ordinal should fit")),
4761 ]],
4762 );
4763 assert_eq!(
4764 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
4765 expected_high_water,
4766 );
4767 JOURNALED_SCHEMA_STORE.with(|store| {
4768 let cursor = store
4769 .borrow()
4770 .identity_statement_cursor(
4771 database_incarnation_id()
4772 .expect("database incarnation should remain readable"),
4773 ENTITY_TAG,
4774 FieldId::new(1),
4775 &AcceptedFieldKind::Nat64,
4776 )
4777 .expect("guarded recovery must leave quiescent active state");
4778 assert_eq!(
4779 cursor.expected_high_water(),
4780 u128::from(expected_high_water),
4781 );
4782 assert!(!cursor.has_allocations());
4783 });
4784 }
4785
4786 for (ordinal, (interruption, deleted_key)) in [
4787 (MutationCommitInterruption::MarkerPersisted, 2),
4788 (MutationCommitInterruption::JournalPublished, 4),
4789 (MutationCommitInterruption::RowPrefixPublished, 6),
4790 (MutationCommitInterruption::RowsPublished, 8),
4791 (MutationCommitInterruption::StateMaterialized, 7),
4792 ]
4793 .into_iter()
4794 .enumerate()
4795 {
4796 let expected_payload =
4797 501 + u64::try_from(ordinal).expect("small interruption ordinal should fit");
4798 interrupt_next_mutation_commit_for_tests(interruption);
4799 let interrupted = session.execute_trusted_dynamic_mutation_batch(vec![
4800 DynamicMutation::Update {
4801 entity: ENTITY_NAME.to_string(),
4802 key: InputValue::Nat64(1),
4803 patch: dynamic_payload_patch(expected_payload),
4804 },
4805 DynamicMutation::Delete {
4806 entity: ENTITY_NAME.to_string(),
4807 key: InputValue::Nat64(deleted_key),
4808 },
4809 ]);
4810 assert!(
4811 interrupted.is_err(),
4812 "the selected caller-key mixed publication boundary should interrupt",
4813 );
4814 let recovered_update = session
4815 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
4816 entity: ENTITY_NAME.to_string(),
4817 key: InputValue::Nat64(1),
4818 patch: dynamic_payload_patch(expected_payload),
4819 })
4820 .expect("guarded reentry should complete the marker-authorized mixed batch");
4821 assert_eq!(
4822 recovered_update.affected_rows, 0,
4823 "the recovered update must already expose its admitted final image",
4824 );
4825 let recovered_delete = session
4826 .execute_trusted_dynamic_mutation(&DynamicMutation::Delete {
4827 entity: ENTITY_NAME.to_string(),
4828 key: InputValue::Nat64(deleted_key),
4829 })
4830 .expect_err("the recovered delete must already be materialized");
4831 assert_eq!(recovered_delete.class(), ErrorClass::NotFound);
4832 JOURNALED_SCHEMA_STORE.with(|store| {
4833 let cursor = store
4834 .borrow()
4835 .identity_statement_cursor(
4836 database_incarnation_id()
4837 .expect("database incarnation should remain readable"),
4838 ENTITY_TAG,
4839 FieldId::new(1),
4840 &AcceptedFieldKind::Nat64,
4841 )
4842 .expect("caller-key recovery must preserve active Identity state");
4843 assert_eq!(cursor.expected_high_water(), 8);
4844 assert!(!cursor.has_allocations());
4845 });
4846 }
4847
4848 forget_recovered_domain_for_tests(&session.db)
4849 .expect("the final journal tail should remain recoverable");
4850 session
4851 .db
4852 .ensure_recovered_state()
4853 .expect("derived rebuild must not allocate another identity");
4854
4855 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
4856 let index_generation = JOURNALED_INDEX_STORE.with(|store| store.borrow().generation());
4857 forget_recovered_domain_for_tests(&session.db)
4858 .expect("an empty-tail upgrade should reset recovery ownership");
4859 session
4860 .db
4861 .ensure_recovered_state()
4862 .expect("an empty-tail upgrade should admit without rebuilding stored rows or indexes");
4863 assert_eq!(
4864 JOURNALED_DATA_STORE.with(|store| store.borrow().generation()),
4865 data_generation,
4866 "empty-tail recovery must not traverse or rewrite authoritative rows",
4867 );
4868 assert_eq!(
4869 JOURNALED_INDEX_STORE.with(|store| store.borrow().generation()),
4870 index_generation,
4871 "empty-tail recovery must not clear or rebuild secondary indexes",
4872 );
4873
4874 let quick = execute_quick_integrity(&session.db, catalog.inspection_plan())
4875 .expect("quiescent Identity control inventory should be inspectable");
4876 assert_eq!(quick.status(), &QuickIntegrityStatus::CompleteClean);
4877 let row_page = execute_row_integrity_page(
4878 &session.db,
4879 catalog.inspection_plan(),
4880 PhysicalUnitCheckpoint::BeforeFirst,
4881 RowInspectionLimits::standard(),
4882 )
4883 .expect("Identity rows should remain within committed high-water");
4884 assert!(row_page.exhausted());
4885 assert!(row_page.findings().is_empty());
4886
4887 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 3);
4888 assert!(
4889 JOURNALED_INDEX_STORE.with(|store| !store.borrow().is_empty()),
4890 "derived index rebuild should restore witnesses without allocating identities",
4891 );
4892 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
4893 JOURNALED_SCHEMA_STORE.with(|store| {
4894 let cursor = store
4895 .borrow()
4896 .identity_statement_cursor(
4897 database_incarnation_id().expect("database incarnation should remain readable"),
4898 ENTITY_TAG,
4899 FieldId::new(1),
4900 &AcceptedFieldKind::Nat64,
4901 )
4902 .expect("folded identity state should reopen without allocating");
4903 assert_eq!(cursor.expected_high_water(), 8);
4904 assert!(!cursor.has_allocations());
4905 });
4906 }
4907
4908 #[test]
4909 fn journaled_startup_recovery_resumes_by_durable_pages_without_reallocating_ids() {
4910 let session = initialize_journaled();
4911 let catalog = session
4912 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
4913 .expect("journaled identity catalog should resolve");
4914 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
4915 .expect("journaled identity row layout should build");
4916
4917 for payload in 0_u64..129 {
4918 session
4919 .execute_accepted_structural_save_batch(
4920 &catalog,
4921 &descriptor,
4922 batch(&[payload]),
4923 Timestamp::from_millis(8),
4924 Ok,
4925 )
4926 .expect("journaled identity fixture row should commit");
4927 }
4928
4929 forget_recovered_domain_for_tests(&session.db)
4930 .expect("upgrade should reset recovery ownership");
4931 assert!(
4932 !session
4933 .db
4934 .continue_startup_recovery()
4935 .expect("the first bounded recovery page should commit"),
4936 "one page must not consume a tail larger than the production page bound",
4937 );
4938 assert!(JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
4939 let mut pages = 1;
4940 while !session
4941 .db
4942 .continue_startup_recovery()
4943 .expect("each bounded recovery page should commit")
4944 {
4945 pages += 1;
4946 assert!(pages <= 4, "the small fixture should finish promptly");
4947 }
4948 assert!(pages >= 2);
4949
4950 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 129);
4951 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
4952 assert_dynamic_payload(&session, 1, 0);
4953 assert_dynamic_payload(&session, 129, 128);
4954 JOURNALED_SCHEMA_STORE.with(|store| {
4955 let cursor = store
4956 .borrow()
4957 .identity_statement_cursor(
4958 database_incarnation_id().expect("database incarnation should remain readable"),
4959 ENTITY_TAG,
4960 FieldId::new(1),
4961 &AcceptedFieldKind::Nat64,
4962 )
4963 .expect("paged recovery must preserve active Identity state");
4964 assert_eq!(cursor.expected_high_water(), 129);
4965 assert!(!cursor.has_allocations());
4966 });
4967 }
4968
4969 #[test]
4970 fn journaled_startup_recovery_resumes_within_one_large_batch() {
4971 let session = initialize_journaled();
4972 let catalog = session
4973 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
4974 .expect("journaled identity catalog should resolve");
4975 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
4976 .expect("journaled identity row layout should build");
4977 let payloads = (0_u64..129).collect::<Vec<_>>();
4978 session
4979 .execute_accepted_structural_save_batch(
4980 &catalog,
4981 &descriptor,
4982 batch(&payloads),
4983 Timestamp::from_millis(9),
4984 Ok,
4985 )
4986 .expect("one large journal batch should commit");
4987
4988 forget_recovered_domain_for_tests(&session.db)
4989 .expect("upgrade should reset recovery ownership");
4990 assert!(
4991 !session
4992 .db
4993 .continue_startup_recovery()
4994 .expect("the first record-bounded recovery page should commit"),
4995 "a single batch larger than the record bound must remain resumable",
4996 );
4997 JOURNALED_TAIL_STORE.with(|tail| {
4998 let tail = tail.borrow();
4999 let cursor = tail
5000 .fold_record_cursor()
5001 .expect("the fold cursor should decode")
5002 .expect("the incomplete batch should retain a fold cursor");
5003 assert_eq!(cursor.next_record_ordinal(), 128);
5004 assert!(tail.has_stored_batch());
5005 });
5006 assert!(
5007 session
5008 .db
5009 .continue_startup_recovery()
5010 .expect("the terminal record-bounded recovery page should commit"),
5011 );
5012
5013 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 129);
5014 JOURNALED_TAIL_STORE.with(|tail| {
5015 let tail = tail.borrow();
5016 assert!(!tail.has_stored_batch());
5017 assert!(!tail.has_fold_record_cursor());
5018 });
5019 assert_dynamic_payload(&session, 1, 0);
5020 assert_dynamic_payload(&session, 129, 128);
5021 }
5022
5023 #[test]
5024 #[ignore = "release-closeout native timing probe for one marker-authorized Identity recovery"]
5025 fn identity_recovery_closeout_reports_guarded_reentry_time() {
5026 let session = initialize_journaled();
5027 let catalog = session
5028 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5029 .expect("journaled identity catalog should resolve");
5030 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5031 .expect("journaled identity row layout should build");
5032
5033 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::RowsPublished);
5034 let interrupted = session.execute_accepted_structural_save_batch(
5035 &catalog,
5036 &descriptor,
5037 batch(&[1]),
5038 Timestamp::from_millis(10),
5039 Ok,
5040 );
5041 assert!(
5042 interrupted.is_err(),
5043 "the selected publication boundary should interrupt",
5044 );
5045
5046 let start = Instant::now();
5047 let committed = session
5048 .execute_accepted_structural_save_batch(
5049 &catalog,
5050 &descriptor,
5051 batch(&[2]),
5052 Timestamp::from_millis(11),
5053 Ok,
5054 )
5055 .expect("guarded reentry should recover before allocation");
5056 let elapsed = start.elapsed();
5057 assert_eq!(
5058 committed
5059 .into_iter()
5060 .map(|row| row.values)
5061 .collect::<Vec<_>>(),
5062 vec![vec![Value::Nat64(2), Value::Nat64(2)]],
5063 );
5064
5065 println!(
5066 "identity recovery closeout: guarded_reentry_nanos={}",
5067 elapsed.as_nanos(),
5068 );
5069 }
5070}
5071
5072#[cfg(test)]
5073mod targeted_rule_mutation_tests {
5074 use super::{
5075 DbSession, DynamicMutation, DynamicStructuralPatch, DynamicTypedFieldBindingRequest,
5076 DynamicTypedFieldType, DynamicTypedMutation, DynamicWriteCell,
5077 };
5078 use crate::{
5079 db::{
5080 data::{DataStore, encode_input_value_for_candidate_field_contract},
5081 index::IndexStore,
5082 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
5083 schema::{
5084 AcceptedCheckLiteralV1, AcceptedCompositeCatalog, AcceptedFieldDecodeContract,
5085 AcceptedFieldKind, AcceptedNamedTypeIdentity, AcceptedRuleOperation,
5086 AcceptedRuleTarget, AcceptedSchemaRevision, AcceptedSourceBindingCatalog,
5087 ConstraintOrigin, FieldId, FieldStorageDecode, FieldWriteManagement, LeafCodec,
5088 PersistedFieldSnapshot, PersistedNestedLeafSnapshot, PersistedSchemaSnapshot,
5089 ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy, SchemaInsertDefault,
5090 SchemaRowLayout, SchemaStore, SchemaVersion,
5091 accepted_schema_candidate_with_catalogs_for_tests,
5092 build_record_newtype_composite_catalog_for_tests,
5093 empty_accepted_enum_catalog_for_tests, enum_catalog::ValueAdmissionBudget,
5094 },
5095 },
5096 error::InternalError,
5097 traits::{CanisterKind, Path},
5098 types::EntityTag,
5099 value::InputValue,
5100 };
5101 use icydb_schema::{
5102 ConstraintSourceKey, EntitySourceKey, FieldSourceKey, ScalarType, TypeSourceKey,
5103 };
5104 use std::{cell::RefCell, collections::BTreeMap};
5105
5106 const STORE_PATH: &str = "session::write::targeted_rule_mutation_tests::Store";
5107 const ENTITY_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity";
5108 const ID_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::id";
5109 const PROFILE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::profile";
5110 const UPDATED_AT_SOURCE: &str =
5111 "session::write::targeted_rule_mutation_tests::Entity::updated_at";
5112 const PROFILE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Profile";
5113 const DEGREE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Degree";
5114 const DEGREE_MEMBER_SOURCE: &str =
5115 "session::write::targeted_rule_mutation_tests::Profile::degree";
5116 const DEGREE_RULE_SOURCE: &str =
5117 "session::write::targeted_rule_mutation_tests::Profile::degree_multiple";
5118
5119 struct TestCanister;
5120
5121 impl Path for TestCanister {
5122 const PATH: &'static str = "session::write::targeted_rule_mutation_tests::Canister";
5123 }
5124
5125 impl CanisterKind for TestCanister {
5126 const COMMIT_MEMORY_ID: u8 = 43;
5127 const COMMIT_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.commit.v1";
5128 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 44;
5129 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
5130 "icydb.targeted_mutation_tests.integrity.progress.v1";
5131 }
5132
5133 thread_local! {
5134 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
5135 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
5136 static SCHEMA_STORE: RefCell<SchemaStore> =
5137 const { RefCell::new(SchemaStore::init_heap()) };
5138 static STORE_REGISTRY: StoreRegistry = {
5139 let mut registry = StoreRegistry::new();
5140 registry.register_store(
5141 STORE_PATH,
5142 &DATA_STORE,
5143 &INDEX_STORE,
5144 &SCHEMA_STORE,
5145 StoreAllocationIdentities::absent(),
5146 StoreRuntimeStorageCapabilities::heap(),
5147 ).expect("targeted mutation test store should register");
5148 registry
5149 };
5150 }
5151
5152 fn source<T, E: std::fmt::Debug>(raw: &str, parse: impl FnOnce(String) -> Result<T, E>) -> T {
5153 parse(raw.to_string()).expect("test source identity should admit")
5154 }
5155
5156 fn profile_input(degree: u64) -> InputValue {
5157 InputValue::Map(vec![(
5158 InputValue::Text("degree".to_string()),
5159 InputValue::Nat64(degree),
5160 )])
5161 }
5162
5163 fn structural_patch(id: u64, degree: u64) -> DynamicStructuralPatch {
5164 DynamicStructuralPatch::new(vec![
5165 (
5166 "id".to_string(),
5167 DynamicWriteCell::Value(InputValue::Nat64(id)),
5168 ),
5169 (
5170 "profile".to_string(),
5171 DynamicWriteCell::Value(profile_input(degree)),
5172 ),
5173 ])
5174 }
5175
5176 fn encoded_value(
5177 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
5178 composite_catalog: &AcceptedCompositeCatalog,
5179 name: &str,
5180 kind: &AcceptedFieldKind,
5181 storage_decode: FieldStorageDecode,
5182 leaf_codec: LeafCodec,
5183 value: InputValue,
5184 ) -> Vec<u8> {
5185 let field = AcceptedFieldDecodeContract::new(name, kind, false, storage_decode, leaf_codec);
5186 encode_input_value_for_candidate_field_contract(
5187 enum_catalog,
5188 composite_catalog,
5189 field,
5190 value,
5191 &mut ValueAdmissionBudget::standard(),
5192 )
5193 .expect("test accepted value should encode")
5194 }
5195
5196 fn nat64_literal(
5197 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
5198 composite_catalog: &AcceptedCompositeCatalog,
5199 value: u64,
5200 ) -> AcceptedCheckLiteralV1 {
5201 let kind = AcceptedFieldKind::Nat64;
5202 AcceptedCheckLiteralV1::from_accepted_parts(
5203 kind.clone(),
5204 FieldStorageDecode::ByKind,
5205 LeafCodec::Scalar(ScalarCodec::Nat64),
5206 encoded_value(
5207 enum_catalog,
5208 composite_catalog,
5209 "degree_bound",
5210 &kind,
5211 FieldStorageDecode::ByKind,
5212 LeafCodec::Scalar(ScalarCodec::Nat64),
5213 InputValue::Nat64(value),
5214 ),
5215 )
5216 }
5217
5218 fn targeted_constraint_id(error: &InternalError) -> u32 {
5219 let facts = error.diagnostic_facts();
5220 assert!(facts.contains(&(
5221 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
5222 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
5223 )));
5224 assert!(facts.contains(&(icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,)));
5225 assert!(facts.contains(&(
5226 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
5227 icydb_diagnostic_code::DiagnosticConstraintKind::TargetedRule.raw(),
5228 )));
5229 assert_eq!(
5230 facts
5231 .iter()
5232 .filter(|(tag, _)| matches!(
5233 tag,
5234 icydb_diagnostic_code::DiagnosticFactTag::RootField
5235 | icydb_diagnostic_code::DiagnosticFactTag::RecordMember
5236 ))
5237 .copied()
5238 .collect::<Vec<_>>(),
5239 vec![
5240 (icydb_diagnostic_code::DiagnosticFactTag::RootField, 2),
5241 (
5242 icydb_diagnostic_code::DiagnosticFactTag::RecordMember,
5243 icydb_diagnostic_code::pack_u32_pair(1, 1),
5244 ),
5245 ]
5246 );
5247 let value = facts
5248 .iter()
5249 .find_map(|(tag, value)| {
5250 (*tag == icydb_diagnostic_code::DiagnosticFactTag::ConstraintId).then_some(*value)
5251 })
5252 .expect("targeted mutation should retain its accepted constraint ID");
5253 u32::try_from(value).expect("accepted constraint ID fits u32")
5254 }
5255
5256 #[expect(
5257 clippy::too_many_lines,
5258 reason = "one end-to-end fixture proves every maintained write frontend converges on the same accepted targeted-rule schedule"
5259 )]
5260 #[test]
5261 fn targeted_rules_converge_across_dynamic_typed_sql_default_timestamp_and_batch_writes() {
5262 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
5263 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
5264 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
5265
5266 let entity_tag = EntityTag::new(93);
5267 let enum_catalog = empty_accepted_enum_catalog_for_tests();
5268 let (composite_catalog, profile_type, degree_type, degree_member) =
5269 build_record_newtype_composite_catalog_for_tests(
5270 "tests::TargetedProfile".to_string(),
5271 "degree".to_string(),
5272 "tests::TargetedDegree".to_string(),
5273 AcceptedFieldKind::Nat64,
5274 &enum_catalog,
5275 )
5276 .expect("targeted mutation composites should close");
5277 let profile_kind = AcceptedFieldKind::Composite {
5278 type_id: profile_type,
5279 };
5280 let profile_default = encoded_value(
5281 &enum_catalog,
5282 &composite_catalog,
5283 "profile",
5284 &profile_kind,
5285 FieldStorageDecode::CatalogValue,
5286 LeafCodec::Structural,
5287 profile_input(12),
5288 );
5289 let fields = vec![
5290 PersistedFieldSnapshot::new_initial(
5291 FieldId::new(1),
5292 "id".to_string(),
5293 SchemaFieldSlot::new(0),
5294 AcceptedFieldKind::Nat64,
5295 Vec::new(),
5296 false,
5297 SchemaInsertDefault::None,
5298 FieldStorageDecode::ByKind,
5299 LeafCodec::Scalar(ScalarCodec::Nat64),
5300 ),
5301 PersistedFieldSnapshot::new_initial(
5302 FieldId::new(2),
5303 "profile".to_string(),
5304 SchemaFieldSlot::new(1),
5305 profile_kind,
5306 vec![PersistedNestedLeafSnapshot::new(
5307 vec!["degree".to_string()],
5308 AcceptedFieldKind::Composite {
5309 type_id: degree_type,
5310 },
5311 false,
5312 )],
5313 false,
5314 SchemaInsertDefault::SlotPayload(profile_default),
5315 FieldStorageDecode::CatalogValue,
5316 LeafCodec::Structural,
5317 ),
5318 PersistedFieldSnapshot::new_initial_with_write_policy(
5319 FieldId::new(3),
5320 "updated_at".to_string(),
5321 SchemaFieldSlot::new(2),
5322 AcceptedFieldKind::Timestamp,
5323 Vec::new(),
5324 false,
5325 SchemaInsertDefault::None,
5326 SchemaFieldWritePolicy::from_model_policies(
5327 None,
5328 Some(FieldWriteManagement::UpdatedAt),
5329 ),
5330 FieldStorageDecode::ByKind,
5331 LeafCodec::Scalar(ScalarCodec::Timestamp),
5332 ),
5333 ];
5334 let mut snapshot = PersistedSchemaSnapshot::new(
5335 SchemaVersion::initial(),
5336 ENTITY_SOURCE.to_string(),
5337 "TargetedMutation".to_string(),
5338 FieldId::new(1),
5339 SchemaRowLayout::initial(
5340 fields
5341 .iter()
5342 .map(|field| (field.id(), field.slot()))
5343 .collect(),
5344 ),
5345 fields,
5346 );
5347 let constraint_catalog = snapshot
5348 .constraint_catalog()
5349 .clone()
5350 .with_added_targeted_rule(
5351 "profile_degree_multiple".to_string(),
5352 ConstraintOrigin::Generated,
5353 AcceptedRuleTarget::new(
5354 FieldId::new(2),
5355 AcceptedNamedTypeIdentity::Composite(degree_type),
5356 ),
5357 AcceptedRuleOperation::MultipleOf {
5358 divisor: nat64_literal(&enum_catalog, &composite_catalog, 5),
5359 },
5360 )
5361 .expect("targeted mutation rule should allocate");
5362 let targeted_rule_id = constraint_catalog
5363 .constraints()
5364 .last()
5365 .expect("targeted mutation rule should persist")
5366 .id();
5367 snapshot = snapshot.with_constraint_catalog(constraint_catalog);
5368
5369 let entity_source = source(ENTITY_SOURCE, EntitySourceKey::try_new);
5370 let id_source = source(ID_SOURCE, FieldSourceKey::try_new);
5371 let profile_source = source(PROFILE_SOURCE, FieldSourceKey::try_new);
5372 let updated_at_source = source(UPDATED_AT_SOURCE, FieldSourceKey::try_new);
5373 let profile_type_source = source(PROFILE_TYPE_SOURCE, TypeSourceKey::try_new);
5374 let degree_type_source = source(DEGREE_TYPE_SOURCE, TypeSourceKey::try_new);
5375 let degree_member_source = source(DEGREE_MEMBER_SOURCE, FieldSourceKey::try_new);
5376 let degree_rule_source = source(DEGREE_RULE_SOURCE, ConstraintSourceKey::try_new);
5377 let source_bindings = AcceptedSourceBindingCatalog::initial_for_tests(
5378 BTreeMap::from([(entity_source, entity_tag)]),
5379 BTreeMap::from([
5380 ((entity_tag, id_source), FieldId::new(1)),
5381 ((entity_tag, profile_source), FieldId::new(2)),
5382 ((entity_tag, updated_at_source), FieldId::new(3)),
5383 ]),
5384 BTreeMap::from([((entity_tag, degree_rule_source), targeted_rule_id)]),
5385 BTreeMap::new(),
5386 BTreeMap::new(),
5387 )
5388 .with_initial_named_types_for_tests(
5389 BTreeMap::from([
5390 (
5391 profile_type_source,
5392 AcceptedNamedTypeIdentity::Composite(profile_type),
5393 ),
5394 (
5395 degree_type_source,
5396 AcceptedNamedTypeIdentity::Composite(degree_type),
5397 ),
5398 ]),
5399 BTreeMap::new(),
5400 BTreeMap::from([((profile_type, degree_member_source), degree_member)]),
5401 );
5402 let candidate = accepted_schema_candidate_with_catalogs_for_tests(
5403 STORE_PATH,
5404 AcceptedSchemaRevision::INITIAL,
5405 enum_catalog,
5406 composite_catalog,
5407 source_bindings,
5408 BTreeMap::from([(entity_tag, snapshot)]),
5409 );
5410
5411 let session = DbSession::<TestCanister>::new(
5412 &STORE_REGISTRY,
5413 &crate::db::RequestExecutionRoot::__new_runtime_root(),
5414 );
5415 session
5416 .db
5417 .ensure_recovered_state()
5418 .expect("targeted mutation test database should initialize");
5419 let store = session
5420 .db
5421 .store_handle(STORE_PATH)
5422 .expect("targeted mutation test store should resolve");
5423 crate::db::commit::publish_accepted_schema_candidate(
5424 STORE_PATH,
5425 store,
5426 AcceptedSchemaRevision::NONE,
5427 &candidate,
5428 )
5429 .expect("targeted mutation candidate should publish");
5430
5431 let dynamic_error = session
5432 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5433 entity: "TargetedMutation".to_string(),
5434 patch: structural_patch(1, 12),
5435 })
5436 .expect_err("dynamic write must enforce the targeted rule");
5437 assert_eq!(
5438 targeted_constraint_id(&dynamic_error),
5439 targeted_rule_id.get()
5440 );
5441
5442 let binding = session
5443 .issue_typed_entity_binding(
5444 ENTITY_SOURCE,
5445 &[
5446 DynamicTypedFieldBindingRequest::new(
5447 ID_SOURCE.to_string(),
5448 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
5449 false,
5450 ),
5451 DynamicTypedFieldBindingRequest::new(
5452 PROFILE_SOURCE.to_string(),
5453 DynamicTypedFieldType::Named(PROFILE_TYPE_SOURCE.to_string()),
5454 false,
5455 ),
5456 DynamicTypedFieldBindingRequest::new(
5457 UPDATED_AT_SOURCE.to_string(),
5458 DynamicTypedFieldType::Scalar(ScalarType::Timestamp),
5459 false,
5460 ),
5461 ],
5462 )
5463 .expect("targeted typed binding should issue");
5464 let typed_patch = binding
5465 .bind_write_fields(vec![
5466 (
5467 ID_SOURCE.to_string(),
5468 DynamicWriteCell::Value(InputValue::Nat64(2)),
5469 ),
5470 (
5471 PROFILE_SOURCE.to_string(),
5472 DynamicWriteCell::Value(profile_input(12)),
5473 ),
5474 ])
5475 .expect("targeted typed patch should bind");
5476 let typed_error = session
5477 .execute_trusted_typed_mutation(
5478 &binding,
5479 &DynamicTypedMutation::Insert { patch: typed_patch },
5480 )
5481 .expect_err("typed write must enforce the targeted rule");
5482 assert_eq!(targeted_constraint_id(&typed_error), targeted_rule_id.get());
5483
5484 #[cfg(feature = "sql")]
5485 {
5486 let sql_error = session
5487 .execute_trusted_sql_mutation("INSERT INTO TargetedMutation (id) VALUES (3)")
5488 .expect_err("SQL default resolution must enforce the targeted rule");
5489 let crate::db::QueryError::Execute(execute) = sql_error else {
5490 panic!("targeted SQL write should fail at shared execution admission");
5491 };
5492 assert_eq!(
5493 targeted_constraint_id(execute.as_internal()),
5494 targeted_rule_id.get()
5495 );
5496 }
5497
5498 session
5499 .execute_trusted_dynamic_mutation_batch(vec![
5500 DynamicMutation::Insert {
5501 entity: "TargetedMutation".to_string(),
5502 patch: structural_patch(4, 5),
5503 },
5504 DynamicMutation::Insert {
5505 entity: "TargetedMutation".to_string(),
5506 patch: structural_patch(5, 12),
5507 },
5508 ])
5509 .expect_err("one invalid targeted value must reject the whole batch");
5510 assert_eq!(
5511 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
5512 Some(0),
5513 "no frontend or earlier valid batch row may escape targeted admission",
5514 );
5515
5516 let admitted = session
5517 .execute_trusted_dynamic_mutation_batch(vec![
5518 DynamicMutation::Insert {
5519 entity: "TargetedMutation".to_string(),
5520 patch: structural_patch(6, 5),
5521 },
5522 DynamicMutation::Insert {
5523 entity: "TargetedMutation".to_string(),
5524 patch: structural_patch(7, 10),
5525 },
5526 ])
5527 .expect("compliant targeted values should share one accepted batch");
5528 let [first, second] = admitted.rows.as_slice() else {
5529 panic!("the mixed targeted batch should return two rows");
5530 };
5531 let first_timestamp = first
5532 .get(2)
5533 .expect("the first mixed row should contain its managed timestamp");
5534 assert!(matches!(
5535 first_timestamp,
5536 crate::value::OutputValue::Timestamp(_)
5537 ));
5538 assert_eq!(
5539 second.get(2),
5540 Some(first_timestamp),
5541 "one accepted mixed batch must materialize one managed timestamp",
5542 );
5543 assert_eq!(
5544 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
5545 Some(2),
5546 );
5547 }
5548}