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