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