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