1use super::AcceptedSchemaCatalogContext;
8use crate::{
9 db::{
10 DbSession, DynamicMutation, DynamicMutationResult, DynamicStructuralPatch,
11 DynamicTypedBindingError, DynamicTypedEntityBinding, DynamicTypedFieldBindingRequest,
12 DynamicTypedFieldType, DynamicTypedMutation, DynamicTypedStructuralPatch, DynamicWriteCell,
13 commit::{CommitRowOp, database_incarnation_id},
14 data::{
15 AcceptedMutationIntentPatch, AcceptedPreKeyInsert, DecodedDataStoreKey, FieldSlot,
16 RawRow, StructuralRowContract, StructuralSlotReader,
17 canonical_row_from_raw_row_with_accepted_decode_contract,
18 resolve_existing_replace_structural_patch_with_accepted_contract,
19 resolve_insert_structural_patch_with_accepted_contract,
20 resolve_update_structural_patch_with_accepted_contract,
21 },
22 executor::{
23 AcceptedMutationConstraintScheduler, commit_structural_row_ops_with_window_for_path,
24 mutation_key_exists_error,
25 },
26 schema::{
27 AcceptedFieldKind, AcceptedIdentityAllocation, AcceptedRowLayoutRuntimeContract,
28 FieldId, FieldInsertGeneration, IdentityStatementCursor, lower_field_type,
29 output_value_from_runtime,
30 },
31 write_context::{AcceptedWriteContext, MutationMode},
32 },
33 error::{InternalError, MutationDiagnosticContext},
34 metrics::sink::{MetricsEvent, SaveMutationKind, record},
35 traits::CanisterKind,
36 types::{CurrentTimestamp, Timestamp},
37 value::{InputValue, Value},
38};
39use icydb_schema::{EntitySourceKey, FieldSourceKey, FieldType, TypeSourceKey};
40
41#[derive(Clone, Debug, Eq, PartialEq)]
42struct AcceptedIdentityInsertField {
43 field_id: FieldId,
44 field_slot: usize,
45 accepted_kind: AcceptedFieldKind,
46}
47
48pub(in crate::db::session) enum AcceptedStructuralMutationTarget {
51 ResolveFromAfterImage,
52 Expected(Box<DecodedDataStoreKey>),
53}
54
55impl AcceptedStructuralMutationTarget {
56 pub(in crate::db::session) fn expected(key: DecodedDataStoreKey) -> Self {
57 Self::Expected(Box::new(key))
58 }
59}
60
61pub(in crate::db::session) enum AcceptedStructuralMutation {
64 Save {
65 mode: MutationMode,
66 target: AcceptedStructuralMutationTarget,
67 patch: AcceptedMutationIntentPatch,
68 },
69 Delete {
70 key: Box<DecodedDataStoreKey>,
71 },
72}
73
74impl AcceptedStructuralMutation {
75 pub(in crate::db::session) const fn save(
76 mode: MutationMode,
77 target: AcceptedStructuralMutationTarget,
78 patch: AcceptedMutationIntentPatch,
79 ) -> Self {
80 Self::Save {
81 mode,
82 target,
83 patch,
84 }
85 }
86
87 pub(in crate::db::session) fn delete(key: DecodedDataStoreKey) -> Self {
88 Self::Delete { key: Box::new(key) }
89 }
90}
91
92const MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS: usize = 4_096;
93const MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES: usize = 16 * 1024 * 1024;
94const MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES: usize = 1024 * 1024;
95
96fn add_structural_mutation_staged_bytes(
97 total: &mut usize,
98 lengths: impl IntoIterator<Item = usize>,
99) -> Result<(), InternalError> {
100 for length in lengths {
101 *total = total.checked_add(length).ok_or_else(|| {
102 InternalError::mutation_batch_staged_bytes_exceeded(
103 None,
104 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
105 )
106 })?;
107 if *total > MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES {
108 return Err(InternalError::mutation_batch_staged_bytes_exceeded(
109 Some(*total),
110 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
111 ));
112 }
113 }
114 Ok(())
115}
116
117fn validate_structural_mutation_result_bytes(encoded_bytes: usize) -> Result<(), InternalError> {
118 if encoded_bytes > MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES {
119 return Err(InternalError::mutation_batch_result_bytes_exceeded(
120 encoded_bytes,
121 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES,
122 ));
123 }
124 Ok(())
125}
126
127pub(in crate::db::session) struct AcceptedStructuralMutationRow {
129 values: Vec<Value>,
130 logical_changed: bool,
131}
132
133impl AcceptedStructuralMutationRow {
134 #[cfg(feature = "sql")]
135 pub(in crate::db::session) fn into_values(self) -> Vec<Value> {
136 self.values
137 }
138
139 pub(in crate::db::session) const fn logical_changed(&self) -> bool {
140 self.logical_changed
141 }
142}
143
144const fn dynamic_mutation_mode(request: &DynamicMutation) -> Option<MutationMode> {
145 match request {
146 DynamicMutation::Insert { .. } => Some(MutationMode::Insert),
147 DynamicMutation::Update { .. } => Some(MutationMode::Update),
148 DynamicMutation::Replace { .. } => Some(MutationMode::Replace),
149 DynamicMutation::Delete { .. } => None,
150 }
151}
152
153const fn dynamic_typed_mutation_mode(request: &DynamicTypedMutation) -> MutationMode {
154 match request {
155 DynamicTypedMutation::Insert { .. } => MutationMode::Insert,
156 DynamicTypedMutation::Update { .. } => MutationMode::Update,
157 DynamicTypedMutation::Replace { .. } => MutationMode::Replace,
158 }
159}
160
161const fn diagnostic_mutation_operation(
162 mode: MutationMode,
163) -> icydb_diagnostic_code::DiagnosticMutationOperation {
164 match mode {
165 MutationMode::Insert => icydb_diagnostic_code::DiagnosticMutationOperation::Insert,
166 MutationMode::Replace => icydb_diagnostic_code::DiagnosticMutationOperation::Replace,
167 MutationMode::Update => icydb_diagnostic_code::DiagnosticMutationOperation::Update,
168 }
169}
170
171const fn mutation_diagnostic_context(
172 entity_tag: crate::types::EntityTag,
173 mode: MutationMode,
174 batch_position: u32,
175) -> MutationDiagnosticContext {
176 MutationDiagnosticContext::new(
177 entity_tag.value(),
178 diagnostic_mutation_operation(mode),
179 batch_position,
180 )
181}
182
183const fn dynamic_write_context(operation_timestamp: Timestamp) -> AcceptedWriteContext {
184 AcceptedWriteContext::new(operation_timestamp)
185}
186
187fn insert_key_exists_after_generation(identity_generated: bool) -> InternalError {
188 if identity_generated {
189 InternalError::identity_state_corruption()
190 } else {
191 mutation_key_exists_error()
192 }
193}
194
195fn dynamic_key(
196 entity_tag: crate::types::EntityTag,
197 key: &InputValue,
198) -> Result<DecodedDataStoreKey, InternalError> {
199 let value = key
200 .clone()
201 .try_into_runtime_non_enum()
202 .ok_or_else(InternalError::executor_unsupported)?;
203 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
204}
205
206fn lower_dynamic_patch(
207 entity_path: &str,
208 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
209 patch: &DynamicStructuralPatch,
210 mode: MutationMode,
211 mutation_context: MutationDiagnosticContext,
212) -> Result<AcceptedMutationIntentPatch, InternalError> {
213 let mut lowered = AcceptedMutationIntentPatch::new();
214 for (field_name, cell) in patch.fields() {
215 let slot = descriptor
216 .field_slot_index_by_name(field_name)
217 .ok_or_else(|| {
218 InternalError::mutation_structural_field_unknown(entity_path, field_name)
219 })?;
220 let field = descriptor
221 .field_for_slot_index(slot)
222 .ok_or_else(InternalError::executor_invariant)?;
223 if !matches!(cell, DynamicWriteCell::Omitted)
224 && (field.write_policy().insert_generation().is_some()
225 || field.write_policy().write_management().is_some())
226 {
227 return Err(InternalError::mutation_database_owned_field_explicit(
228 mutation_context,
229 field.field_id().get(),
230 ));
231 }
232 let slot = FieldSlot::from_validated_index(slot);
233 lowered = match cell {
234 DynamicWriteCell::Omitted => lowered,
235 DynamicWriteCell::Default => match mode {
236 MutationMode::Insert | MutationMode::Replace => {
237 lowered.set_explicit_insert_default(slot)
238 }
239 MutationMode::Update => lowered.set_explicit_update_default(slot),
240 },
241 DynamicWriteCell::Null => lowered.set_authored(slot, InputValue::Null),
242 DynamicWriteCell::Value(value) => lowered.set_authored(slot, value.clone()),
243 };
244 }
245 Ok(lowered)
246}
247
248fn lower_dynamic_mutation_intent(
249 entity_tag: crate::types::EntityTag,
250 entity_path: &str,
251 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
252 request: &DynamicMutation,
253 batch_position: u32,
254) -> Result<(AcceptedStructuralMutation, Option<SaveMutationKind>), InternalError> {
255 match request {
256 DynamicMutation::Insert { patch, .. } => {
257 let mode = MutationMode::Insert;
258 Ok((
259 AcceptedStructuralMutation::save(
260 mode,
261 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
262 lower_dynamic_patch(
263 entity_path,
264 descriptor,
265 patch,
266 mode,
267 mutation_diagnostic_context(entity_tag, mode, batch_position),
268 )?,
269 ),
270 Some(SaveMutationKind::Insert),
271 ))
272 }
273 DynamicMutation::Update { key, patch, .. }
274 | DynamicMutation::Replace { key, patch, .. } => {
275 let mode =
276 dynamic_mutation_mode(request).ok_or_else(InternalError::executor_invariant)?;
277 let kind = match mode {
278 MutationMode::Insert => SaveMutationKind::Insert,
279 MutationMode::Replace => SaveMutationKind::Replace,
280 MutationMode::Update => SaveMutationKind::Update,
281 };
282 Ok((
283 AcceptedStructuralMutation::save(
284 mode,
285 AcceptedStructuralMutationTarget::expected(dynamic_key(entity_tag, key)?),
286 lower_dynamic_patch(
287 entity_path,
288 descriptor,
289 patch,
290 mode,
291 mutation_diagnostic_context(entity_tag, mode, batch_position),
292 )?,
293 ),
294 Some(kind),
295 ))
296 }
297 DynamicMutation::Delete { key, .. } => Ok((
298 AcceptedStructuralMutation::delete(dynamic_key(entity_tag, key)?),
299 None,
300 )),
301 }
302}
303
304fn lower_typed_patch(
305 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
306 patch: &DynamicTypedStructuralPatch,
307 mode: MutationMode,
308 mutation_context: MutationDiagnosticContext,
309) -> Result<AcceptedMutationIntentPatch, InternalError> {
310 let mut lowered = AcceptedMutationIntentPatch::new();
311 for (field_id, slot, cell) in patch.fields() {
312 let slot_index = usize::from(*slot);
313 let field = descriptor
314 .field_for_slot_index(slot_index)
315 .ok_or_else(InternalError::store_invariant)?;
316 if field.field_id().get() != *field_id {
317 return Err(InternalError::store_invariant());
318 }
319 if !matches!(cell, DynamicWriteCell::Omitted)
320 && (field.write_policy().insert_generation().is_some()
321 || field.write_policy().write_management().is_some())
322 {
323 return Err(InternalError::mutation_database_owned_field_explicit(
324 mutation_context,
325 field.field_id().get(),
326 ));
327 }
328 let slot = FieldSlot::from_validated_index(slot_index);
329 lowered = match cell {
330 DynamicWriteCell::Omitted => lowered,
331 DynamicWriteCell::Default => match mode {
332 MutationMode::Insert | MutationMode::Replace => {
333 lowered.set_explicit_insert_default(slot)
334 }
335 MutationMode::Update => lowered.set_explicit_update_default(slot),
336 },
337 DynamicWriteCell::Null => lowered.set_authored(slot, InputValue::Null),
338 DynamicWriteCell::Value(value) => lowered.set_authored(slot, value.clone()),
339 };
340 }
341 Ok(lowered)
342}
343
344fn preserve_dynamic_replacement_identity(
345 key: &DecodedDataStoreKey,
346 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
347 mut patch: AcceptedMutationIntentPatch,
348) -> Result<AcceptedMutationIntentPatch, InternalError> {
349 let primary_key_slots = descriptor.primary_key_slot_indices();
350 let runtime_key = key.primary_key_runtime_value();
351 let components = match runtime_key {
352 Value::List(values) if primary_key_slots.len() > 1 => values,
353 value if primary_key_slots.len() == 1 => vec![value],
354 _ => return Err(InternalError::executor_invariant()),
355 };
356 if components.len() != primary_key_slots.len() {
357 return Err(InternalError::executor_invariant());
358 }
359
360 for (slot, value) in primary_key_slots.iter().copied().zip(components) {
361 let _ = descriptor
362 .field_for_slot_index(slot)
363 .ok_or_else(InternalError::executor_invariant)?;
364 let has_explicit_intent = patch
365 .entries()
366 .iter()
367 .any(|entry| entry.slot().index() == slot);
368 if has_explicit_intent {
369 continue;
370 }
371 let value = InputValue::try_from_runtime_non_enum(&value)
372 .ok_or_else(InternalError::executor_invariant)?;
373 patch =
374 patch.set_preserved_replacement_identity(FieldSlot::from_validated_index(slot), value);
375 }
376
377 Ok(patch)
378}
379
380fn accepted_identity_insert_field(
384 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
385) -> Result<Option<AcceptedIdentityInsertField>, InternalError> {
386 let mut identity = None;
387 for field in descriptor.fields() {
388 if field.write_policy().insert_generation() != Some(FieldInsertGeneration::Identity) {
389 continue;
390 }
391 let field_slot = usize::from(field.slot().get());
392 if identity
393 .replace(AcceptedIdentityInsertField {
394 field_id: field.field_id(),
395 field_slot,
396 accepted_kind: field.kind().clone(),
397 })
398 .is_some()
399 || descriptor.primary_key_slot_indices() != [field_slot]
400 {
401 return Err(InternalError::identity_corruption());
402 }
403 }
404 Ok(identity)
405}
406
407fn checked_pre_key_candidate_count(count: usize) -> Result<u32, InternalError> {
408 u32::try_from(count).map_err(|_| InternalError::identity_candidate_count_exhausted())
409}
410
411fn validate_identity_materialization(
412 entity_tag: crate::types::EntityTag,
413 identity_field: &AcceptedIdentityInsertField,
414 candidate: &AcceptedPreKeyInsert,
415 allocation: &AcceptedIdentityAllocation,
416 data_key: &DecodedDataStoreKey,
417 reader: &StructuralSlotReader<'_>,
418) -> Result<(), InternalError> {
419 let owner = allocation.owner();
420 let slot_value = reader.required_cached_value(identity_field.field_slot)?;
421 if candidate.entity_tag() != entity_tag
422 || candidate.input_ordinal() != allocation.input_ordinal()
423 || owner.entity_tag() != entity_tag
424 || owner.field_id() != identity_field.field_id
425 || allocation.field_slot() != identity_field.field_slot
426 || slot_value != allocation.value()
427 || data_key.primary_key_runtime_value() != *allocation.value()
428 {
429 return Err(InternalError::identity_corruption());
430 }
431 Ok(())
432}
433
434fn data_key_from_row(
435 entity_tag: crate::types::EntityTag,
436 contract: &StructuralRowContract,
437 row: &RawRow,
438) -> Result<DecodedDataStoreKey, InternalError> {
439 let reader =
440 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(row, contract)?;
441 let values = contract
442 .primary_key_slot_indices()
443 .iter()
444 .map(|slot| reader.required_cached_value(*slot).cloned())
445 .collect::<Result<Vec<_>, _>>()?;
446 let value = match values.as_slice() {
447 [value] => value.clone(),
448 _ => Value::List(values),
449 };
450 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
451}
452
453#[cfg(feature = "sql")]
454pub(in crate::db::session) fn structural_data_key_from_runtime_values(
455 entity_tag: crate::types::EntityTag,
456 values: Vec<Value>,
457) -> Result<DecodedDataStoreKey, InternalError> {
458 let value = match values.as_slice() {
459 [value] => value.clone(),
460 _ => Value::List(values),
461 };
462 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
463}
464
465fn validated_existing_row(
466 store: crate::db::registry::StoreHandle,
467 data_key: &DecodedDataStoreKey,
468 contract: &StructuralRowContract,
469) -> Result<Option<RawRow>, InternalError> {
470 let raw_key = data_key.to_raw()?;
471 let row = store.with_data(|data| data.get(&raw_key));
472 if let Some(row) = row.as_ref() {
473 let reader =
474 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(row, contract)?;
475 reader.validate_primary_key(data_key)?;
476 }
477 Ok(row)
478}
479
480fn prepare_dynamic_mutation_result(
481 catalog: &AcceptedSchemaCatalogContext,
482 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
483 rows: Vec<AcceptedStructuralMutationRow>,
484 enforce_mixed_batch_result_bound: bool,
485) -> Result<DynamicMutationResult, InternalError> {
486 let affected_rows = rows.iter().try_fold(0_u32, |total, row| {
487 total
488 .checked_add(u32::from(row.logical_changed()))
489 .ok_or_else(InternalError::executor_invariant)
490 })?;
491 let columns = descriptor
492 .fields()
493 .iter()
494 .map(|field| field.name().to_string())
495 .collect();
496 let rows = rows
497 .into_iter()
498 .map(|row| {
499 row.values
500 .iter()
501 .map(|value| {
502 output_value_from_runtime(catalog.enum_catalog(), value)
503 .map_err(|_| InternalError::store_invariant())
504 })
505 .collect::<Result<Vec<_>, _>>()
506 })
507 .collect::<Result<Vec<_>, _>>()?;
508 let result = DynamicMutationResult {
509 entity: catalog.snapshot().entity_name().to_string(),
510 columns,
511 rows,
512 affected_rows,
513 };
514 if enforce_mixed_batch_result_bound {
515 let encoded =
516 candid::encode_one(&result).map_err(|_| InternalError::executor_invariant())?;
517 validate_structural_mutation_result_bytes(encoded.len())?;
518 }
519 Ok(result)
520}
521
522fn dynamic_typed_field_type(
523 field_type: DynamicTypedFieldType,
524) -> Result<FieldType, DynamicTypedBindingError> {
525 match field_type {
526 DynamicTypedFieldType::Scalar(scalar) => Ok(FieldType::Scalar(scalar)),
527 DynamicTypedFieldType::List(item) => {
528 Ok(FieldType::List(Box::new(dynamic_typed_field_type(*item)?)))
529 }
530 DynamicTypedFieldType::Named(source_key) => TypeSourceKey::try_new(source_key)
531 .map(FieldType::Named)
532 .map_err(|_| DynamicTypedBindingError::FieldUnavailable),
533 }
534}
535
536fn typed_adapter_field_kind_matches(
537 accepted: &AcceptedFieldKind,
538 expected: &AcceptedFieldKind,
539) -> bool {
540 if accepted == expected {
541 return true;
542 }
543 match (accepted, expected) {
544 (AcceptedFieldKind::Relation { key_kind, .. }, expected) => {
545 typed_adapter_field_kind_matches(key_kind, expected)
546 }
547 (AcceptedFieldKind::List(accepted), AcceptedFieldKind::List(expected)) => {
548 typed_adapter_field_kind_matches(accepted, expected)
549 }
550 _ => false,
551 }
552}
553
554impl<C: CanisterKind> DbSession<C> {
555 pub fn issue_typed_entity_binding(
557 &self,
558 entity_source_key: &str,
559 field_requests: &[DynamicTypedFieldBindingRequest],
560 ) -> Result<DynamicTypedEntityBinding, DynamicTypedBindingError> {
561 let entity_source = EntitySourceKey::try_new(entity_source_key)
562 .map_err(|_| DynamicTypedBindingError::FieldUnavailable)?;
563 let field_requests = field_requests
564 .iter()
565 .map(|request| {
566 Ok((
567 FieldSourceKey::try_new(request.source_key.clone())
568 .map_err(|_| DynamicTypedBindingError::FieldUnavailable)?,
569 dynamic_typed_field_type(request.field_type.clone())?,
570 request.nullable,
571 ))
572 })
573 .collect::<Result<Vec<_>, DynamicTypedBindingError>>()?;
574 let catalog = self
575 .find_accepted_schema_catalog_context_for_entity_source_key(entity_source.as_str())?
576 .ok_or(DynamicTypedBindingError::FieldUnavailable)?;
577 let identity = catalog.identity();
578 if identity.entity_path() != entity_source.as_str() {
579 return Err(InternalError::store_invariant().into());
580 }
581 let store = self.db.recovered_store(identity.store_path())?;
582 let bundle = store
583 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
584 .ok_or_else(InternalError::store_invariant)?;
585 let entity_tag = identity.entity_tag();
586 if bundle.source_bindings().entity(&entity_source) != Some(entity_tag)
587 || bundle.revision() != catalog.revision()
588 {
589 return Err(InternalError::store_invariant().into());
590 }
591 let snapshot = bundle
592 .entity_snapshots()
593 .get(&entity_tag)
594 .ok_or_else(InternalError::store_invariant)?;
595 let descriptor =
596 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
597 let mut fields = Vec::with_capacity(field_requests.len());
598 for (source, field_type, nullable) in &field_requests {
599 let field_id = bundle
600 .source_bindings()
601 .field(entity_tag, source)
602 .ok_or(DynamicTypedBindingError::FieldUnavailable)?;
603 let field = snapshot
604 .fields()
605 .iter()
606 .find(|field| field.id() == field_id)
607 .ok_or_else(InternalError::store_invariant)?;
608 let runtime_field = descriptor
609 .field_for_slot_index(usize::from(field.slot().get()))
610 .ok_or_else(InternalError::store_invariant)?;
611 if runtime_field.field_id() != field_id {
612 return Err(InternalError::store_invariant().into());
613 }
614 let expected_kind = lower_field_type(field_type, |source| {
615 bundle.source_bindings().named_type(source)
616 })
617 .map_err(|_| DynamicTypedBindingError::IncompatibleField)?;
618 if field.nullable() != *nullable
619 || !typed_adapter_field_kind_matches(field.kind(), &expected_kind)
620 {
621 return Err(DynamicTypedBindingError::IncompatibleField);
622 }
623 fields.push((
624 source.as_str().to_string(),
625 field_id.get(),
626 field.slot().get(),
627 field.name().to_string(),
628 ));
629 }
630 let adapter_names = bundle.typed_adapter_names()?;
631
632 DynamicTypedEntityBinding::new(
633 database_incarnation_id()?.to_bytes(),
634 entity_source.as_str().to_string(),
635 snapshot.entity_name().to_string(),
636 entity_tag.value(),
637 catalog.revision().get(),
638 catalog.fingerprint(),
639 descriptor.current_layout_version().get(),
640 fields,
641 adapter_names.named_types,
642 adapter_names.enum_variants,
643 adapter_names.composite_fields,
644 )
645 .map_err(Into::into)
646 }
647
648 pub(in crate::db::session) fn current_typed_entity_binding_catalog(
649 &self,
650 binding: &DynamicTypedEntityBinding,
651 ) -> Result<Option<AcceptedSchemaCatalogContext>, InternalError> {
652 if database_incarnation_id()?.to_bytes() != binding.database_incarnation {
653 return Ok(None);
654 }
655 let Some(catalog) = self.find_accepted_schema_catalog_context_for_entity_source_key(
656 binding.entity_source.as_str(),
657 )?
658 else {
659 return Ok(None);
660 };
661 let descriptor =
662 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
663 let identity = catalog.identity();
664 if identity.entity_path() != binding.entity_source.as_str()
665 || identity.entity_tag().value() != binding.entity_tag
666 || catalog.revision().get() != binding.accepted_revision
667 || catalog.fingerprint() != binding.accepted_fingerprint
668 || descriptor.current_layout_version().get() != binding.entity_generation
669 {
670 return Ok(None);
671 }
672 let entity_source = EntitySourceKey::try_new(binding.entity_source.clone())
673 .map_err(|_| InternalError::store_invariant())?;
674 let store = self.db.recovered_store(identity.store_path())?;
675 let bundle = store
676 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
677 .ok_or_else(InternalError::store_invariant)?;
678 if bundle.revision() != catalog.revision()
679 || bundle.source_bindings().entity(&entity_source) != Some(identity.entity_tag())
680 {
681 return Ok(None);
682 }
683 let snapshot = bundle
684 .entity_snapshots()
685 .get(&identity.entity_tag())
686 .ok_or_else(InternalError::store_invariant)?;
687 for (source_key, expected_field_id, expected_slot) in binding.field_identity_bindings() {
688 let source = FieldSourceKey::try_new(source_key)
689 .map_err(|_| InternalError::store_invariant())?;
690 let Some(field_id) = bundle
691 .source_bindings()
692 .field(identity.entity_tag(), &source)
693 else {
694 return Ok(None);
695 };
696 let Some(field) = snapshot
697 .fields()
698 .iter()
699 .find(|field| field.id() == field_id)
700 else {
701 return Err(InternalError::store_invariant());
702 };
703 if field_id.get() != expected_field_id || field.slot().get() != expected_slot {
704 return Ok(None);
705 }
706 }
707 Ok(Some(catalog))
708 }
709
710 pub fn typed_entity_binding_is_current(
712 &self,
713 binding: &DynamicTypedEntityBinding,
714 ) -> Result<bool, InternalError> {
715 self.current_typed_entity_binding_catalog(binding)
716 .map(|catalog| catalog.is_some())
717 }
718
719 #[cfg(feature = "sql")]
722 pub(in crate::db::session) fn execute_accepted_structural_delete_batch(
723 &self,
724 catalog: &AcceptedSchemaCatalogContext,
725 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
726 keys: Vec<DecodedDataStoreKey>,
727 precommit_validation: impl FnOnce(&[Vec<Value>]) -> Result<(), InternalError>,
728 ) -> Result<Vec<Vec<Value>>, InternalError> {
729 let mutations = keys
730 .into_iter()
731 .map(AcceptedStructuralMutation::delete)
732 .collect();
733 self.execute_accepted_structural_mutation_batch_inner(
734 catalog,
735 descriptor,
736 mutations,
737 Timestamp::now(),
738 false,
739 |rows| {
740 let rows = rows
741 .into_iter()
742 .map(AcceptedStructuralMutationRow::into_values)
743 .collect::<Vec<_>>();
744 precommit_validation(rows.as_slice())?;
745 Ok(rows)
746 },
747 )
748 }
749
750 pub(in crate::db::session) fn execute_accepted_structural_save_batch<T>(
758 &self,
759 catalog: &AcceptedSchemaCatalogContext,
760 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
761 mutations: Vec<AcceptedStructuralMutation>,
762 operation_timestamp: Timestamp,
763 precommit_preparation: impl FnOnce(
764 Vec<AcceptedStructuralMutationRow>,
765 ) -> Result<T, InternalError>,
766 ) -> Result<T, InternalError> {
767 self.execute_accepted_structural_mutation_batch_inner(
768 catalog,
769 descriptor,
770 mutations,
771 operation_timestamp,
772 false,
773 precommit_preparation,
774 )
775 }
776
777 #[cfg(feature = "sql")]
779 pub(in crate::db::session) fn execute_accepted_structural_update_prefix(
780 &self,
781 catalog: &AcceptedSchemaCatalogContext,
782 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
783 mutations: Vec<AcceptedStructuralMutation>,
784 operation_timestamp: Timestamp,
785 ) -> Result<usize, InternalError> {
786 self.execute_accepted_structural_mutation_batch_inner(
787 catalog,
788 descriptor,
789 mutations,
790 operation_timestamp,
791 true,
792 |rows| Ok(rows.len()),
793 )
794 }
795
796 #[expect(
797 clippy::too_many_lines,
798 reason = "one phased owner keeps accepted authority, mutation context, precommit preparation, output capture, and commit staging inseparable"
799 )]
800 fn execute_accepted_structural_mutation_batch_inner<T>(
801 &self,
802 catalog: &AcceptedSchemaCatalogContext,
803 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
804 mutations: Vec<AcceptedStructuralMutation>,
805 operation_timestamp: Timestamp,
806 largest_journaled_prefix: bool,
807 precommit_preparation: impl FnOnce(
808 Vec<AcceptedStructuralMutationRow>,
809 ) -> Result<T, InternalError>,
810 ) -> Result<T, InternalError> {
811 let identity = catalog.identity();
812 let entity_path = identity.entity_path();
813 let store_path = identity.store_path();
814 let row_decode_contract =
815 descriptor.row_decode_contract(catalog.value_catalog_handle().clone());
816 let row_contract = StructuralRowContract::from_accepted_decode_contract(
817 entity_path,
818 row_decode_contract.clone(),
819 );
820 let store = self.db.recovered_store(store_path)?;
821 let write_context = dynamic_write_context(operation_timestamp);
822 let identity_field = accepted_identity_insert_field(descriptor)?;
823 let identity_incarnation = identity_field
824 .as_ref()
825 .map(|_| database_incarnation_id())
826 .transpose()?;
827 let mutation_count = mutations.len();
828 if mutation_count > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
829 return Err(InternalError::mutation_batch_too_many_items(
830 mutation_count,
831 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
832 ));
833 }
834 let identity_candidate_count = mutations
835 .iter()
836 .filter(|mutation| {
837 matches!(
838 mutation,
839 AcceptedStructuralMutation::Save {
840 mode: MutationMode::Insert,
841 target: AcceptedStructuralMutationTarget::ResolveFromAfterImage,
842 ..
843 }
844 )
845 })
846 .count();
847 let _ = checked_pre_key_candidate_count(identity_candidate_count)?;
848 let mut identity_cursor: Option<IdentityStatementCursor> = None;
849 let mut identity_insert_ordinal = 0_u32;
850 let mut scheduler = AcceptedMutationConstraintScheduler::new(
851 entity_path,
852 identity.entity_tag(),
853 row_decode_contract.clone(),
854 catalog.fingerprint(),
855 catalog.fingerprint_method_version(),
856 catalog.accepted_row_constraints(),
857 mutation_count,
858 );
859 let mut output = Vec::with_capacity(mutation_count);
860 let mut staged_bytes = 0_usize;
861
862 for (input_index, mutation) in mutations.into_iter().enumerate() {
863 let batch_input_ordinal = u32::try_from(input_index).map_err(|_| {
864 InternalError::mutation_batch_too_many_items(
865 mutation_count,
866 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
867 )
868 })?;
869 let AcceptedStructuralMutation::Save {
870 mode,
871 target,
872 patch: authored_patch,
873 } = mutation
874 else {
875 let AcceptedStructuralMutation::Delete { key } = mutation else {
876 return Err(InternalError::executor_invariant());
877 };
878 let before = validated_existing_row(store, &key, &row_contract)?
879 .ok_or_else(|| InternalError::store_not_found(&key))?;
880 let raw_key = key.to_raw()?;
881 let canonical_before = canonical_row_from_raw_row_with_accepted_decode_contract(
882 entity_path,
883 row_decode_contract.clone(),
884 &before,
885 )?;
886 add_structural_mutation_staged_bytes(
887 &mut staged_bytes,
888 [
889 raw_key.as_bytes().len(),
890 canonical_before.as_raw_row().as_bytes().len(),
891 ],
892 )?;
893 scheduler.schedule_delete(
894 CommitRowOp::new(
895 entity_path,
896 raw_key,
897 Some(canonical_before.as_raw_row().as_bytes().to_vec()),
898 None,
899 catalog.fingerprint(),
900 ),
901 batch_input_ordinal,
902 )?;
903 let reader = StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
904 canonical_before.as_raw_row(),
905 &row_contract,
906 )?;
907 let mut values = Vec::with_capacity(descriptor.fields().len());
908 for field in descriptor.fields() {
909 values.push(
910 reader
911 .required_cached_value(usize::from(field.slot().get()))?
912 .clone(),
913 );
914 }
915 output.push(AcceptedStructuralMutationRow {
916 values,
917 logical_changed: true,
918 });
919 continue;
920 };
921 let mutation_context =
922 mutation_diagnostic_context(identity.entity_tag(), mode, batch_input_ordinal);
923 let (expected_key, pre_key_insert, mut keyed_patch) = match target {
924 AcceptedStructuralMutationTarget::ResolveFromAfterImage => {
925 let candidate_ordinal =
926 if identity_field.is_some() && matches!(mode, MutationMode::Insert) {
927 identity_insert_ordinal
928 } else {
929 batch_input_ordinal
930 };
931 (
932 None,
933 Some(AcceptedPreKeyInsert::new(
934 identity.entity_tag(),
935 authored_patch,
936 candidate_ordinal,
937 )),
938 None,
939 )
940 }
941 AcceptedStructuralMutationTarget::Expected(key) => {
942 (Some(*key), None, Some(authored_patch))
943 }
944 };
945 if matches!(mode, MutationMode::Replace)
946 && let Some(key) = expected_key.as_ref()
947 {
948 let patch = keyed_patch
949 .take()
950 .ok_or_else(InternalError::executor_invariant)?;
951 keyed_patch = Some(preserve_dynamic_replacement_identity(
952 key, descriptor, patch,
953 )?);
954 }
955 let patch = pre_key_insert
956 .as_ref()
957 .map(AcceptedPreKeyInsert::fields)
958 .or(keyed_patch.as_ref())
959 .ok_or_else(InternalError::executor_invariant)?;
960 let before = expected_key
961 .as_ref()
962 .map(|key| validated_existing_row(store, key, &row_contract))
963 .transpose()?
964 .flatten();
965 match mode {
966 MutationMode::Insert if before.is_some() => {
967 return Err(mutation_key_exists_error());
968 }
969 MutationMode::Update if before.is_none() => {
970 let key = expected_key
971 .as_ref()
972 .ok_or_else(InternalError::executor_invariant)?;
973 return Err(InternalError::store_not_found(key));
974 }
975 MutationMode::Insert | MutationMode::Replace | MutationMode::Update => {}
976 }
977
978 let identity_allocation = if let Some(identity_field) = identity_field.as_ref()
979 && matches!(mode, MutationMode::Insert)
980 && before.is_none()
981 {
982 let candidate = pre_key_insert.as_ref().ok_or_else(|| {
983 InternalError::mutation_database_owned_field_explicit(
984 mutation_context,
985 identity_field.field_id.get(),
986 )
987 })?;
988 if identity_cursor.is_none() {
989 let incarnation = identity_incarnation
990 .ok_or_else(InternalError::identity_state_corruption)?;
991 identity_cursor = Some(store.with_schema(|schema_store| {
992 schema_store.identity_statement_cursor(
993 incarnation,
994 identity.entity_tag(),
995 identity_field.field_id,
996 &identity_field.accepted_kind,
997 )
998 })?);
999 }
1000 let allocation = identity_cursor
1001 .as_mut()
1002 .ok_or_else(InternalError::identity_state_corruption)?
1003 .allocate(identity_field.field_slot, candidate.input_ordinal())?;
1004 identity_insert_ordinal = identity_insert_ordinal
1005 .checked_add(1)
1006 .ok_or_else(InternalError::identity_candidate_count_exhausted)?;
1007 Some(allocation)
1008 } else if let Some(identity_field) = identity_field.as_ref()
1009 && matches!(mode, MutationMode::Replace)
1010 && before.is_none()
1011 {
1012 return Err(InternalError::mutation_database_owned_field_explicit(
1013 mutation_context,
1014 identity_field.field_id.get(),
1015 ));
1016 } else {
1017 None
1018 };
1019
1020 let resolved = match (mode, before.as_ref()) {
1021 (MutationMode::Insert | MutationMode::Replace, None) => {
1022 resolve_insert_structural_patch_with_accepted_contract(
1023 entity_path,
1024 row_decode_contract.clone(),
1025 catalog.fingerprint(),
1026 catalog.accepted_row_constraints(),
1027 patch,
1028 write_context,
1029 mutation_context,
1030 identity_allocation.as_ref(),
1031 )?
1032 }
1033 (MutationMode::Update, Some(before)) => {
1034 resolve_update_structural_patch_with_accepted_contract(
1035 entity_path,
1036 row_decode_contract.clone(),
1037 catalog.fingerprint(),
1038 catalog.accepted_row_constraints(),
1039 before,
1040 patch,
1041 write_context,
1042 mutation_context,
1043 )?
1044 }
1045 (MutationMode::Replace, Some(before)) => {
1046 resolve_existing_replace_structural_patch_with_accepted_contract(
1047 entity_path,
1048 row_decode_contract.clone(),
1049 catalog.fingerprint(),
1050 catalog.accepted_row_constraints(),
1051 before,
1052 patch,
1053 write_context,
1054 mutation_context,
1055 )?
1056 }
1057 (MutationMode::Insert, Some(_)) | (MutationMode::Update, None) => {
1058 return Err(InternalError::executor_invariant());
1059 }
1060 };
1061 let (after, provenance) = resolved.into_parts();
1062 let reader = StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
1063 after.as_raw_row(),
1064 &row_contract,
1065 )?;
1066 let data_key = match expected_key {
1067 Some(key) => {
1068 reader.validate_primary_key(&key)?;
1069 key
1070 }
1071 None => {
1072 data_key_from_row(identity.entity_tag(), &row_contract, after.as_raw_row())?
1073 }
1074 };
1075 if let Some(allocation) = identity_allocation.as_ref() {
1076 validate_identity_materialization(
1077 identity.entity_tag(),
1078 identity_field
1079 .as_ref()
1080 .ok_or_else(InternalError::identity_corruption)?,
1081 pre_key_insert
1082 .as_ref()
1083 .ok_or_else(InternalError::identity_corruption)?,
1084 allocation,
1085 &data_key,
1086 &reader,
1087 )?;
1088 }
1089 if matches!(mode, MutationMode::Insert)
1090 && validated_existing_row(store, &data_key, &row_contract)?.is_some()
1091 {
1092 return Err(insert_key_exists_after_generation(
1093 identity_allocation.is_some(),
1094 ));
1095 }
1096 let raw_key = data_key.to_raw()?;
1097 let canonical_before = before
1098 .as_ref()
1099 .map(|before| {
1100 canonical_row_from_raw_row_with_accepted_decode_contract(
1101 entity_path,
1102 row_decode_contract.clone(),
1103 before,
1104 )
1105 })
1106 .transpose()?;
1107 let logical_changed = canonical_before.as_ref().is_none_or(|before| {
1108 before.as_raw_row().as_bytes() != after.as_raw_row().as_bytes()
1109 });
1110 let physical_changed = before
1111 .as_ref()
1112 .is_none_or(|before| before.as_bytes() != after.as_raw_row().as_bytes());
1113 add_structural_mutation_staged_bytes(
1114 &mut staged_bytes,
1115 [
1116 raw_key.as_bytes().len(),
1117 canonical_before
1118 .as_ref()
1119 .map_or(0, |before| before.as_raw_row().as_bytes().len()),
1120 after.as_raw_row().as_bytes().len(),
1121 ],
1122 )?;
1123 let row_op = physical_changed.then(|| {
1124 CommitRowOp::new(
1125 entity_path,
1126 raw_key.clone(),
1127 canonical_before
1128 .as_ref()
1129 .map(|before| before.as_raw_row().as_bytes().to_vec()),
1130 Some(after.as_raw_row().as_bytes().to_vec()),
1131 catalog.fingerprint(),
1132 )
1133 });
1134 scheduler.schedule_save_after_image(
1135 mode,
1136 &data_key,
1137 after.as_raw_row(),
1138 provenance.as_slice(),
1139 row_op,
1140 batch_input_ordinal,
1141 )?;
1142 if physical_changed {
1143 #[cfg(feature = "sql")]
1144 if largest_journaled_prefix
1145 && !crate::db::commit::journaled_row_ops_fit_commit_window(scheduler.rows())
1146 {
1147 scheduler.pop_last_save_row()?;
1148 if output.is_empty() {
1149 return Err(InternalError::query_sql_write_boundary(
1150 icydb_diagnostic_code::SqlWriteBoundaryCode::ResumableUpdateSingleRowResourceExceeded,
1151 ));
1152 }
1153 break;
1154 }
1155 }
1156
1157 let mut values = Vec::with_capacity(descriptor.fields().len());
1158 for field in descriptor.fields() {
1159 values.push(
1160 reader
1161 .required_cached_value(usize::from(field.slot().get()))?
1162 .clone(),
1163 );
1164 }
1165 output.push(AcceptedStructuralMutationRow {
1166 values,
1167 logical_changed,
1168 });
1169 }
1170
1171 #[cfg(not(feature = "sql"))]
1172 let _ = largest_journaled_prefix;
1173
1174 let batch = scheduler.finish();
1175 let prepared = precommit_preparation(output)?;
1176 let identity_ranges = identity_cursor
1177 .map(IdentityStatementCursor::into_range_advance)
1178 .transpose()?
1179 .into_iter()
1180 .flatten()
1181 .collect::<Vec<_>>();
1182 if batch.is_empty() && !identity_ranges.is_empty() {
1183 return Err(InternalError::identity_corruption());
1184 }
1185 if !batch.is_empty() {
1186 commit_structural_row_ops_with_window_for_path(
1187 &self.db,
1188 entity_path,
1189 batch,
1190 identity_ranges,
1191 "accepted_structural_batch_apply",
1192 )?;
1193 }
1194 Ok(prepared)
1195 }
1196
1197 fn execute_one_accepted_save_mutation(
1198 &self,
1199 catalog: &AcceptedSchemaCatalogContext,
1200 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
1201 mode: MutationMode,
1202 target: AcceptedStructuralMutationTarget,
1203 patch: AcceptedMutationIntentPatch,
1204 ) -> Result<DynamicMutationResult, InternalError> {
1205 let identity = catalog.identity();
1206 let entity_path = identity.entity_path();
1207 let result = self.execute_accepted_structural_save_batch(
1208 catalog,
1209 descriptor,
1210 vec![AcceptedStructuralMutation::save(mode, target, patch)],
1211 Timestamp::now(),
1212 |rows| prepare_dynamic_mutation_result(catalog, descriptor, rows, false),
1213 )?;
1214 record(MetricsEvent::SaveMutation {
1215 entity_path: entity_path.into(),
1216 kind: match mode {
1217 MutationMode::Insert => SaveMutationKind::Insert,
1218 MutationMode::Replace => SaveMutationKind::Replace,
1219 MutationMode::Update => SaveMutationKind::Update,
1220 },
1221 rows_touched: u64::from(result.affected_rows),
1222 });
1223 Ok(result)
1224 }
1225
1226 pub fn execute_trusted_dynamic_mutation(
1233 &self,
1234 request: &DynamicMutation,
1235 ) -> Result<DynamicMutationResult, InternalError> {
1236 self.execute_trusted_dynamic_mutation_batch_with_result_policy(vec![request.clone()], false)
1237 }
1238
1239 pub fn execute_trusted_dynamic_mutation_batch(
1245 &self,
1246 requests: Vec<DynamicMutation>,
1247 ) -> Result<DynamicMutationResult, InternalError> {
1248 self.execute_trusted_dynamic_mutation_batch_with_result_policy(requests, true)
1249 }
1250
1251 fn execute_trusted_dynamic_mutation_batch_with_result_policy(
1252 &self,
1253 requests: Vec<DynamicMutation>,
1254 enforce_mixed_batch_result_bound: bool,
1255 ) -> Result<DynamicMutationResult, InternalError> {
1256 if requests.is_empty() {
1257 return Err(InternalError::mutation_batch_empty());
1258 }
1259 if requests.len() > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1260 return Err(InternalError::mutation_batch_too_many_items(
1261 requests.len(),
1262 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1263 ));
1264 }
1265 let first = requests
1266 .first()
1267 .ok_or_else(InternalError::mutation_batch_empty)?;
1268 if first.entity().is_empty() {
1269 return Err(InternalError::executor_unsupported());
1270 }
1271 let catalog = self.accepted_schema_catalog_context_for_entity_name(Some(first.entity()))?;
1272 let accepted_identity = catalog.identity();
1273 let descriptor =
1274 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1275 let mut mutations = Vec::with_capacity(requests.len());
1276 let mut save_kinds = Vec::with_capacity(requests.len());
1277
1278 for (batch_position, request) in requests.iter().enumerate() {
1279 let batch_position = u32::try_from(batch_position).map_err(|_| {
1280 InternalError::mutation_batch_too_many_items(
1281 requests.len(),
1282 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1283 )
1284 })?;
1285 if request.entity().is_empty() {
1286 return Err(InternalError::executor_unsupported());
1287 }
1288 let item_catalog =
1289 self.accepted_schema_catalog_context_for_entity_name(Some(request.entity()))?;
1290 if item_catalog.identity() != accepted_identity {
1291 return Err(InternalError::mutation_batch_entity_mismatch(
1292 batch_position,
1293 accepted_identity.entity_tag().value(),
1294 item_catalog.identity().entity_tag().value(),
1295 ));
1296 }
1297 let (mutation, save_kind) = lower_dynamic_mutation_intent(
1298 accepted_identity.entity_tag(),
1299 accepted_identity.entity_path(),
1300 &descriptor,
1301 request,
1302 batch_position,
1303 )?;
1304 mutations.push(mutation);
1305 save_kinds.push(save_kind);
1306 }
1307
1308 let entity_path = accepted_identity.entity_path_handle();
1309 let (result, metrics) = self.execute_accepted_structural_mutation_batch_inner(
1310 &catalog,
1311 &descriptor,
1312 mutations,
1313 Timestamp::now(),
1314 false,
1315 |rows| {
1316 if rows.len() != save_kinds.len() {
1317 return Err(InternalError::executor_invariant());
1318 }
1319 let metrics = rows
1320 .iter()
1321 .zip(save_kinds)
1322 .filter_map(|(row, kind)| kind.map(|kind| (kind, row.logical_changed())))
1323 .collect::<Vec<_>>();
1324 let result = prepare_dynamic_mutation_result(
1325 &catalog,
1326 &descriptor,
1327 rows,
1328 enforce_mixed_batch_result_bound,
1329 )?;
1330 Ok((result, metrics))
1331 },
1332 )?;
1333 for (kind, logical_changed) in metrics {
1334 record(MetricsEvent::SaveMutation {
1335 entity_path: entity_path.clone(),
1336 kind,
1337 rows_touched: u64::from(logical_changed),
1338 });
1339 }
1340 Ok(result)
1341 }
1342
1343 #[doc(hidden)]
1346 pub fn execute_trusted_typed_mutation(
1347 &self,
1348 binding: &DynamicTypedEntityBinding,
1349 request: &DynamicTypedMutation,
1350 ) -> Result<Option<DynamicMutationResult>, InternalError> {
1351 let Some(catalog) = self.current_typed_entity_binding_catalog(binding)? else {
1352 return Ok(None);
1353 };
1354 let identity = catalog.identity();
1355 let descriptor =
1356 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1357 let mode = dynamic_typed_mutation_mode(request);
1358 let (target, patch) = match request {
1359 DynamicTypedMutation::Insert { patch } => (
1360 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
1361 patch,
1362 ),
1363 DynamicTypedMutation::Update { key, patch }
1364 | DynamicTypedMutation::Replace { key, patch } => (
1365 AcceptedStructuralMutationTarget::expected(dynamic_key(
1366 identity.entity_tag(),
1367 key,
1368 )?),
1369 patch,
1370 ),
1371 };
1372 if !patch.is_bound_to(binding) {
1373 return Ok(None);
1374 }
1375 let patch = lower_typed_patch(
1376 &descriptor,
1377 patch,
1378 mode,
1379 mutation_diagnostic_context(identity.entity_tag(), mode, 0),
1380 )?;
1381 self.execute_one_accepted_save_mutation(&catalog, &descriptor, mode, target, patch)
1382 .map(Some)
1383 }
1384
1385 pub fn execute_trusted_dynamic_insert_batch(
1391 &self,
1392 entity: &str,
1393 patches: Vec<DynamicStructuralPatch>,
1394 ) -> Result<DynamicMutationResult, InternalError> {
1395 let mutations = patches
1396 .into_iter()
1397 .map(|patch| DynamicMutation::Insert {
1398 entity: entity.to_string(),
1399 patch,
1400 })
1401 .collect();
1402 self.execute_trusted_dynamic_mutation_batch_with_result_policy(mutations, false)
1403 }
1404}
1405
1406#[cfg(test)]
1407mod typed_adapter_tests {
1408 use super::{
1409 AcceptedFieldKind, DbSession, DynamicTypedBindingError, DynamicTypedFieldBindingRequest,
1410 DynamicTypedFieldType, DynamicTypedMutation, DynamicWriteCell, dynamic_typed_field_type,
1411 typed_adapter_field_kind_matches,
1412 };
1413 use crate::{
1414 db::{
1415 data::DataStore,
1416 index::IndexStore,
1417 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
1418 schema::{
1419 AcceptedSchemaRevision, FieldId, FieldStorageDecode, LeafCodec,
1420 PersistedFieldSnapshot, PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot,
1421 SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
1422 accepted_schema_candidate_with_field_bindings_for_tests,
1423 },
1424 },
1425 traits::{CanisterKind, Path},
1426 types::EntityTag,
1427 value::InputValue,
1428 };
1429 use icydb_schema::{EntitySourceKey, FieldSourceKey, ScalarType};
1430 use std::{cell::RefCell, collections::BTreeMap};
1431
1432 const STORE_PATH: &str = "session::write::typed_adapter_tests::Store";
1433 const ENTITY_SOURCE: &str = "session::write::typed_adapter_tests::Entity";
1434 const OTHER_ENTITY_SOURCE: &str = "session::write::typed_adapter_tests::OtherEntity";
1435 const ID_SOURCE: &str = "session::write::typed_adapter_tests::Entity::id";
1436 const VALUE_SOURCE: &str = "session::write::typed_adapter_tests::Entity::value";
1437 const REPLACEMENT_SOURCE: &str =
1438 "session::write::typed_adapter_tests::Entity::replacement_value";
1439 const OTHER_ID_SOURCE: &str = "session::write::typed_adapter_tests::OtherEntity::id";
1440
1441 struct TestCanister;
1442
1443 impl Path for TestCanister {
1444 const PATH: &'static str = "session::write::typed_adapter_tests::Canister";
1445 }
1446
1447 impl CanisterKind for TestCanister {
1448 const COMMIT_MEMORY_ID: u8 = 41;
1449 const COMMIT_STABLE_KEY: &'static str = "icydb.typed_adapter_tests.commit.v1";
1450 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 42;
1451 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
1452 "icydb.typed_adapter_tests.integrity.progress.v1";
1453 }
1454
1455 thread_local! {
1456 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
1457 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
1458 static SCHEMA_STORE: RefCell<SchemaStore> =
1459 const { RefCell::new(SchemaStore::init_heap()) };
1460 static STORE_REGISTRY: StoreRegistry = {
1461 let mut registry = StoreRegistry::new();
1462 registry.register_store(
1463 STORE_PATH,
1464 &DATA_STORE,
1465 &INDEX_STORE,
1466 &SCHEMA_STORE,
1467 StoreAllocationIdentities::absent(),
1468 StoreRuntimeStorageCapabilities::heap(),
1469 ).expect("typed adapter test store should register");
1470 registry
1471 };
1472 }
1473
1474 fn nat64_field(id: u32, name: &str, slot: u16) -> PersistedFieldSnapshot {
1475 PersistedFieldSnapshot::new_initial(
1476 FieldId::new(id),
1477 name.to_string(),
1478 SchemaFieldSlot::new(slot),
1479 AcceptedFieldKind::Nat64,
1480 Vec::new(),
1481 false,
1482 SchemaInsertDefault::None,
1483 FieldStorageDecode::ByKind,
1484 LeafCodec::Scalar(ScalarCodec::Nat64),
1485 )
1486 }
1487
1488 fn snapshot(
1489 entity_source: &str,
1490 entity_name: &str,
1491 fields: Vec<PersistedFieldSnapshot>,
1492 ) -> PersistedSchemaSnapshot {
1493 let layout = SchemaRowLayout::initial(
1494 fields
1495 .iter()
1496 .map(|field| (field.id(), field.slot()))
1497 .collect(),
1498 );
1499 PersistedSchemaSnapshot::new(
1500 SchemaVersion::initial(),
1501 entity_source.to_string(),
1502 entity_name.to_string(),
1503 FieldId::new(1),
1504 layout,
1505 fields,
1506 )
1507 }
1508
1509 fn field_source(source: &str) -> FieldSourceKey {
1510 FieldSourceKey::try_new(source).expect("typed field source should admit")
1511 }
1512
1513 fn entity_source(source: &str) -> EntitySourceKey {
1514 EntitySourceKey::try_new(source).expect("typed entity source should admit")
1515 }
1516
1517 fn publish(
1518 session: &DbSession<TestCanister>,
1519 expected: AcceptedSchemaRevision,
1520 revision: AcceptedSchemaRevision,
1521 snapshots: BTreeMap<EntityTag, PersistedSchemaSnapshot>,
1522 fields: BTreeMap<(EntityTag, FieldSourceKey), FieldId>,
1523 ) {
1524 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
1525 STORE_PATH, revision, snapshots, fields,
1526 );
1527 let store = session
1528 .db
1529 .store_handle(STORE_PATH)
1530 .expect("typed adapter test store should resolve");
1531 crate::db::commit::publish_accepted_schema_candidate(
1532 STORE_PATH, store, expected, &candidate,
1533 )
1534 .expect("typed binding candidate should publish");
1535 }
1536
1537 fn request(source: &str) -> DynamicTypedFieldBindingRequest {
1538 DynamicTypedFieldBindingRequest::new(
1539 source.to_string(),
1540 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
1541 false,
1542 )
1543 }
1544
1545 fn assert_query_diagnostic(
1546 error: crate::db::QueryError,
1547 code: icydb_diagnostic_code::DiagnosticCode,
1548 origin: icydb_diagnostic_code::ErrorOrigin,
1549 detail: icydb_diagnostic_code::DiagnosticDetail,
1550 ) {
1551 let diagnostic = error.diagnostic();
1552 assert_eq!(diagnostic.code(), code);
1553 assert_eq!(diagnostic.origin(), origin);
1554 assert_eq!(diagnostic.detail(), Some(&detail));
1555 }
1556
1557 #[test]
1558 fn typed_adapter_kind_matching_is_exact_but_accepts_relation_key_wrappers() {
1559 let relation = AcceptedFieldKind::Relation {
1560 target_path: "test::Target".to_string(),
1561 target_entity_name: "Target".to_string(),
1562 target_entity_tag: EntityTag::new(7),
1563 target_store_path: "test::Store".to_string(),
1564 key_kind: Box::new(AcceptedFieldKind::Nat64),
1565 };
1566
1567 assert!(typed_adapter_field_kind_matches(
1568 &relation,
1569 &AcceptedFieldKind::Nat64,
1570 ));
1571 assert!(typed_adapter_field_kind_matches(
1572 &AcceptedFieldKind::List(Box::new(relation)),
1573 &AcceptedFieldKind::List(Box::new(AcceptedFieldKind::Nat64)),
1574 ));
1575 assert!(!typed_adapter_field_kind_matches(
1576 &AcceptedFieldKind::Nat64,
1577 &AcceptedFieldKind::Nat32,
1578 ));
1579 }
1580
1581 #[test]
1582 fn typed_adapter_field_contract_rejects_invalid_named_source_identity() {
1583 assert!(matches!(
1584 dynamic_typed_field_type(DynamicTypedFieldType::Named(String::new())),
1585 Err(DynamicTypedBindingError::FieldUnavailable),
1586 ));
1587 assert!(matches!(
1588 dynamic_typed_field_type(DynamicTypedFieldType::Scalar(ScalarType::Nat16)),
1589 Ok(icydb_schema::FieldType::Scalar(ScalarType::Nat16)),
1590 ));
1591 }
1592
1593 #[expect(clippy::too_many_lines)]
1596 #[test]
1597 fn typed_binding_uses_accepted_ids_and_slots_across_renames_and_name_reuse() {
1598 let entity_tag = EntityTag::new(91);
1599 let other_entity_tag = EntityTag::new(92);
1600 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
1601 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
1602 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
1603
1604 let session = DbSession::<TestCanister>::new(&STORE_REGISTRY);
1605 session
1606 .db
1607 .ensure_recovered_state()
1608 .expect("typed adapter test database should initialize");
1609 publish(
1610 &session,
1611 AcceptedSchemaRevision::NONE,
1612 AcceptedSchemaRevision::INITIAL,
1613 BTreeMap::from([(
1614 entity_tag,
1615 snapshot(
1616 ENTITY_SOURCE,
1617 "Entity",
1618 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
1619 ),
1620 )]),
1621 BTreeMap::from([
1622 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1623 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
1624 ]),
1625 );
1626
1627 let initial_catalog = session
1628 .find_accepted_schema_catalog_context_for_entity_source_key(ENTITY_SOURCE)
1629 .expect("initial source catalog lookup should inspect")
1630 .expect("initial source catalog should exist");
1631 assert_eq!(initial_catalog.identity().entity_tag(), entity_tag);
1632 let initial = session
1633 .issue_typed_entity_binding(
1634 entity_source(ENTITY_SOURCE).as_str(),
1635 &[request(ID_SOURCE), request(VALUE_SOURCE)],
1636 )
1637 .expect("initial typed binding should issue");
1638 assert_eq!(initial.field_slot(ID_SOURCE), Some(0));
1639 assert_eq!(initial.field_slot(VALUE_SOURCE), Some(1));
1640 assert_eq!(initial.output_field_slot("value"), Some(1));
1641 let initial_patch = initial
1642 .bind_write_fields(vec![(
1643 VALUE_SOURCE.to_string(),
1644 DynamicWriteCell::Value(InputValue::Nat64(7)),
1645 )])
1646 .expect("source-bound patch should lower");
1647 assert_eq!(
1648 initial_patch.fields(),
1649 &[(2, 1, DynamicWriteCell::Value(InputValue::Nat64(7)))]
1650 );
1651
1652 publish(
1653 &session,
1654 AcceptedSchemaRevision::INITIAL,
1655 AcceptedSchemaRevision::new(2),
1656 BTreeMap::from([
1657 (
1658 entity_tag,
1659 snapshot(
1660 ENTITY_SOURCE,
1661 "RenamedEntity",
1662 vec![
1663 nat64_field(1, "id", 0),
1664 nat64_field(2, "renamed_value", 1),
1665 nat64_field(3, "value", 2),
1666 ],
1667 ),
1668 ),
1669 (
1670 other_entity_tag,
1671 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
1672 ),
1673 ]),
1674 BTreeMap::from([
1675 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1676 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
1677 (
1678 (entity_tag, field_source(REPLACEMENT_SOURCE)),
1679 FieldId::new(3),
1680 ),
1681 (
1682 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
1683 FieldId::new(1),
1684 ),
1685 ]),
1686 );
1687
1688 let stale_authority = session
1689 .ensure_accepted_schema_authority_is_current_for_store_path(
1690 STORE_PATH,
1691 initial_catalog.value_catalog_handle().authority(),
1692 )
1693 .expect_err("the initial accepted authority must be stale after revision two");
1694 assert_eq!(
1695 stale_authority.diagnostic_facts(),
1696 vec![
1697 (
1698 icydb_diagnostic_code::DiagnosticFactTag::ExpectedRevision,
1699 AcceptedSchemaRevision::INITIAL.get(),
1700 ),
1701 (
1702 icydb_diagnostic_code::DiagnosticFactTag::CurrentRevision,
1703 AcceptedSchemaRevision::new(2).get(),
1704 ),
1705 ],
1706 );
1707
1708 assert!(
1709 !session
1710 .typed_entity_binding_is_current(&initial)
1711 .expect("renamed binding currentness should inspect")
1712 );
1713 let renamed = session
1714 .issue_typed_entity_binding(ENTITY_SOURCE, &[request(ID_SOURCE), request(VALUE_SOURCE)])
1715 .expect("renamed source-bound adapter should rebind");
1716 assert_eq!(renamed.entity(), "RenamedEntity");
1717 assert_eq!(renamed.field_slot(VALUE_SOURCE), Some(1));
1718 assert_eq!(renamed.output_field_slot("renamed_value"), Some(1));
1719 assert_eq!(renamed.output_field_slot("value"), None);
1720
1721 publish(
1722 &session,
1723 AcceptedSchemaRevision::new(2),
1724 AcceptedSchemaRevision::new(3),
1725 BTreeMap::from([
1726 (
1727 entity_tag,
1728 snapshot(
1729 ENTITY_SOURCE,
1730 "RenamedEntity",
1731 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
1732 ),
1733 ),
1734 (
1735 other_entity_tag,
1736 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
1737 ),
1738 ]),
1739 BTreeMap::from([
1740 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1741 (
1742 (entity_tag, field_source(REPLACEMENT_SOURCE)),
1743 FieldId::new(2),
1744 ),
1745 (
1746 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
1747 FieldId::new(1),
1748 ),
1749 ]),
1750 );
1751
1752 assert!(matches!(
1753 session.issue_typed_entity_binding(
1754 ENTITY_SOURCE,
1755 &[request(ID_SOURCE), request(VALUE_SOURCE)],
1756 ),
1757 Err(DynamicTypedBindingError::FieldUnavailable),
1758 ));
1759 assert!(
1760 !session
1761 .typed_entity_binding_is_current(&renamed)
1762 .expect("removed source binding should become stale")
1763 );
1764
1765 let replacement = session
1766 .issue_typed_entity_binding(
1767 ENTITY_SOURCE,
1768 &[request(ID_SOURCE), request(REPLACEMENT_SOURCE)],
1769 )
1770 .expect("explicit replacement source should bind");
1771 assert!(
1772 session
1773 .execute_trusted_typed_mutation(
1774 &replacement,
1775 &DynamicTypedMutation::Insert {
1776 patch: initial_patch
1777 },
1778 )
1779 .expect("cross-binding patch should fail closed")
1780 .is_none()
1781 );
1782 let patch = replacement
1783 .bind_write_fields(vec![
1784 (
1785 ID_SOURCE.to_string(),
1786 DynamicWriteCell::Value(InputValue::Nat64(1)),
1787 ),
1788 (
1789 REPLACEMENT_SOURCE.to_string(),
1790 DynamicWriteCell::Value(InputValue::Nat64(9)),
1791 ),
1792 ])
1793 .expect("replacement source write should bind by accepted IDs and slots");
1794 let result = session
1795 .execute_trusted_typed_mutation(&replacement, &DynamicTypedMutation::Insert { patch })
1796 .expect("typed insert should use the accepted mutation pipeline")
1797 .expect("replacement binding should remain current");
1798 assert_eq!(result.entity, "RenamedEntity");
1799 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
1800 assert_eq!(
1801 result.rows,
1802 vec![vec![
1803 crate::value::OutputValue::Nat64(1),
1804 crate::value::OutputValue::Nat64(9)
1805 ]]
1806 );
1807 assert_eq!(result.affected_rows, 1);
1808
1809 let second_patch = replacement
1810 .bind_write_fields(vec![
1811 (
1812 ID_SOURCE.to_string(),
1813 DynamicWriteCell::Value(InputValue::Nat64(2)),
1814 ),
1815 (
1816 REPLACEMENT_SOURCE.to_string(),
1817 DynamicWriteCell::Value(InputValue::Nat64(10)),
1818 ),
1819 ])
1820 .expect("second source-bound patch should lower");
1821 session
1822 .execute_trusted_typed_mutation(
1823 &replacement,
1824 &DynamicTypedMutation::Insert {
1825 patch: second_patch,
1826 },
1827 )
1828 .expect("second typed insert should use the accepted mutation pipeline")
1829 .expect("replacement binding should remain current");
1830
1831 {
1832 let query = crate::db::DynamicQuery::new("RenamedEntity")
1833 .select(["id", "value"])
1834 .order_by(crate::db::asc("id"))
1835 .limit(1);
1836 let result = session
1837 .execute_trusted_dynamic_query(&query)
1838 .expect("SQL-free dynamic execution should use accepted authority");
1839 assert_eq!(result.entity, "RenamedEntity");
1840 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
1841 assert_eq!(
1842 result.rows,
1843 vec![vec![
1844 crate::value::OutputValue::Nat64(1),
1845 crate::value::OutputValue::Nat64(9)
1846 ]]
1847 );
1848 assert_eq!(result.row_count, 1);
1849 assert_query_diagnostic(
1850 session
1851 .execute_trusted_dynamic_query(&query.cursor("00"))
1852 .expect_err("scalar execution must reject grouped cursor state"),
1853 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
1854 icydb_diagnostic_code::ErrorOrigin::Query,
1855 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
1856 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
1857 },
1858 );
1859 assert_query_diagnostic(
1860 session
1861 .execute_public_dynamic_grouped_query(
1862 &crate::db::DynamicQuery::new("RenamedEntity").grouped_limits(1, 1024),
1863 )
1864 .expect_err("grouped execution must reject scalar query state"),
1865 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
1866 icydb_diagnostic_code::ErrorOrigin::Query,
1867 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
1868 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
1869 },
1870 );
1871
1872 let grouped_query = crate::db::DynamicQuery::new("RenamedEntity")
1873 .filter(crate::db::FieldRef::new("id").eq(1_u64))
1874 .group_by("value")
1875 .aggregate(crate::db::count())
1876 .grouped_limits(1, 1024)
1877 .limit(1);
1878 let grouped = session
1879 .execute_public_dynamic_grouped_query(&grouped_query)
1880 .expect("SQL-free grouped execution should use accepted authority");
1881 let typed_grouped = session
1882 .execute_public_dynamic_grouped_query_for_typed_binding(
1883 &replacement,
1884 &grouped_query,
1885 )
1886 .expect("typed grouped execution should inspect accepted authority")
1887 .expect("replacement binding should remain current");
1888 assert_eq!(typed_grouped, grouped);
1889 assert!(
1890 session
1891 .execute_public_dynamic_grouped_query_for_typed_binding(
1892 &renamed,
1893 &grouped_query,
1894 )
1895 .expect("stale grouped binding should inspect accepted authority")
1896 .is_none(),
1897 "stale typed grouped bindings must fail closed before execution"
1898 );
1899 assert_eq!(grouped.entity, "RenamedEntity");
1900 assert_eq!(grouped.row_count, 1);
1901 assert_eq!(grouped.rows.len(), 1);
1902 assert_eq!(
1903 grouped.rows[0].group_key(),
1904 &[crate::value::OutputValue::Nat64(9)]
1905 );
1906 assert_eq!(
1907 grouped.rows[0].aggregate_values(),
1908 &[crate::value::OutputValue::Nat64(1)]
1909 );
1910 assert_eq!(grouped.next_cursor, None);
1911
1912 assert_query_diagnostic(
1913 session
1914 .execute_public_dynamic_grouped_query(&grouped_query.clone().select(["value"]))
1915 .expect_err("grouped output must reject scalar selection"),
1916 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
1917 icydb_diagnostic_code::ErrorOrigin::Query,
1918 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
1919 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
1920 },
1921 );
1922 assert_query_diagnostic(
1923 session
1924 .execute_public_dynamic_grouped_query(
1925 &crate::db::DynamicQuery::new("RenamedEntity")
1926 .group_by("value")
1927 .aggregate(crate::db::count()),
1928 )
1929 .expect_err("public grouped execution must require explicit limits"),
1930 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
1931 icydb_diagnostic_code::ErrorOrigin::Query,
1932 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
1933 reason:
1934 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
1935 },
1936 );
1937 assert_query_diagnostic(
1938 session
1939 .execute_trusted_dynamic_grouped_query(
1940 &crate::db::DynamicQuery::new("RenamedEntity")
1941 .group_by("value")
1942 .aggregate(crate::db::count())
1943 .grouped_limits(0, 1024),
1944 )
1945 .expect_err("trusted grouped execution must reject zero limits"),
1946 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
1947 icydb_diagnostic_code::ErrorOrigin::Query,
1948 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
1949 reason:
1950 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
1951 },
1952 );
1953 assert_query_diagnostic(
1954 session
1955 .execute_public_dynamic_grouped_query(&grouped_query.grouped_limits(101, 1024))
1956 .expect_err("public grouped execution must enforce its group budget"),
1957 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
1958 icydb_diagnostic_code::ErrorOrigin::Query,
1959 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
1960 reason:
1961 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryExceedsBudget,
1962 },
1963 );
1964
1965 let paged_query = crate::db::DynamicQuery::new("RenamedEntity")
1966 .group_by("value")
1967 .aggregate(crate::db::count())
1968 .grouped_limits(2, 1024)
1969 .limit(1);
1970 assert_query_diagnostic(
1971 session
1972 .execute_public_dynamic_grouped_query(&paged_query)
1973 .expect_err("public grouped execution must reject an unbounded full scan"),
1974 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
1975 icydb_diagnostic_code::ErrorOrigin::Query,
1976 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
1977 reason:
1978 icydb_diagnostic_code::QueryReadAdmissionCode::UnboundedFullScanRejected,
1979 },
1980 );
1981 let first_page = session
1982 .execute_trusted_dynamic_grouped_query(&paged_query)
1983 .expect("SQL-free grouped first page should execute");
1984 assert_eq!(first_page.row_count, 1);
1985 assert_eq!(
1986 first_page.rows[0].group_key(),
1987 &[crate::value::OutputValue::Nat64(9)]
1988 );
1989 let cursor = first_page
1990 .next_cursor
1991 .expect("first grouped page should return a continuation cursor");
1992 assert_query_diagnostic(
1993 session
1994 .execute_trusted_dynamic_grouped_query(
1995 &paged_query.clone().cursor(format!("{cursor}0")),
1996 )
1997 .expect_err("tampered grouped cursor must fail closed"),
1998 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
1999 icydb_diagnostic_code::ErrorOrigin::Cursor,
2000 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
2001 kind: icydb_diagnostic_code::QueryErrorKind::InvalidContinuationCursor,
2002 },
2003 );
2004 let second_page = session
2005 .execute_trusted_dynamic_grouped_query(&paged_query.cursor(cursor))
2006 .expect("SQL-free grouped continuation should execute");
2007 assert_eq!(second_page.row_count, 1);
2008 assert_eq!(
2009 second_page.rows[0].group_key(),
2010 &[crate::value::OutputValue::Nat64(10)]
2011 );
2012 assert_eq!(second_page.next_cursor, None);
2013 }
2014 }
2015}
2016
2017#[cfg(test)]
2018mod mixed_relation_batch_tests {
2019 use super::{DbSession, DynamicMutation, DynamicStructuralPatch, DynamicWriteCell};
2020 use crate::{
2021 db::{
2022 data::DataStore,
2023 index::IndexStore,
2024 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
2025 schema::{
2026 AcceptedConstraintCatalog, AcceptedFieldKind, AcceptedSchemaRevision, FieldId,
2027 FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
2028 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
2029 PersistedRelationEdgeSnapshot, PersistedSchemaSnapshot, RelationId, ScalarCodec,
2030 SchemaFieldSlot, SchemaIndexId, SchemaInsertDefault, SchemaRowLayout, SchemaStore,
2031 SchemaVersion, accepted_schema_candidate_with_field_bindings_for_tests,
2032 },
2033 },
2034 error::ErrorClass,
2035 traits::{CanisterKind, Path},
2036 types::EntityTag,
2037 value::{InputValue, OutputValue},
2038 };
2039 use icydb_schema::FieldSourceKey;
2040 use std::{cell::RefCell, collections::BTreeMap};
2041
2042 const STORE_PATH: &str = "session::write::mixed_relation_batch_tests::Store";
2043 const ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node";
2044 const ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::id";
2045 const PARENT_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::parent_id";
2046 const CODE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::code";
2047 const ENTITY_NAME: &str = "MixedRelationNode";
2048 const ENTITY_TAG: EntityTag = EntityTag::new(94);
2049 const OTHER_ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other";
2050 const OTHER_ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::id";
2051 const OTHER_VALUE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::value";
2052 const OTHER_ENTITY_NAME: &str = "MixedRelationOther";
2053 const OTHER_ENTITY_TAG: EntityTag = EntityTag::new(95);
2054
2055 struct TestCanister;
2056
2057 impl Path for TestCanister {
2058 const PATH: &'static str = "session::write::mixed_relation_batch_tests::Canister";
2059 }
2060
2061 impl CanisterKind for TestCanister {
2062 const COMMIT_MEMORY_ID: u8 = 47;
2063 const COMMIT_STABLE_KEY: &'static str = "icydb.mixed_relation_batch_tests.commit.v1";
2064 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 48;
2065 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
2066 "icydb.mixed_relation_batch_tests.integrity.progress.v1";
2067 }
2068
2069 thread_local! {
2070 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
2071 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
2072 static SCHEMA_STORE: RefCell<SchemaStore> =
2073 const { RefCell::new(SchemaStore::init_heap()) };
2074 static STORE_REGISTRY: StoreRegistry = {
2075 let mut registry = StoreRegistry::new();
2076 registry.register_store(
2077 STORE_PATH,
2078 &DATA_STORE,
2079 &INDEX_STORE,
2080 &SCHEMA_STORE,
2081 StoreAllocationIdentities::absent(),
2082 StoreRuntimeStorageCapabilities::heap(),
2083 ).expect("mixed relation test store should register");
2084 registry
2085 };
2086 }
2087
2088 fn source_key(source: &str) -> FieldSourceKey {
2089 FieldSourceKey::try_new(source).expect("mixed relation field source should admit")
2090 }
2091
2092 fn relation_snapshot() -> PersistedSchemaSnapshot {
2093 let fields = vec![
2094 PersistedFieldSnapshot::new_initial(
2095 FieldId::new(1),
2096 "id".to_string(),
2097 SchemaFieldSlot::new(0),
2098 AcceptedFieldKind::Nat64,
2099 Vec::new(),
2100 false,
2101 SchemaInsertDefault::None,
2102 FieldStorageDecode::ByKind,
2103 LeafCodec::Scalar(ScalarCodec::Nat64),
2104 ),
2105 PersistedFieldSnapshot::new_initial(
2106 FieldId::new(2),
2107 "parent_id".to_string(),
2108 SchemaFieldSlot::new(1),
2109 AcceptedFieldKind::Relation {
2110 target_path: ENTITY_SOURCE.to_string(),
2111 target_entity_name: ENTITY_NAME.to_string(),
2112 target_entity_tag: ENTITY_TAG,
2113 target_store_path: STORE_PATH.to_string(),
2114 key_kind: Box::new(AcceptedFieldKind::Nat64),
2115 },
2116 Vec::new(),
2117 true,
2118 SchemaInsertDefault::None,
2119 FieldStorageDecode::ByKind,
2120 LeafCodec::Scalar(ScalarCodec::Nat64),
2121 ),
2122 PersistedFieldSnapshot::new_initial(
2123 FieldId::new(3),
2124 "code".to_string(),
2125 SchemaFieldSlot::new(2),
2126 AcceptedFieldKind::Nat64,
2127 Vec::new(),
2128 false,
2129 SchemaInsertDefault::None,
2130 FieldStorageDecode::ByKind,
2131 LeafCodec::Scalar(ScalarCodec::Nat64),
2132 ),
2133 ];
2134 let relation = PersistedRelationEdgeSnapshot::new(
2135 RelationId::new(1).expect("mixed relation identity should be non-zero"),
2136 "parent".to_string(),
2137 ENTITY_SOURCE.to_string(),
2138 vec![FieldId::new(2)],
2139 );
2140 let snapshot = PersistedSchemaSnapshot::new_with_indexes(
2141 SchemaVersion::initial(),
2142 ENTITY_SOURCE.to_string(),
2143 ENTITY_NAME.to_string(),
2144 FieldId::new(1),
2145 SchemaRowLayout::initial(
2146 fields
2147 .iter()
2148 .map(|field| (field.id(), field.slot()))
2149 .collect(),
2150 ),
2151 fields,
2152 vec![PersistedIndexSnapshot::new(
2153 SchemaIndexId::new(1).expect("mixed unique index identity should be non-zero"),
2154 1,
2155 "by_code".to_string(),
2156 STORE_PATH.to_string(),
2157 true,
2158 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
2159 FieldId::new(3),
2160 SchemaFieldSlot::new(2),
2161 vec!["code".to_string()],
2162 AcceptedFieldKind::Nat64,
2163 false,
2164 )]),
2165 None,
2166 )],
2167 )
2168 .with_relations(vec![relation]);
2169 let constraints = AcceptedConstraintCatalog::initial(
2170 snapshot.fields(),
2171 snapshot.indexes(),
2172 snapshot.relations(),
2173 )
2174 .expect("mixed relation constraints should close");
2175 snapshot.with_constraint_catalog(constraints)
2176 }
2177
2178 fn other_snapshot() -> PersistedSchemaSnapshot {
2179 let fields = vec![
2180 PersistedFieldSnapshot::new_initial(
2181 FieldId::new(1),
2182 "id".to_string(),
2183 SchemaFieldSlot::new(0),
2184 AcceptedFieldKind::Nat64,
2185 Vec::new(),
2186 false,
2187 SchemaInsertDefault::None,
2188 FieldStorageDecode::ByKind,
2189 LeafCodec::Scalar(ScalarCodec::Nat64),
2190 ),
2191 PersistedFieldSnapshot::new_initial(
2192 FieldId::new(2),
2193 "value".to_string(),
2194 SchemaFieldSlot::new(1),
2195 AcceptedFieldKind::Nat64,
2196 Vec::new(),
2197 false,
2198 SchemaInsertDefault::None,
2199 FieldStorageDecode::ByKind,
2200 LeafCodec::Scalar(ScalarCodec::Nat64),
2201 ),
2202 ];
2203 PersistedSchemaSnapshot::new(
2204 SchemaVersion::initial(),
2205 OTHER_ENTITY_SOURCE.to_string(),
2206 OTHER_ENTITY_NAME.to_string(),
2207 FieldId::new(1),
2208 SchemaRowLayout::initial(
2209 fields
2210 .iter()
2211 .map(|field| (field.id(), field.slot()))
2212 .collect(),
2213 ),
2214 fields,
2215 )
2216 }
2217
2218 fn initialize() -> DbSession<TestCanister> {
2219 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2220 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2221 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2222 let session = DbSession::<TestCanister>::new(&STORE_REGISTRY);
2223 session
2224 .db
2225 .ensure_recovered_state()
2226 .expect("mixed relation database should initialize");
2227 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
2228 STORE_PATH,
2229 AcceptedSchemaRevision::INITIAL,
2230 BTreeMap::from([
2231 (ENTITY_TAG, relation_snapshot()),
2232 (OTHER_ENTITY_TAG, other_snapshot()),
2233 ]),
2234 BTreeMap::from([
2235 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
2236 ((ENTITY_TAG, source_key(PARENT_SOURCE)), FieldId::new(2)),
2237 ((ENTITY_TAG, source_key(CODE_SOURCE)), FieldId::new(3)),
2238 (
2239 (OTHER_ENTITY_TAG, source_key(OTHER_ID_SOURCE)),
2240 FieldId::new(1),
2241 ),
2242 (
2243 (OTHER_ENTITY_TAG, source_key(OTHER_VALUE_SOURCE)),
2244 FieldId::new(2),
2245 ),
2246 ]),
2247 );
2248 let store = session
2249 .db
2250 .store_handle(STORE_PATH)
2251 .expect("mixed relation store should resolve");
2252 crate::db::commit::publish_accepted_schema_candidate(
2253 STORE_PATH,
2254 store,
2255 AcceptedSchemaRevision::NONE,
2256 &candidate,
2257 )
2258 .expect("mixed relation candidate should publish");
2259 session
2260 }
2261
2262 fn patch(id: Option<u64>, parent: Option<u64>, code: Option<u64>) -> DynamicStructuralPatch {
2263 let mut fields = Vec::new();
2264 if let Some(id) = id {
2265 fields.push((
2266 "id".to_string(),
2267 DynamicWriteCell::Value(InputValue::Nat64(id)),
2268 ));
2269 }
2270 fields.push((
2271 "parent_id".to_string(),
2272 parent.map_or(DynamicWriteCell::Null, |parent| {
2273 DynamicWriteCell::Value(InputValue::Nat64(parent))
2274 }),
2275 ));
2276 if let Some(code) = code {
2277 fields.push((
2278 "code".to_string(),
2279 DynamicWriteCell::Value(InputValue::Nat64(code)),
2280 ));
2281 }
2282 DynamicStructuralPatch::new(fields)
2283 }
2284
2285 fn insert(id: u64, parent: Option<u64>) -> DynamicMutation {
2286 insert_with_code(id, parent, id)
2287 }
2288
2289 fn insert_with_code(id: u64, parent: Option<u64>, code: u64) -> DynamicMutation {
2290 DynamicMutation::Insert {
2291 entity: ENTITY_NAME.to_string(),
2292 patch: patch(Some(id), parent, Some(code)),
2293 }
2294 }
2295
2296 fn update_parent(id: u64, parent: Option<u64>) -> DynamicMutation {
2297 DynamicMutation::Update {
2298 entity: ENTITY_NAME.to_string(),
2299 key: InputValue::Nat64(id),
2300 patch: patch(None, parent, None),
2301 }
2302 }
2303
2304 fn update_code(id: u64, code: u64) -> DynamicMutation {
2305 DynamicMutation::Update {
2306 entity: ENTITY_NAME.to_string(),
2307 key: InputValue::Nat64(id),
2308 patch: DynamicStructuralPatch::new(vec![(
2309 "code".to_string(),
2310 DynamicWriteCell::Value(InputValue::Nat64(code)),
2311 )]),
2312 }
2313 }
2314
2315 fn delete(id: u64) -> DynamicMutation {
2316 DynamicMutation::Delete {
2317 entity: ENTITY_NAME.to_string(),
2318 key: InputValue::Nat64(id),
2319 }
2320 }
2321
2322 fn expected_row(id: u64, parent: Option<u64>) -> Vec<OutputValue> {
2323 expected_row_with_code(id, parent, id)
2324 }
2325
2326 fn expected_row_with_code(id: u64, parent: Option<u64>, code: u64) -> Vec<OutputValue> {
2327 vec![
2328 OutputValue::Nat64(id),
2329 parent.map_or(OutputValue::Null, OutputValue::Nat64),
2330 OutputValue::Nat64(code),
2331 ]
2332 }
2333
2334 fn other_patch(id: Option<u64>, value: u64) -> DynamicStructuralPatch {
2335 let mut fields = Vec::new();
2336 if let Some(id) = id {
2337 fields.push((
2338 "id".to_string(),
2339 DynamicWriteCell::Value(InputValue::Nat64(id)),
2340 ));
2341 }
2342 fields.push((
2343 "value".to_string(),
2344 DynamicWriteCell::Value(InputValue::Nat64(value)),
2345 ));
2346 DynamicStructuralPatch::new(fields)
2347 }
2348
2349 fn assert_relation_violation(error: &crate::error::InternalError) {
2350 assert!(error.diagnostic_facts().contains(&(
2351 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
2352 icydb_diagnostic_code::DiagnosticConstraintKind::Relation.raw(),
2353 )));
2354 }
2355
2356 #[test]
2357 fn mixed_relation_validation_uses_the_complete_final_row_overlay() {
2358 let session = initialize();
2359 session
2360 .execute_trusted_dynamic_mutation_batch(vec![insert(1, None), insert(2, Some(1))])
2361 .expect("the initial relation should commit");
2362
2363 let blocked = session
2364 .execute_trusted_dynamic_mutation(&delete(1))
2365 .expect_err("an unaffected committed source must block target deletion");
2366 assert_relation_violation(&blocked);
2367
2368 let deleted = session
2369 .execute_trusted_dynamic_mutation_batch(vec![delete(2), delete(1)])
2370 .expect("a source and its target should delete atomically");
2371 assert_eq!(
2372 deleted.rows,
2373 vec![expected_row(2, Some(1)), expected_row(1, None)],
2374 );
2375
2376 session
2377 .execute_trusted_dynamic_mutation_batch(vec![insert(3, None), insert(4, Some(3))])
2378 .expect("the update-away fixture should commit");
2379 let updated_away = session
2380 .execute_trusted_dynamic_mutation_batch(vec![update_parent(4, None), delete(3)])
2381 .expect("an updated final source may release a deleted target");
2382 assert_eq!(
2383 updated_away.rows,
2384 vec![expected_row(4, None), expected_row(3, None)],
2385 );
2386
2387 session
2388 .execute_trusted_dynamic_mutation_batch(vec![insert(5, None), insert(6, Some(5))])
2389 .expect("the retained-reference fixture should commit");
2390 let retained = session
2391 .execute_trusted_dynamic_mutation_batch(vec![update_parent(6, Some(5)), delete(5)])
2392 .expect_err("a final updated source must still block target deletion");
2393 assert_relation_violation(&retained);
2394
2395 session
2396 .execute_trusted_dynamic_mutation(&insert(7, None))
2397 .expect("the inserted-reference fixture target should commit");
2398 let inserted_reference = session
2399 .execute_trusted_dynamic_mutation_batch(vec![insert(8, Some(7)), delete(7)])
2400 .expect_err("a final inserted source must not reference a deleted target");
2401 assert_relation_violation(&inserted_reference);
2402
2403 let inserted_target = session
2404 .execute_trusted_dynamic_mutation_batch(vec![insert(10, Some(9)), insert(9, None)])
2405 .expect("an inserted relation should see its batch-final target");
2406 assert_eq!(
2407 inserted_target.rows,
2408 vec![expected_row(10, Some(9)), expected_row(9, None)],
2409 );
2410
2411 session
2412 .execute_trusted_dynamic_mutation(&insert(11, None))
2413 .expect("the updated-reference fixture source should commit");
2414 let updated_target = session
2415 .execute_trusted_dynamic_mutation_batch(vec![
2416 update_parent(11, Some(12)),
2417 insert(12, None),
2418 ])
2419 .expect("an updated relation should see its batch-final target");
2420 assert_eq!(
2421 updated_target.rows,
2422 vec![expected_row(11, Some(12)), expected_row(12, None)],
2423 );
2424 }
2425
2426 #[test]
2427 fn mixed_batch_rejects_cross_entity_missing_and_collision_then_honors_replace() {
2428 let session = initialize();
2429 session
2430 .execute_trusted_dynamic_mutation(&insert(1, None))
2431 .expect("the primary mixed fixture row should commit");
2432 session
2433 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
2434 entity: OTHER_ENTITY_NAME.to_string(),
2435 patch: other_patch(Some(1), 10),
2436 })
2437 .expect("the secondary mixed fixture row should commit");
2438
2439 let mixed_entity = session
2440 .execute_trusted_dynamic_mutation_batch(vec![
2441 update_code(1, 11),
2442 DynamicMutation::Update {
2443 entity: OTHER_ENTITY_NAME.to_string(),
2444 key: InputValue::Nat64(1),
2445 patch: other_patch(None, 11),
2446 },
2447 ])
2448 .expect_err("one atomic batch must not cross accepted entities");
2449 assert_eq!(mixed_entity.class(), ErrorClass::Conflict);
2450 assert_eq!(
2451 mixed_entity.diagnostic_facts(),
2452 vec![
2453 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1,),
2454 (
2455 icydb_diagnostic_code::DiagnosticFactTag::ExpectedEntityTag,
2456 ENTITY_TAG.value(),
2457 ),
2458 (
2459 icydb_diagnostic_code::DiagnosticFactTag::ActualEntityTag,
2460 OTHER_ENTITY_TAG.value(),
2461 ),
2462 ],
2463 );
2464
2465 let missing = session
2466 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 12), delete(99)])
2467 .expect_err("a late missing delete must reject the earlier staged update");
2468 assert_eq!(missing.class(), ErrorClass::NotFound);
2469
2470 session
2471 .execute_trusted_dynamic_mutation(&insert(2, None))
2472 .expect("the collision fixture should commit");
2473 let collision = session
2474 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 13), insert(2, None)])
2475 .expect_err("an insert collision must reject the earlier staged update");
2476 assert_eq!(collision.class(), ErrorClass::Conflict);
2477 let failures_unchanged = session
2478 .execute_trusted_dynamic_mutation(&update_code(1, 1))
2479 .expect("failed batches must preserve the original unique value");
2480 assert_eq!(failures_unchanged.affected_rows, 0);
2481
2482 let replaced = session
2483 .execute_trusted_dynamic_mutation_batch(vec![
2484 update_code(1, 14),
2485 DynamicMutation::Replace {
2486 entity: ENTITY_NAME.to_string(),
2487 key: InputValue::Nat64(99),
2488 patch: patch(None, None, Some(99)),
2489 },
2490 ])
2491 .expect("ordinary caller-key replace should insert its absent final row");
2492 assert_eq!(
2493 replaced.rows,
2494 vec![
2495 expected_row_with_code(1, None, 14),
2496 expected_row_with_code(99, None, 99),
2497 ],
2498 );
2499
2500 let unchanged = session
2501 .execute_trusted_dynamic_mutation(&update_code(1, 14))
2502 .expect("the successful mixed replace must publish its preceding update");
2503 assert_eq!(unchanged.affected_rows, 0);
2504 let other_unchanged = session
2505 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
2506 entity: OTHER_ENTITY_NAME.to_string(),
2507 key: InputValue::Nat64(1),
2508 patch: other_patch(None, 10),
2509 })
2510 .expect("cross-entity rejection must preserve the secondary row");
2511 assert_eq!(other_unchanged.affected_rows, 0);
2512 }
2513
2514 #[test]
2515 fn mixed_batch_unique_swap_and_delete_release_use_the_final_overlay() {
2516 let session = initialize();
2517 session
2518 .execute_trusted_dynamic_mutation_batch(vec![
2519 insert_with_code(1, None, 10),
2520 insert_with_code(2, None, 20),
2521 ])
2522 .expect("the unique-overlay fixture should commit");
2523
2524 let swapped = session
2525 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 20), update_code(2, 10)])
2526 .expect("two final rows should atomically swap unique memberships");
2527 assert_eq!(
2528 swapped.rows,
2529 vec![
2530 expected_row_with_code(1, None, 20),
2531 expected_row_with_code(2, None, 10),
2532 ],
2533 );
2534
2535 let released = session
2536 .execute_trusted_dynamic_mutation_batch(vec![delete(1), insert_with_code(3, None, 20)])
2537 .expect("a delete should release unique membership to a final inserted row");
2538 assert_eq!(
2539 released.rows,
2540 vec![
2541 expected_row_with_code(1, None, 20),
2542 expected_row_with_code(3, None, 20),
2543 ],
2544 );
2545 }
2546}
2547
2548#[cfg(test)]
2549mod identity_pre_key_tests {
2550 use super::{
2551 AcceptedMutationIntentPatch, AcceptedRowLayoutRuntimeContract, AcceptedStructuralMutation,
2552 AcceptedStructuralMutationTarget, DbSession, DynamicMutation, DynamicStructuralPatch,
2553 DynamicTypedFieldBindingRequest, DynamicTypedFieldType, DynamicTypedMutation,
2554 DynamicWriteCell, FieldSlot, MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
2555 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
2556 add_structural_mutation_staged_bytes, checked_pre_key_candidate_count,
2557 insert_key_exists_after_generation, validate_structural_mutation_result_bytes,
2558 };
2559 use crate::{
2560 db::{
2561 commit::{database_incarnation_id, forget_recovered_domain_for_tests},
2562 data::DataStore,
2563 executor::{MutationCommitInterruption, interrupt_next_mutation_commit_for_tests},
2564 index::IndexStore,
2565 integrity::{
2566 PhysicalUnitCheckpoint, QuickIntegrityStatus, RowInspectionLimits,
2567 execute_quick_integrity, execute_row_integrity_page,
2568 },
2569 journal::JournalTailStore,
2570 registry::{
2571 StoreAllocationIdentities, StoreAllocationIdentity, StoreRegistry,
2572 StoreRuntimeStorageCapabilities,
2573 },
2574 schema::{
2575 AcceptedFieldKind, AcceptedSchemaRevision, FieldId, FieldInsertGeneration,
2576 FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
2577 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
2578 PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy,
2579 SchemaIndexId, SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
2580 accepted_schema_candidate_with_field_bindings_for_tests,
2581 },
2582 write_context::MutationMode,
2583 },
2584 error::{ErrorClass, ErrorOrigin, InternalError},
2585 testing::test_memory,
2586 traits::{CanisterKind, Path},
2587 types::{EntityTag, Timestamp},
2588 value::{InputValue, OutputValue, Value},
2589 };
2590 use icydb_schema::{FieldSourceKey, ScalarType};
2591 use std::{cell::RefCell, collections::BTreeMap, time::Instant};
2592
2593 const STORE_PATH: &str = "session::write::identity_pre_key_tests::Store";
2594 const ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::Entity";
2595 const ID_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::id";
2596 const PAYLOAD_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::payload";
2597 const ENTITY_NAME: &str = "IdentityRow";
2598 const ENTITY_TAG: EntityTag = EntityTag::new(93);
2599 const JOURNALED_STORE_PATH: &str = "session::write::identity_pre_key_tests::JournaledStore";
2600
2601 struct TestCanister;
2602
2603 impl Path for TestCanister {
2604 const PATH: &'static str = "session::write::identity_pre_key_tests::Canister";
2605 }
2606
2607 impl CanisterKind for TestCanister {
2608 const COMMIT_MEMORY_ID: u8 = 45;
2609 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.commit.v1";
2610 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 46;
2611 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
2612 "icydb.identity_pre_key_tests.integrity.progress.v1";
2613 }
2614
2615 thread_local! {
2616 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
2617 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
2618 static SCHEMA_STORE: RefCell<SchemaStore> =
2619 const { RefCell::new(SchemaStore::init_heap()) };
2620 static STORE_REGISTRY: StoreRegistry = {
2621 let mut registry = StoreRegistry::new();
2622 registry.register_store(
2623 STORE_PATH,
2624 &DATA_STORE,
2625 &INDEX_STORE,
2626 &SCHEMA_STORE,
2627 StoreAllocationIdentities::absent(),
2628 StoreRuntimeStorageCapabilities::heap(),
2629 ).expect("identity pre-key test store should register");
2630 registry
2631 };
2632 static JOURNALED_DATA_STORE: RefCell<DataStore> =
2633 RefCell::new(DataStore::init_journaled(test_memory(186)));
2634 static JOURNALED_INDEX_STORE: RefCell<IndexStore> =
2635 RefCell::new(IndexStore::init_journaled(test_memory(187)));
2636 static JOURNALED_SCHEMA_STORE: RefCell<SchemaStore> =
2637 RefCell::new(SchemaStore::init_journaled(test_memory(188)));
2638 static JOURNALED_TAIL_STORE: RefCell<JournalTailStore> =
2639 RefCell::new(JournalTailStore::init(test_memory(189)));
2640 static JOURNALED_STORE_REGISTRY: StoreRegistry = {
2641 let mut registry = StoreRegistry::new();
2642 registry.register_journaled_store(
2643 JOURNALED_STORE_PATH,
2644 &JOURNALED_DATA_STORE,
2645 &JOURNALED_INDEX_STORE,
2646 &JOURNALED_SCHEMA_STORE,
2647 &JOURNALED_TAIL_STORE,
2648 StoreAllocationIdentities::new_journaled(
2649 StoreAllocationIdentity::new(186, "icydb.test.identity-range.data.v1"),
2650 StoreAllocationIdentity::new(187, "icydb.test.identity-range.index.v1"),
2651 StoreAllocationIdentity::new(188, "icydb.test.identity-range.schema.v1"),
2652 StoreAllocationIdentity::new(189, "icydb.test.identity-range.journal.v1"),
2653 ),
2654 StoreRuntimeStorageCapabilities::journaled(),
2655 ).expect("identity range journaled store should register");
2656 registry
2657 };
2658 }
2659
2660 struct JournaledTestCanister;
2661
2662 impl Path for JournaledTestCanister {
2663 const PATH: &'static str = "session::write::identity_pre_key_tests::JournaledCanister";
2664 }
2665
2666 impl CanisterKind for JournaledTestCanister {
2667 const COMMIT_MEMORY_ID: u8 = 190;
2668 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_range_tests.commit.v1";
2669 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 191;
2670 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
2671 "icydb.identity_range_tests.integrity.progress.v1";
2672 }
2673
2674 fn source_key(source: &str) -> FieldSourceKey {
2675 FieldSourceKey::try_new(source).expect("identity test field source should admit")
2676 }
2677
2678 fn identity_snapshot(store_path: &str) -> PersistedSchemaSnapshot {
2679 let fields = vec![
2680 PersistedFieldSnapshot::new_initial_with_write_policy(
2681 FieldId::new(1),
2682 "id".to_string(),
2683 SchemaFieldSlot::new(0),
2684 AcceptedFieldKind::Nat64,
2685 Vec::new(),
2686 false,
2687 SchemaInsertDefault::None,
2688 SchemaFieldWritePolicy::from_model_policies(
2689 Some(FieldInsertGeneration::Identity),
2690 None,
2691 ),
2692 FieldStorageDecode::ByKind,
2693 LeafCodec::Scalar(ScalarCodec::Nat64),
2694 ),
2695 PersistedFieldSnapshot::new_initial(
2696 FieldId::new(2),
2697 "payload".to_string(),
2698 SchemaFieldSlot::new(1),
2699 AcceptedFieldKind::Nat64,
2700 Vec::new(),
2701 false,
2702 SchemaInsertDefault::None,
2703 FieldStorageDecode::ByKind,
2704 LeafCodec::Scalar(ScalarCodec::Nat64),
2705 ),
2706 ];
2707 PersistedSchemaSnapshot::new_with_indexes(
2708 SchemaVersion::initial(),
2709 ENTITY_SOURCE.to_string(),
2710 ENTITY_NAME.to_string(),
2711 FieldId::new(1),
2712 SchemaRowLayout::initial(
2713 fields
2714 .iter()
2715 .map(|field| (field.id(), field.slot()))
2716 .collect(),
2717 ),
2718 fields,
2719 vec![PersistedIndexSnapshot::new(
2720 SchemaIndexId::new(1).expect("identity test index ID should admit"),
2721 1,
2722 "by_payload".to_string(),
2723 store_path.to_string(),
2724 false,
2725 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
2726 FieldId::new(2),
2727 SchemaFieldSlot::new(1),
2728 vec!["payload".to_string()],
2729 AcceptedFieldKind::Nat64,
2730 false,
2731 )]),
2732 None,
2733 )],
2734 )
2735 }
2736
2737 fn initialize() -> DbSession<TestCanister> {
2738 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2739 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2740 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2741 let session = DbSession::<TestCanister>::new(&STORE_REGISTRY);
2742 session
2743 .db
2744 .ensure_recovered_state()
2745 .expect("identity pre-key test database should initialize");
2746 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
2747 STORE_PATH,
2748 AcceptedSchemaRevision::INITIAL,
2749 BTreeMap::from([(ENTITY_TAG, identity_snapshot(STORE_PATH))]),
2750 BTreeMap::from([
2751 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
2752 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
2753 ]),
2754 );
2755 let store = session
2756 .db
2757 .store_handle(STORE_PATH)
2758 .expect("identity pre-key test store should resolve");
2759 crate::db::commit::publish_accepted_schema_candidate(
2760 STORE_PATH,
2761 store,
2762 AcceptedSchemaRevision::NONE,
2763 &candidate,
2764 )
2765 .expect("identity candidate should publish with explicit zero state");
2766 session
2767 }
2768
2769 fn initialize_journaled() -> DbSession<JournaledTestCanister> {
2770 let session = DbSession::<JournaledTestCanister>::new(&JOURNALED_STORE_REGISTRY);
2771 session
2772 .db
2773 .ensure_recovered_state()
2774 .expect("journaled identity database should initialize");
2775 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
2776 JOURNALED_STORE_PATH,
2777 AcceptedSchemaRevision::INITIAL,
2778 BTreeMap::from([(ENTITY_TAG, identity_snapshot(JOURNALED_STORE_PATH))]),
2779 BTreeMap::from([
2780 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
2781 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
2782 ]),
2783 );
2784 let store = session
2785 .db
2786 .store_handle(JOURNALED_STORE_PATH)
2787 .expect("journaled identity store should resolve");
2788 crate::db::commit::publish_accepted_schema_candidate(
2789 JOURNALED_STORE_PATH,
2790 store,
2791 AcceptedSchemaRevision::NONE,
2792 &candidate,
2793 )
2794 .expect("journaled identity candidate should publish");
2795 session
2796 }
2797
2798 fn payload_patch(value: u64) -> AcceptedMutationIntentPatch {
2799 AcceptedMutationIntentPatch::new()
2800 .set_authored(FieldSlot::from_validated_index(1), InputValue::Nat64(value))
2801 }
2802
2803 fn dynamic_payload_patch(value: u64) -> DynamicStructuralPatch {
2804 DynamicStructuralPatch::new(vec![(
2805 "payload".to_string(),
2806 DynamicWriteCell::Value(InputValue::Nat64(value)),
2807 )])
2808 }
2809
2810 fn expected_dynamic_row(id: u64, payload: u64) -> Vec<OutputValue> {
2811 vec![OutputValue::Nat64(id), OutputValue::Nat64(payload)]
2812 }
2813
2814 fn assert_dynamic_payload(session: &DbSession<TestCanister>, key: u64, expected_payload: u64) {
2815 let unchanged = session
2816 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
2817 entity: ENTITY_NAME.to_string(),
2818 key: InputValue::Nat64(key),
2819 patch: dynamic_payload_patch(expected_payload),
2820 })
2821 .expect("the expected row should remain readable through a no-op update");
2822 assert_eq!(unchanged.affected_rows, 0);
2823 assert_eq!(
2824 unchanged.rows,
2825 vec![expected_dynamic_row(key, expected_payload)],
2826 );
2827 }
2828
2829 fn batch(values: &[u64]) -> Vec<AcceptedStructuralMutation> {
2830 values
2831 .iter()
2832 .map(|value| {
2833 AcceptedStructuralMutation::save(
2834 MutationMode::Insert,
2835 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
2836 payload_patch(*value),
2837 )
2838 })
2839 .collect()
2840 }
2841
2842 fn assert_identity_boundary(error: &InternalError) {
2843 assert_eq!(error.class(), ErrorClass::Unsupported);
2844 assert_eq!(error.origin(), ErrorOrigin::Identity);
2845 }
2846
2847 #[test]
2848 fn generated_candidate_collision_is_identity_corruption_before_generic_uniqueness() {
2849 let generated = insert_key_exists_after_generation(true);
2850 assert_eq!(generated.class(), ErrorClass::Corruption);
2851 assert_eq!(generated.origin(), ErrorOrigin::Identity);
2852
2853 let ordinary = insert_key_exists_after_generation(false);
2854 assert_ne!(ordinary.origin(), ErrorOrigin::Identity);
2855 }
2856
2857 #[cfg(target_pointer_width = "64")]
2858 #[test]
2859 fn pre_key_candidate_count_rejects_values_beyond_the_persisted_u32_bound() {
2860 let error = checked_pre_key_candidate_count(
2861 usize::try_from(u64::from(u32::MAX) + 1).expect("64-bit usize should hold u32 + 1"),
2862 )
2863 .expect_err("candidate counts beyond u32 must reject");
2864 assert_identity_boundary(&error);
2865 }
2866
2867 #[test]
2868 #[expect(
2869 clippy::too_many_lines,
2870 reason = "one holding lifecycle proves split, merge, transfer, late-failure neutrality, result order, and Identity state"
2871 )]
2872 fn mixed_structural_batch_preserves_holding_conservation_and_failure_atomicity() {
2873 let session = initialize();
2874 let seeded = session
2875 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
2876 .expect("seed rows should commit");
2877 assert_eq!(seeded.affected_rows, 1);
2878
2879 let split = session
2880 .execute_trusted_dynamic_mutation_batch(vec![
2881 DynamicMutation::Update {
2882 entity: ENTITY_NAME.to_string(),
2883 key: InputValue::Nat64(1),
2884 patch: dynamic_payload_patch(60),
2885 },
2886 DynamicMutation::Insert {
2887 entity: ENTITY_NAME.to_string(),
2888 patch: dynamic_payload_patch(40),
2889 },
2890 ])
2891 .expect("one holding should split atomically");
2892 assert_eq!(split.affected_rows, 2);
2893 assert_eq!(
2894 split.rows,
2895 vec![expected_dynamic_row(1, 60), expected_dynamic_row(2, 40),],
2896 "split after-images must retain input order and exact quantity",
2897 );
2898
2899 let rejected_split = session
2900 .execute_trusted_dynamic_mutation_batch(vec![
2901 DynamicMutation::Update {
2902 entity: ENTITY_NAME.to_string(),
2903 key: InputValue::Nat64(1),
2904 patch: dynamic_payload_patch(50),
2905 },
2906 DynamicMutation::Insert {
2907 entity: ENTITY_NAME.to_string(),
2908 patch: DynamicStructuralPatch::new(Vec::new()),
2909 },
2910 ])
2911 .expect_err("an invalid split output must reject the staged source update");
2912 assert_eq!(rejected_split.class(), ErrorClass::Unsupported);
2913 assert_eq!(rejected_split.origin(), ErrorOrigin::Executor);
2914 assert_eq!(
2915 rejected_split.diagnostic_facts(),
2916 vec![
2917 (
2918 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
2919 ENTITY_TAG.value(),
2920 ),
2921 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 2),
2922 (
2923 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
2924 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
2925 ),
2926 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1,),
2927 ],
2928 );
2929 assert_dynamic_payload(&session, 1, 60);
2930 assert_dynamic_payload(&session, 2, 40);
2931
2932 let transfer = session
2933 .execute_trusted_dynamic_mutation_batch(vec![
2934 DynamicMutation::Update {
2935 entity: ENTITY_NAME.to_string(),
2936 key: InputValue::Nat64(1),
2937 patch: dynamic_payload_patch(70),
2938 },
2939 DynamicMutation::Update {
2940 entity: ENTITY_NAME.to_string(),
2941 key: InputValue::Nat64(2),
2942 patch: dynamic_payload_patch(30),
2943 },
2944 ])
2945 .expect("distinct transfer patches should share one atomic batch");
2946 assert_eq!(
2947 transfer.rows,
2948 vec![expected_dynamic_row(1, 70), expected_dynamic_row(2, 30),],
2949 "the transfer must preserve the exact total quantity",
2950 );
2951
2952 let merge = session
2953 .execute_trusted_dynamic_mutation_batch(vec![
2954 DynamicMutation::Delete {
2955 entity: ENTITY_NAME.to_string(),
2956 key: InputValue::Nat64(2),
2957 },
2958 DynamicMutation::Update {
2959 entity: ENTITY_NAME.to_string(),
2960 key: InputValue::Nat64(1),
2961 patch: dynamic_payload_patch(100),
2962 },
2963 ])
2964 .expect("two holdings should merge atomically");
2965 assert_eq!(
2966 merge.rows,
2967 vec![expected_dynamic_row(2, 30), expected_dynamic_row(1, 100),],
2968 "delete before-images and update after-images must retain input order",
2969 );
2970
2971 let resplit = session
2972 .execute_trusted_dynamic_mutation_batch(vec![
2973 DynamicMutation::Update {
2974 entity: ENTITY_NAME.to_string(),
2975 key: InputValue::Nat64(1),
2976 patch: dynamic_payload_patch(60),
2977 },
2978 DynamicMutation::Insert {
2979 entity: ENTITY_NAME.to_string(),
2980 patch: dynamic_payload_patch(40),
2981 },
2982 ])
2983 .expect("the merged holding should split again");
2984 assert_eq!(
2985 resplit.rows,
2986 vec![expected_dynamic_row(1, 60), expected_dynamic_row(3, 40),],
2987 );
2988
2989 let rejected_merge = session
2990 .execute_trusted_dynamic_mutation_batch(vec![
2991 DynamicMutation::Delete {
2992 entity: ENTITY_NAME.to_string(),
2993 key: InputValue::Nat64(3),
2994 },
2995 DynamicMutation::Update {
2996 entity: ENTITY_NAME.to_string(),
2997 key: InputValue::Nat64(99),
2998 patch: dynamic_payload_patch(100),
2999 },
3000 ])
3001 .expect_err("a late missing merge target must preserve the earlier staged delete");
3002 assert_eq!(rejected_merge.class(), ErrorClass::NotFound);
3003 assert_dynamic_payload(&session, 1, 60);
3004 assert_dynamic_payload(&session, 3, 40);
3005
3006 SCHEMA_STORE.with(|store| {
3007 let cursor = store
3008 .borrow()
3009 .identity_statement_cursor(
3010 database_incarnation_id().expect("database incarnation should remain readable"),
3011 ENTITY_TAG,
3012 FieldId::new(1),
3013 &AcceptedFieldKind::Nat64,
3014 )
3015 .expect("mixed Identity state should remain readable");
3016 assert_eq!(cursor.expected_high_water(), 3);
3017 assert!(!cursor.has_allocations());
3018 });
3019 }
3020
3021 #[test]
3022 fn mixed_structural_batch_rejects_duplicate_holding_targets_without_mutation() {
3023 let session = initialize();
3024 session
3025 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
3026 .expect("the holding fixture should initialize");
3027
3028 let duplicate = session
3029 .execute_trusted_dynamic_mutation_batch(vec![
3030 DynamicMutation::Update {
3031 entity: ENTITY_NAME.to_string(),
3032 key: InputValue::Nat64(1),
3033 patch: dynamic_payload_patch(60),
3034 },
3035 DynamicMutation::Delete {
3036 entity: ENTITY_NAME.to_string(),
3037 key: InputValue::Nat64(1),
3038 },
3039 ])
3040 .expect_err("duplicate targets across operation kinds must reject");
3041 assert!(matches!(
3042 duplicate.diagnostic().detail(),
3043 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3044 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchDuplicateKey,
3045 }),
3046 ));
3047 assert_eq!(
3048 duplicate.diagnostic_facts(),
3049 vec![
3050 (
3051 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
3052 ENTITY_TAG.value(),
3053 ),
3054 (
3055 icydb_diagnostic_code::DiagnosticFactTag::FirstBatchPosition,
3056 0,
3057 ),
3058 (
3059 icydb_diagnostic_code::DiagnosticFactTag::DuplicateBatchPosition,
3060 1,
3061 ),
3062 ],
3063 );
3064 assert_dynamic_payload(&session, 1, 100);
3065 }
3066
3067 #[test]
3068 fn mixed_structural_batch_rejects_empty_and_over_bound_before_resolution() {
3069 let session = initialize();
3070 let empty = session
3071 .execute_trusted_dynamic_mutation_batch(Vec::new())
3072 .expect_err("an empty public batch must reject");
3073 assert!(matches!(
3074 empty.diagnostic().detail(),
3075 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3076 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchEmpty,
3077 }),
3078 ));
3079 assert_eq!(
3080 empty.diagnostic_facts(),
3081 vec![(icydb_diagnostic_code::DiagnosticFactTag::ActualCount, 0,)],
3082 );
3083
3084 let requests = (0..=MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS)
3085 .map(|_| DynamicMutation::Delete {
3086 entity: ENTITY_NAME.to_string(),
3087 key: InputValue::Nat64(1),
3088 })
3089 .collect();
3090 let over_bound = session
3091 .execute_trusted_dynamic_mutation_batch(requests)
3092 .expect_err("operation cap plus one must reject before row resolution");
3093 assert!(matches!(
3094 over_bound.diagnostic().detail(),
3095 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3096 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchTooManyItems,
3097 }),
3098 ));
3099 assert_eq!(
3100 over_bound.diagnostic_facts(),
3101 vec![
3102 (
3103 icydb_diagnostic_code::DiagnosticFactTag::ActualCount,
3104 (MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS + 1) as u64,
3105 ),
3106 (
3107 icydb_diagnostic_code::DiagnosticFactTag::Limit,
3108 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS as u64,
3109 ),
3110 ],
3111 );
3112 }
3113
3114 #[test]
3115 fn mixed_structural_batch_staged_byte_bound_uses_checked_exact_boundary() {
3116 let mut exact = 0;
3117 add_structural_mutation_staged_bytes(
3118 &mut exact,
3119 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES],
3120 )
3121 .expect("the exact staged-byte boundary should admit");
3122 assert_eq!(exact, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
3123
3124 let error = add_structural_mutation_staged_bytes(&mut exact, [1])
3125 .expect_err("one byte above the staged-byte boundary must reject");
3126 assert!(matches!(
3127 error.diagnostic().detail(),
3128 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3129 boundary:
3130 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchStagedBytesExceeded,
3131 }),
3132 ));
3133 assert_eq!(
3134 error.diagnostic_facts(),
3135 vec![
3136 (
3137 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
3138 (MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES + 1) as u64,
3139 ),
3140 (
3141 icydb_diagnostic_code::DiagnosticFactTag::Limit,
3142 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES as u64,
3143 ),
3144 ],
3145 );
3146
3147 validate_structural_mutation_result_bytes(MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES)
3148 .expect("the exact result-byte boundary should admit");
3149 let error = validate_structural_mutation_result_bytes(
3150 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1,
3151 )
3152 .expect_err("one byte above the result-byte boundary must reject");
3153 assert!(matches!(
3154 error.diagnostic().detail(),
3155 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3156 boundary:
3157 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchResultBytesExceeded,
3158 }),
3159 ));
3160 assert_eq!(
3161 error.diagnostic_facts(),
3162 vec![
3163 (
3164 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
3165 (MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1) as u64,
3166 ),
3167 (
3168 icydb_diagnostic_code::DiagnosticFactTag::Limit,
3169 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES as u64,
3170 ),
3171 ],
3172 );
3173 }
3174
3175 #[expect(
3176 clippy::too_many_lines,
3177 reason = "one lifecycle proves shared materialization and every maintained frontend against the same zero-state owner"
3178 )]
3179 #[test]
3180 fn identity_insert_frontends_share_one_committed_range_without_rejected_consumption() {
3181 let session = initialize();
3182 let catalog = session
3183 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
3184 .expect("identity catalog should resolve");
3185 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
3186 .expect("identity row layout should build");
3187 let initial_description = session
3188 .try_describe_entity_by_name(ENTITY_NAME)
3189 .expect("accepted Identity description should resolve");
3190 assert_eq!(
3191 initial_description.entity_tag(),
3192 catalog.identity().entity_tag().value()
3193 );
3194 assert_eq!(
3195 initial_description.accepted_schema_fingerprint_method(),
3196 catalog.fingerprint_method_version()
3197 );
3198 assert_eq!(
3199 initial_description.accepted_schema_fingerprint(),
3200 catalog.fingerprint()
3201 );
3202 let initial_identity = initial_description
3203 .identity()
3204 .expect("accepted Identity policy should be described");
3205 assert_eq!(initial_identity.field(), "id");
3206 assert_eq!(initial_identity.generator(), "Identity::next");
3207 assert_eq!(initial_identity.accepted_kind(), "nat64");
3208 assert_eq!(initial_identity.minimum(), 1);
3209 assert_eq!(initial_identity.maximum(), u128::from(u64::MAX));
3210 assert_eq!(initial_identity.high_water(), 0);
3211 assert_eq!(initial_identity.remaining(), u128::from(u64::MAX));
3212 assert!(!initial_identity.exhausted());
3213
3214 let rejected = session
3215 .execute_accepted_structural_save_batch(
3216 &catalog,
3217 &descriptor,
3218 batch(&[1_000, 2_000]),
3219 Timestamp::from_millis(6),
3220 |_| Err::<(), _>(InternalError::executor_unsupported()),
3221 )
3222 .expect_err("a rejected precommit result must not publish its tentative range");
3223 assert_eq!(rejected.class(), ErrorClass::Unsupported);
3224 assert_eq!(DATA_STORE.with(|store| store.borrow().len()), 0);
3225
3226 let rows = session
3227 .execute_accepted_structural_save_batch(
3228 &catalog,
3229 &descriptor,
3230 batch(&[10, 20, 30]),
3231 Timestamp::from_millis(7),
3232 Ok,
3233 )
3234 .expect("one accepted batch should commit rows and one identity range");
3235 assert_eq!(
3236 rows.into_iter().map(|row| row.values).collect::<Vec<_>>(),
3237 vec![
3238 vec![Value::Nat64(1), Value::Nat64(10)],
3239 vec![Value::Nat64(2), Value::Nat64(20)],
3240 vec![Value::Nat64(3), Value::Nat64(30)],
3241 ],
3242 );
3243
3244 let dynamic = session
3245 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
3246 entity: ENTITY_NAME.to_string(),
3247 patch: DynamicStructuralPatch::new(vec![(
3248 "payload".to_string(),
3249 DynamicWriteCell::Value(InputValue::Nat64(40)),
3250 )]),
3251 })
3252 .expect("dynamic omission should commit through shared Identity generation");
3253 assert_eq!(dynamic.affected_rows, 1);
3254
3255 for (request, operation) in [
3256 (
3257 DynamicMutation::Insert {
3258 entity: ENTITY_NAME.to_string(),
3259 patch: DynamicStructuralPatch::new(vec![
3260 (
3261 "id".to_string(),
3262 DynamicWriteCell::Value(InputValue::Nat64(41)),
3263 ),
3264 (
3265 "payload".to_string(),
3266 DynamicWriteCell::Value(InputValue::Nat64(42)),
3267 ),
3268 ]),
3269 },
3270 icydb_diagnostic_code::DiagnosticMutationOperation::Insert,
3271 ),
3272 (
3273 DynamicMutation::Update {
3274 entity: ENTITY_NAME.to_string(),
3275 key: InputValue::Nat64(1),
3276 patch: DynamicStructuralPatch::new(vec![(
3277 "id".to_string(),
3278 DynamicWriteCell::Default,
3279 )]),
3280 },
3281 icydb_diagnostic_code::DiagnosticMutationOperation::Update,
3282 ),
3283 ] {
3284 let error = session
3285 .execute_trusted_dynamic_mutation(&request)
3286 .expect_err("structural Identity authorship and regeneration must reject");
3287 assert_eq!(error.class(), ErrorClass::Unsupported);
3288 assert_eq!(error.origin(), ErrorOrigin::Executor);
3289 assert_eq!(
3290 error.diagnostic_facts(),
3291 vec![
3292 (
3293 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
3294 ENTITY_TAG.value(),
3295 ),
3296 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
3297 (
3298 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
3299 operation.raw(),
3300 ),
3301 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,),
3302 ],
3303 );
3304 }
3305
3306 let binding = session
3307 .issue_typed_entity_binding(
3308 ENTITY_SOURCE,
3309 &[
3310 DynamicTypedFieldBindingRequest::new(
3311 ID_SOURCE.to_string(),
3312 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3313 false,
3314 ),
3315 DynamicTypedFieldBindingRequest::new(
3316 PAYLOAD_SOURCE.to_string(),
3317 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3318 false,
3319 ),
3320 ],
3321 )
3322 .expect("typed output should bind the Identity field");
3323 let typed_patch = binding
3324 .bind_write_fields(vec![(
3325 PAYLOAD_SOURCE.to_string(),
3326 DynamicWriteCell::Value(InputValue::Nat64(50)),
3327 )])
3328 .expect("typed payload should lower");
3329 let typed = session
3330 .execute_trusted_typed_mutation(
3331 &binding,
3332 &DynamicTypedMutation::Insert { patch: typed_patch },
3333 )
3334 .expect("typed omission should commit through shared Identity generation");
3335 assert_eq!(
3336 typed
3337 .expect("typed insert should return one mutation result")
3338 .affected_rows,
3339 1,
3340 );
3341 let explicit_typed_patch = binding
3342 .bind_write_fields(vec![
3343 (
3344 ID_SOURCE.to_string(),
3345 DynamicWriteCell::Value(InputValue::Nat64(51)),
3346 ),
3347 (
3348 PAYLOAD_SOURCE.to_string(),
3349 DynamicWriteCell::Value(InputValue::Nat64(52)),
3350 ),
3351 ])
3352 .expect("the low-level binding should retain exact authored intent");
3353 let explicit_typed_error = session
3354 .execute_trusted_typed_mutation(
3355 &binding,
3356 &DynamicTypedMutation::Insert {
3357 patch: explicit_typed_patch,
3358 },
3359 )
3360 .expect_err("typed Identity authorship must reject before allocation");
3361 assert_eq!(explicit_typed_error.class(), ErrorClass::Unsupported);
3362 assert_eq!(explicit_typed_error.origin(), ErrorOrigin::Executor);
3363 assert_eq!(
3364 explicit_typed_error.diagnostic_facts(),
3365 vec![
3366 (
3367 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
3368 ENTITY_TAG.value(),
3369 ),
3370 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
3371 (
3372 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
3373 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
3374 ),
3375 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,),
3376 ],
3377 );
3378
3379 let replace_error = session
3380 .execute_trusted_dynamic_mutation(&DynamicMutation::Replace {
3381 entity: ENTITY_NAME.to_string(),
3382 key: InputValue::Nat64(99),
3383 patch: DynamicStructuralPatch::new(vec![(
3384 "payload".to_string(),
3385 DynamicWriteCell::Value(InputValue::Nat64(60)),
3386 )]),
3387 })
3388 .expect_err("save-as-insert with a chosen Identity must reject");
3389 assert_eq!(replace_error.class(), ErrorClass::Unsupported);
3390 assert_eq!(replace_error.origin(), ErrorOrigin::Executor);
3391
3392 #[cfg(feature = "sql")]
3393 {
3394 for sql in [
3395 "INSERT INTO IdentityRow (payload) VALUES (70) RETURNING id, payload",
3396 "INSERT INTO IdentityRow (id, payload) VALUES (DEFAULT, 80) RETURNING id",
3397 ] {
3398 let _result = session
3399 .execute_trusted_sql_mutation(sql)
3400 .expect("SQL omission and DEFAULT should commit Identity generation");
3401 }
3402
3403 let error = session
3404 .execute_trusted_sql_mutation(
3405 "INSERT INTO IdentityRow (id, payload) VALUES (42, 90)",
3406 )
3407 .expect_err("an explicit SQL Identity value must reject before allocation");
3408 let diagnostic = error.diagnostic();
3409 assert_eq!(
3410 diagnostic.code(),
3411 icydb_diagnostic_code::DiagnosticCode::QuerySqlWriteBoundary,
3412 );
3413 assert!(matches!(
3414 diagnostic.detail(),
3415 Some(icydb_diagnostic_code::DiagnosticDetail::SqlWriteBoundary {
3416 boundary: icydb_diagnostic_code::SqlWriteBoundaryCode::ExplicitGeneratedField,
3417 }),
3418 ));
3419 }
3420
3421 let expected_committed = if cfg!(feature = "sql") { 7 } else { 5 };
3422 assert_eq!(
3423 DATA_STORE.with(|store| store.borrow().len()),
3424 expected_committed
3425 );
3426 SCHEMA_STORE.with(|store| {
3427 let cursor = store
3428 .borrow()
3429 .identity_statement_cursor(
3430 database_incarnation_id().expect("database incarnation should remain readable"),
3431 ENTITY_TAG,
3432 FieldId::new(1),
3433 &AcceptedFieldKind::Nat64,
3434 )
3435 .expect("committed writes must leave active state readable");
3436 assert_eq!(cursor.expected_high_water(), u128::from(expected_committed),);
3437 assert!(!cursor.has_allocations());
3438 });
3439 let committed_description = session
3440 .try_describe_entity_by_name(ENTITY_NAME)
3441 .expect("committed Identity description should resolve");
3442 let committed_identity = committed_description
3443 .identity()
3444 .expect("accepted Identity policy should remain described");
3445 assert_eq!(
3446 committed_identity.high_water(),
3447 u128::from(expected_committed),
3448 );
3449 assert_eq!(
3450 committed_identity.remaining(),
3451 u128::from(u64::MAX - expected_committed),
3452 );
3453 assert!(!committed_identity.exhausted());
3454 }
3455
3456 #[test]
3457 #[expect(
3458 clippy::too_many_lines,
3459 reason = "one ordered scenario exercises every durable interruption boundary, guarded recovery, derived rebuild, and both integrity tiers"
3460 )]
3461 fn journaled_identity_recovery_quiesces_every_publication_interruption_before_reallocation() {
3462 let session = initialize_journaled();
3463 let catalog = session
3464 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
3465 .expect("journaled identity catalog should resolve");
3466 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
3467 .expect("journaled identity row layout should build");
3468
3469 for (ordinal, interruption) in [
3470 MutationCommitInterruption::MarkerPersisted,
3471 MutationCommitInterruption::JournalPublished,
3472 MutationCommitInterruption::RowsPublished,
3473 MutationCommitInterruption::StateMaterialized,
3474 ]
3475 .into_iter()
3476 .enumerate()
3477 {
3478 interrupt_next_mutation_commit_for_tests(interruption);
3479 let interrupted = session.execute_accepted_structural_save_batch(
3480 &catalog,
3481 &descriptor,
3482 batch(&[u64::try_from(ordinal).expect("ordinal should fit")]),
3483 Timestamp::from_millis(8),
3484 Ok,
3485 );
3486 assert!(
3487 interrupted.is_err(),
3488 "the selected durable boundary should interrupt",
3489 );
3490
3491 let committed = session
3492 .execute_accepted_structural_save_batch(
3493 &catalog,
3494 &descriptor,
3495 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
3496 Timestamp::from_millis(9),
3497 Ok,
3498 )
3499 .expect("the next mutation must recover before allocating");
3500 let expected_high_water =
3501 u64::try_from((ordinal + 1) * 2).expect("small test high-water should fit");
3502 assert_eq!(
3503 committed
3504 .into_iter()
3505 .map(|row| row.values)
3506 .collect::<Vec<_>>(),
3507 vec![vec![
3508 Value::Nat64(expected_high_water),
3509 Value::Nat64(100 + u64::try_from(ordinal).expect("ordinal should fit")),
3510 ]],
3511 );
3512 assert_eq!(
3513 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
3514 expected_high_water,
3515 );
3516 JOURNALED_SCHEMA_STORE.with(|store| {
3517 let cursor = store
3518 .borrow()
3519 .identity_statement_cursor(
3520 database_incarnation_id()
3521 .expect("database incarnation should remain readable"),
3522 ENTITY_TAG,
3523 FieldId::new(1),
3524 &AcceptedFieldKind::Nat64,
3525 )
3526 .expect("guarded recovery must leave quiescent active state");
3527 assert_eq!(
3528 cursor.expected_high_water(),
3529 u128::from(expected_high_water),
3530 );
3531 assert!(!cursor.has_allocations());
3532 });
3533 }
3534
3535 for (ordinal, (interruption, deleted_key)) in [
3536 (MutationCommitInterruption::MarkerPersisted, 2),
3537 (MutationCommitInterruption::JournalPublished, 4),
3538 (MutationCommitInterruption::RowPrefixPublished, 6),
3539 (MutationCommitInterruption::RowsPublished, 8),
3540 (MutationCommitInterruption::StateMaterialized, 7),
3541 ]
3542 .into_iter()
3543 .enumerate()
3544 {
3545 let expected_payload =
3546 501 + u64::try_from(ordinal).expect("small interruption ordinal should fit");
3547 interrupt_next_mutation_commit_for_tests(interruption);
3548 let interrupted = session.execute_trusted_dynamic_mutation_batch(vec![
3549 DynamicMutation::Update {
3550 entity: ENTITY_NAME.to_string(),
3551 key: InputValue::Nat64(1),
3552 patch: dynamic_payload_patch(expected_payload),
3553 },
3554 DynamicMutation::Delete {
3555 entity: ENTITY_NAME.to_string(),
3556 key: InputValue::Nat64(deleted_key),
3557 },
3558 ]);
3559 assert!(
3560 interrupted.is_err(),
3561 "the selected caller-key mixed publication boundary should interrupt",
3562 );
3563 let recovered_update = session
3564 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
3565 entity: ENTITY_NAME.to_string(),
3566 key: InputValue::Nat64(1),
3567 patch: dynamic_payload_patch(expected_payload),
3568 })
3569 .expect("guarded reentry should complete the marker-authorized mixed batch");
3570 assert_eq!(
3571 recovered_update.affected_rows, 0,
3572 "the recovered update must already expose its admitted final image",
3573 );
3574 let recovered_delete = session
3575 .execute_trusted_dynamic_mutation(&DynamicMutation::Delete {
3576 entity: ENTITY_NAME.to_string(),
3577 key: InputValue::Nat64(deleted_key),
3578 })
3579 .expect_err("the recovered delete must already be materialized");
3580 assert_eq!(recovered_delete.class(), ErrorClass::NotFound);
3581 JOURNALED_SCHEMA_STORE.with(|store| {
3582 let cursor = store
3583 .borrow()
3584 .identity_statement_cursor(
3585 database_incarnation_id()
3586 .expect("database incarnation should remain readable"),
3587 ENTITY_TAG,
3588 FieldId::new(1),
3589 &AcceptedFieldKind::Nat64,
3590 )
3591 .expect("caller-key recovery must preserve active Identity state");
3592 assert_eq!(cursor.expected_high_water(), 8);
3593 assert!(!cursor.has_allocations());
3594 });
3595 }
3596
3597 forget_recovered_domain_for_tests(&session.db)
3598 .expect("the final journal tail should remain recoverable");
3599 session
3600 .db
3601 .ensure_recovered_state()
3602 .expect("derived rebuild must not allocate another identity");
3603
3604 let quick = execute_quick_integrity(&session.db, catalog.inspection_plan())
3605 .expect("quiescent Identity control inventory should be inspectable");
3606 assert_eq!(quick.status(), &QuickIntegrityStatus::CompleteClean);
3607 let row_page = execute_row_integrity_page(
3608 &session.db,
3609 catalog.inspection_plan(),
3610 PhysicalUnitCheckpoint::BeforeFirst,
3611 RowInspectionLimits::standard(),
3612 )
3613 .expect("Identity rows should remain within committed high-water");
3614 assert!(row_page.exhausted());
3615 assert!(row_page.findings().is_empty());
3616
3617 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 3);
3618 assert!(
3619 JOURNALED_INDEX_STORE.with(|store| !store.borrow().is_empty()),
3620 "derived index rebuild should restore witnesses without allocating identities",
3621 );
3622 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
3623 JOURNALED_SCHEMA_STORE.with(|store| {
3624 let cursor = store
3625 .borrow()
3626 .identity_statement_cursor(
3627 database_incarnation_id().expect("database incarnation should remain readable"),
3628 ENTITY_TAG,
3629 FieldId::new(1),
3630 &AcceptedFieldKind::Nat64,
3631 )
3632 .expect("folded identity state should reopen without allocating");
3633 assert_eq!(cursor.expected_high_water(), 8);
3634 assert!(!cursor.has_allocations());
3635 });
3636 }
3637
3638 #[test]
3639 #[ignore = "release-closeout native timing probe for one marker-authorized Identity recovery"]
3640 fn identity_recovery_closeout_reports_guarded_reentry_time() {
3641 let session = initialize_journaled();
3642 let catalog = session
3643 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
3644 .expect("journaled identity catalog should resolve");
3645 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
3646 .expect("journaled identity row layout should build");
3647
3648 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::RowsPublished);
3649 let interrupted = session.execute_accepted_structural_save_batch(
3650 &catalog,
3651 &descriptor,
3652 batch(&[1]),
3653 Timestamp::from_millis(10),
3654 Ok,
3655 );
3656 assert!(
3657 interrupted.is_err(),
3658 "the selected publication boundary should interrupt",
3659 );
3660
3661 let start = Instant::now();
3662 let committed = session
3663 .execute_accepted_structural_save_batch(
3664 &catalog,
3665 &descriptor,
3666 batch(&[2]),
3667 Timestamp::from_millis(11),
3668 Ok,
3669 )
3670 .expect("guarded reentry should recover before allocation");
3671 let elapsed = start.elapsed();
3672 assert_eq!(
3673 committed
3674 .into_iter()
3675 .map(|row| row.values)
3676 .collect::<Vec<_>>(),
3677 vec![vec![Value::Nat64(2), Value::Nat64(2)]],
3678 );
3679
3680 println!(
3681 "identity recovery closeout: guarded_reentry_nanos={}",
3682 elapsed.as_nanos(),
3683 );
3684 }
3685}
3686
3687#[cfg(test)]
3688mod targeted_rule_mutation_tests {
3689 use super::{
3690 DbSession, DynamicMutation, DynamicStructuralPatch, DynamicTypedFieldBindingRequest,
3691 DynamicTypedFieldType, DynamicTypedMutation, DynamicWriteCell,
3692 };
3693 use crate::{
3694 db::{
3695 data::{DataStore, encode_input_value_for_candidate_field_contract},
3696 index::IndexStore,
3697 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
3698 schema::{
3699 AcceptedCheckLiteralV1, AcceptedCompositeCatalog, AcceptedFieldDecodeContract,
3700 AcceptedFieldKind, AcceptedNamedTypeIdentity, AcceptedRuleOperation,
3701 AcceptedRuleTarget, AcceptedSchemaRevision, AcceptedSourceBindingCatalog,
3702 ConstraintOrigin, FieldId, FieldStorageDecode, FieldWriteManagement, LeafCodec,
3703 PersistedFieldSnapshot, PersistedNestedLeafSnapshot, PersistedSchemaSnapshot,
3704 ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy, SchemaInsertDefault,
3705 SchemaRowLayout, SchemaStore, SchemaVersion,
3706 accepted_schema_candidate_with_catalogs_for_tests,
3707 build_record_newtype_composite_catalog_for_tests,
3708 empty_accepted_enum_catalog_for_tests, enum_catalog::ValueAdmissionBudget,
3709 },
3710 },
3711 error::InternalError,
3712 traits::{CanisterKind, Path},
3713 types::EntityTag,
3714 value::InputValue,
3715 };
3716 use icydb_schema::{
3717 ConstraintSourceKey, EntitySourceKey, FieldSourceKey, ScalarType, TypeSourceKey,
3718 };
3719 use std::{cell::RefCell, collections::BTreeMap};
3720
3721 const STORE_PATH: &str = "session::write::targeted_rule_mutation_tests::Store";
3722 const ENTITY_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity";
3723 const ID_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::id";
3724 const PROFILE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::profile";
3725 const UPDATED_AT_SOURCE: &str =
3726 "session::write::targeted_rule_mutation_tests::Entity::updated_at";
3727 const PROFILE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Profile";
3728 const DEGREE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Degree";
3729 const DEGREE_MEMBER_SOURCE: &str =
3730 "session::write::targeted_rule_mutation_tests::Profile::degree";
3731 const DEGREE_RULE_SOURCE: &str =
3732 "session::write::targeted_rule_mutation_tests::Profile::degree_multiple";
3733
3734 struct TestCanister;
3735
3736 impl Path for TestCanister {
3737 const PATH: &'static str = "session::write::targeted_rule_mutation_tests::Canister";
3738 }
3739
3740 impl CanisterKind for TestCanister {
3741 const COMMIT_MEMORY_ID: u8 = 43;
3742 const COMMIT_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.commit.v1";
3743 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 44;
3744 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3745 "icydb.targeted_mutation_tests.integrity.progress.v1";
3746 }
3747
3748 thread_local! {
3749 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
3750 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
3751 static SCHEMA_STORE: RefCell<SchemaStore> =
3752 const { RefCell::new(SchemaStore::init_heap()) };
3753 static STORE_REGISTRY: StoreRegistry = {
3754 let mut registry = StoreRegistry::new();
3755 registry.register_store(
3756 STORE_PATH,
3757 &DATA_STORE,
3758 &INDEX_STORE,
3759 &SCHEMA_STORE,
3760 StoreAllocationIdentities::absent(),
3761 StoreRuntimeStorageCapabilities::heap(),
3762 ).expect("targeted mutation test store should register");
3763 registry
3764 };
3765 }
3766
3767 fn source<T, E: std::fmt::Debug>(raw: &str, parse: impl FnOnce(String) -> Result<T, E>) -> T {
3768 parse(raw.to_string()).expect("test source identity should admit")
3769 }
3770
3771 fn profile_input(degree: u64) -> InputValue {
3772 InputValue::Map(vec![(
3773 InputValue::Text("degree".to_string()),
3774 InputValue::Nat64(degree),
3775 )])
3776 }
3777
3778 fn structural_patch(id: u64, degree: u64) -> DynamicStructuralPatch {
3779 DynamicStructuralPatch::new(vec![
3780 (
3781 "id".to_string(),
3782 DynamicWriteCell::Value(InputValue::Nat64(id)),
3783 ),
3784 (
3785 "profile".to_string(),
3786 DynamicWriteCell::Value(profile_input(degree)),
3787 ),
3788 ])
3789 }
3790
3791 fn encoded_value(
3792 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
3793 composite_catalog: &AcceptedCompositeCatalog,
3794 name: &str,
3795 kind: &AcceptedFieldKind,
3796 storage_decode: FieldStorageDecode,
3797 leaf_codec: LeafCodec,
3798 value: InputValue,
3799 ) -> Vec<u8> {
3800 let field = AcceptedFieldDecodeContract::new(name, kind, false, storage_decode, leaf_codec);
3801 encode_input_value_for_candidate_field_contract(
3802 enum_catalog,
3803 composite_catalog,
3804 field,
3805 value,
3806 &mut ValueAdmissionBudget::standard(),
3807 )
3808 .expect("test accepted value should encode")
3809 }
3810
3811 fn nat64_literal(
3812 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
3813 composite_catalog: &AcceptedCompositeCatalog,
3814 value: u64,
3815 ) -> AcceptedCheckLiteralV1 {
3816 let kind = AcceptedFieldKind::Nat64;
3817 AcceptedCheckLiteralV1::from_accepted_parts(
3818 kind.clone(),
3819 FieldStorageDecode::ByKind,
3820 LeafCodec::Scalar(ScalarCodec::Nat64),
3821 encoded_value(
3822 enum_catalog,
3823 composite_catalog,
3824 "degree_bound",
3825 &kind,
3826 FieldStorageDecode::ByKind,
3827 LeafCodec::Scalar(ScalarCodec::Nat64),
3828 InputValue::Nat64(value),
3829 ),
3830 )
3831 }
3832
3833 fn targeted_constraint_id(error: &InternalError) -> u32 {
3834 let facts = error.diagnostic_facts();
3835 assert!(facts.contains(&(
3836 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
3837 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
3838 )));
3839 assert!(facts.contains(&(icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,)));
3840 assert!(facts.contains(&(
3841 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
3842 icydb_diagnostic_code::DiagnosticConstraintKind::TargetedRule.raw(),
3843 )));
3844 assert_eq!(
3845 facts
3846 .iter()
3847 .filter(|(tag, _)| matches!(
3848 tag,
3849 icydb_diagnostic_code::DiagnosticFactTag::RootField
3850 | icydb_diagnostic_code::DiagnosticFactTag::RecordMember
3851 ))
3852 .copied()
3853 .collect::<Vec<_>>(),
3854 vec![
3855 (icydb_diagnostic_code::DiagnosticFactTag::RootField, 2),
3856 (
3857 icydb_diagnostic_code::DiagnosticFactTag::RecordMember,
3858 icydb_diagnostic_code::pack_u32_pair(1, 1),
3859 ),
3860 ]
3861 );
3862 let value = facts
3863 .iter()
3864 .find_map(|(tag, value)| {
3865 (*tag == icydb_diagnostic_code::DiagnosticFactTag::ConstraintId).then_some(*value)
3866 })
3867 .expect("targeted mutation should retain its accepted constraint ID");
3868 u32::try_from(value).expect("accepted constraint ID fits u32")
3869 }
3870
3871 #[expect(
3872 clippy::too_many_lines,
3873 reason = "one end-to-end fixture proves every maintained write frontend converges on the same accepted targeted-rule schedule"
3874 )]
3875 #[test]
3876 fn targeted_rules_converge_across_dynamic_typed_sql_default_timestamp_and_batch_writes() {
3877 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3878 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3879 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3880
3881 let entity_tag = EntityTag::new(93);
3882 let enum_catalog = empty_accepted_enum_catalog_for_tests();
3883 let (composite_catalog, profile_type, degree_type, degree_member) =
3884 build_record_newtype_composite_catalog_for_tests(
3885 "tests::TargetedProfile".to_string(),
3886 "degree".to_string(),
3887 "tests::TargetedDegree".to_string(),
3888 AcceptedFieldKind::Nat64,
3889 &enum_catalog,
3890 )
3891 .expect("targeted mutation composites should close");
3892 let profile_kind = AcceptedFieldKind::Composite {
3893 type_id: profile_type,
3894 };
3895 let profile_default = encoded_value(
3896 &enum_catalog,
3897 &composite_catalog,
3898 "profile",
3899 &profile_kind,
3900 FieldStorageDecode::CatalogValue,
3901 LeafCodec::Structural,
3902 profile_input(12),
3903 );
3904 let fields = vec![
3905 PersistedFieldSnapshot::new_initial(
3906 FieldId::new(1),
3907 "id".to_string(),
3908 SchemaFieldSlot::new(0),
3909 AcceptedFieldKind::Nat64,
3910 Vec::new(),
3911 false,
3912 SchemaInsertDefault::None,
3913 FieldStorageDecode::ByKind,
3914 LeafCodec::Scalar(ScalarCodec::Nat64),
3915 ),
3916 PersistedFieldSnapshot::new_initial(
3917 FieldId::new(2),
3918 "profile".to_string(),
3919 SchemaFieldSlot::new(1),
3920 profile_kind,
3921 vec![PersistedNestedLeafSnapshot::new(
3922 vec!["degree".to_string()],
3923 AcceptedFieldKind::Composite {
3924 type_id: degree_type,
3925 },
3926 false,
3927 )],
3928 false,
3929 SchemaInsertDefault::SlotPayload(profile_default),
3930 FieldStorageDecode::CatalogValue,
3931 LeafCodec::Structural,
3932 ),
3933 PersistedFieldSnapshot::new_initial_with_write_policy(
3934 FieldId::new(3),
3935 "updated_at".to_string(),
3936 SchemaFieldSlot::new(2),
3937 AcceptedFieldKind::Timestamp,
3938 Vec::new(),
3939 false,
3940 SchemaInsertDefault::None,
3941 SchemaFieldWritePolicy::from_model_policies(
3942 None,
3943 Some(FieldWriteManagement::UpdatedAt),
3944 ),
3945 FieldStorageDecode::ByKind,
3946 LeafCodec::Scalar(ScalarCodec::Timestamp),
3947 ),
3948 ];
3949 let mut snapshot = PersistedSchemaSnapshot::new(
3950 SchemaVersion::initial(),
3951 ENTITY_SOURCE.to_string(),
3952 "TargetedMutation".to_string(),
3953 FieldId::new(1),
3954 SchemaRowLayout::initial(
3955 fields
3956 .iter()
3957 .map(|field| (field.id(), field.slot()))
3958 .collect(),
3959 ),
3960 fields,
3961 );
3962 let constraint_catalog = snapshot
3963 .constraint_catalog()
3964 .clone()
3965 .with_added_targeted_rule(
3966 "profile_degree_multiple".to_string(),
3967 ConstraintOrigin::Generated,
3968 AcceptedRuleTarget::new(
3969 FieldId::new(2),
3970 AcceptedNamedTypeIdentity::Composite(degree_type),
3971 ),
3972 AcceptedRuleOperation::MultipleOf {
3973 divisor: nat64_literal(&enum_catalog, &composite_catalog, 5),
3974 },
3975 )
3976 .expect("targeted mutation rule should allocate");
3977 let targeted_rule_id = constraint_catalog
3978 .constraints()
3979 .last()
3980 .expect("targeted mutation rule should persist")
3981 .id();
3982 snapshot = snapshot.with_constraint_catalog(constraint_catalog);
3983
3984 let entity_source = source(ENTITY_SOURCE, EntitySourceKey::try_new);
3985 let id_source = source(ID_SOURCE, FieldSourceKey::try_new);
3986 let profile_source = source(PROFILE_SOURCE, FieldSourceKey::try_new);
3987 let updated_at_source = source(UPDATED_AT_SOURCE, FieldSourceKey::try_new);
3988 let profile_type_source = source(PROFILE_TYPE_SOURCE, TypeSourceKey::try_new);
3989 let degree_type_source = source(DEGREE_TYPE_SOURCE, TypeSourceKey::try_new);
3990 let degree_member_source = source(DEGREE_MEMBER_SOURCE, FieldSourceKey::try_new);
3991 let degree_rule_source = source(DEGREE_RULE_SOURCE, ConstraintSourceKey::try_new);
3992 let source_bindings = AcceptedSourceBindingCatalog::initial_for_tests(
3993 BTreeMap::from([(entity_source, entity_tag)]),
3994 BTreeMap::from([
3995 ((entity_tag, id_source), FieldId::new(1)),
3996 ((entity_tag, profile_source), FieldId::new(2)),
3997 ((entity_tag, updated_at_source), FieldId::new(3)),
3998 ]),
3999 BTreeMap::from([((entity_tag, degree_rule_source), targeted_rule_id)]),
4000 BTreeMap::new(),
4001 BTreeMap::new(),
4002 )
4003 .with_initial_named_types_for_tests(
4004 BTreeMap::from([
4005 (
4006 profile_type_source,
4007 AcceptedNamedTypeIdentity::Composite(profile_type),
4008 ),
4009 (
4010 degree_type_source,
4011 AcceptedNamedTypeIdentity::Composite(degree_type),
4012 ),
4013 ]),
4014 BTreeMap::new(),
4015 BTreeMap::from([((profile_type, degree_member_source), degree_member)]),
4016 );
4017 let candidate = accepted_schema_candidate_with_catalogs_for_tests(
4018 STORE_PATH,
4019 AcceptedSchemaRevision::INITIAL,
4020 enum_catalog,
4021 composite_catalog,
4022 source_bindings,
4023 BTreeMap::from([(entity_tag, snapshot)]),
4024 );
4025
4026 let session = DbSession::<TestCanister>::new(&STORE_REGISTRY);
4027 session
4028 .db
4029 .ensure_recovered_state()
4030 .expect("targeted mutation test database should initialize");
4031 let store = session
4032 .db
4033 .store_handle(STORE_PATH)
4034 .expect("targeted mutation test store should resolve");
4035 crate::db::commit::publish_accepted_schema_candidate(
4036 STORE_PATH,
4037 store,
4038 AcceptedSchemaRevision::NONE,
4039 &candidate,
4040 )
4041 .expect("targeted mutation candidate should publish");
4042
4043 let dynamic_error = session
4044 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4045 entity: "TargetedMutation".to_string(),
4046 patch: structural_patch(1, 12),
4047 })
4048 .expect_err("dynamic write must enforce the targeted rule");
4049 assert_eq!(
4050 targeted_constraint_id(&dynamic_error),
4051 targeted_rule_id.get()
4052 );
4053
4054 let binding = session
4055 .issue_typed_entity_binding(
4056 ENTITY_SOURCE,
4057 &[
4058 DynamicTypedFieldBindingRequest::new(
4059 ID_SOURCE.to_string(),
4060 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
4061 false,
4062 ),
4063 DynamicTypedFieldBindingRequest::new(
4064 PROFILE_SOURCE.to_string(),
4065 DynamicTypedFieldType::Named(PROFILE_TYPE_SOURCE.to_string()),
4066 false,
4067 ),
4068 DynamicTypedFieldBindingRequest::new(
4069 UPDATED_AT_SOURCE.to_string(),
4070 DynamicTypedFieldType::Scalar(ScalarType::Timestamp),
4071 false,
4072 ),
4073 ],
4074 )
4075 .expect("targeted typed binding should issue");
4076 let typed_patch = binding
4077 .bind_write_fields(vec![
4078 (
4079 ID_SOURCE.to_string(),
4080 DynamicWriteCell::Value(InputValue::Nat64(2)),
4081 ),
4082 (
4083 PROFILE_SOURCE.to_string(),
4084 DynamicWriteCell::Value(profile_input(12)),
4085 ),
4086 ])
4087 .expect("targeted typed patch should bind");
4088 let typed_error = session
4089 .execute_trusted_typed_mutation(
4090 &binding,
4091 &DynamicTypedMutation::Insert { patch: typed_patch },
4092 )
4093 .expect_err("typed write must enforce the targeted rule");
4094 assert_eq!(targeted_constraint_id(&typed_error), targeted_rule_id.get());
4095
4096 #[cfg(feature = "sql")]
4097 {
4098 let sql_error = session
4099 .execute_trusted_sql_mutation("INSERT INTO TargetedMutation (id) VALUES (3)")
4100 .expect_err("SQL default resolution must enforce the targeted rule");
4101 let crate::db::QueryError::Execute(execute) = sql_error else {
4102 panic!("targeted SQL write should fail at shared execution admission");
4103 };
4104 assert_eq!(
4105 targeted_constraint_id(execute.as_internal()),
4106 targeted_rule_id.get()
4107 );
4108 }
4109
4110 session
4111 .execute_trusted_dynamic_mutation_batch(vec![
4112 DynamicMutation::Insert {
4113 entity: "TargetedMutation".to_string(),
4114 patch: structural_patch(4, 5),
4115 },
4116 DynamicMutation::Insert {
4117 entity: "TargetedMutation".to_string(),
4118 patch: structural_patch(5, 12),
4119 },
4120 ])
4121 .expect_err("one invalid targeted value must reject the whole batch");
4122 assert_eq!(
4123 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
4124 Some(0),
4125 "no frontend or earlier valid batch row may escape targeted admission",
4126 );
4127
4128 let admitted = session
4129 .execute_trusted_dynamic_mutation_batch(vec![
4130 DynamicMutation::Insert {
4131 entity: "TargetedMutation".to_string(),
4132 patch: structural_patch(6, 5),
4133 },
4134 DynamicMutation::Insert {
4135 entity: "TargetedMutation".to_string(),
4136 patch: structural_patch(7, 10),
4137 },
4138 ])
4139 .expect("compliant targeted values should share one accepted batch");
4140 let [first, second] = admitted.rows.as_slice() else {
4141 panic!("the mixed targeted batch should return two rows");
4142 };
4143 let first_timestamp = first
4144 .get(2)
4145 .expect("the first mixed row should contain its managed timestamp");
4146 assert!(matches!(
4147 first_timestamp,
4148 crate::value::OutputValue::Timestamp(_)
4149 ));
4150 assert_eq!(
4151 second.get(2),
4152 Some(first_timestamp),
4153 "one accepted mixed batch must materialize one managed timestamp",
4154 );
4155 assert_eq!(
4156 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
4157 Some(2),
4158 );
4159 }
4160}