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 fn assert_query_diagnostic(
1463 error: crate::db::QueryError,
1464 code: icydb_diagnostic_code::DiagnosticCode,
1465 origin: icydb_diagnostic_code::ErrorOrigin,
1466 detail: icydb_diagnostic_code::DiagnosticDetail,
1467 ) {
1468 let diagnostic = error.diagnostic();
1469 assert_eq!(diagnostic.code(), code);
1470 assert_eq!(diagnostic.origin(), origin);
1471 assert_eq!(diagnostic.detail(), Some(&detail));
1472 }
1473
1474 #[test]
1475 fn typed_adapter_kind_matching_is_exact_but_accepts_relation_key_wrappers() {
1476 let relation = AcceptedFieldKind::Relation {
1477 target_path: "test::Target".to_string(),
1478 target_entity_name: "Target".to_string(),
1479 target_entity_tag: EntityTag::new(7),
1480 target_store_path: "test::Store".to_string(),
1481 key_kind: Box::new(AcceptedFieldKind::Nat64),
1482 };
1483
1484 assert!(typed_adapter_field_kind_matches(
1485 &relation,
1486 &AcceptedFieldKind::Nat64,
1487 ));
1488 assert!(typed_adapter_field_kind_matches(
1489 &AcceptedFieldKind::List(Box::new(relation)),
1490 &AcceptedFieldKind::List(Box::new(AcceptedFieldKind::Nat64)),
1491 ));
1492 assert!(!typed_adapter_field_kind_matches(
1493 &AcceptedFieldKind::Nat64,
1494 &AcceptedFieldKind::Nat32,
1495 ));
1496 }
1497
1498 #[test]
1499 fn typed_adapter_field_contract_rejects_invalid_named_source_identity() {
1500 assert!(matches!(
1501 dynamic_typed_field_type(DynamicTypedFieldType::Named(String::new())),
1502 Err(DynamicTypedBindingError::FieldUnavailable),
1503 ));
1504 assert!(matches!(
1505 dynamic_typed_field_type(DynamicTypedFieldType::Scalar(ScalarType::Nat16)),
1506 Ok(icydb_schema::FieldType::Scalar(ScalarType::Nat16)),
1507 ));
1508 }
1509
1510 #[expect(clippy::too_many_lines)]
1513 #[test]
1514 fn typed_binding_uses_accepted_ids_and_slots_across_renames_and_name_reuse() {
1515 let entity_tag = EntityTag::new(91);
1516 let other_entity_tag = EntityTag::new(92);
1517 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
1518 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
1519 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
1520
1521 let session = DbSession::<TestCanister>::new(&STORE_REGISTRY);
1522 session
1523 .db
1524 .ensure_recovered_state()
1525 .expect("typed adapter test database should initialize");
1526 publish(
1527 &session,
1528 AcceptedSchemaRevision::NONE,
1529 AcceptedSchemaRevision::INITIAL,
1530 BTreeMap::from([(
1531 entity_tag,
1532 snapshot(
1533 ENTITY_SOURCE,
1534 "Entity",
1535 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
1536 ),
1537 )]),
1538 BTreeMap::from([
1539 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1540 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
1541 ]),
1542 );
1543
1544 let initial_catalog = session
1545 .find_accepted_schema_catalog_context_for_entity_source_key(ENTITY_SOURCE)
1546 .expect("initial source catalog lookup should inspect")
1547 .expect("initial source catalog should exist");
1548 assert_eq!(initial_catalog.identity().entity_tag(), entity_tag);
1549 let initial = session
1550 .issue_typed_entity_binding(
1551 entity_source(ENTITY_SOURCE).as_str(),
1552 &[request(ID_SOURCE), request(VALUE_SOURCE)],
1553 )
1554 .expect("initial typed binding should issue");
1555 assert_eq!(initial.field_slot(ID_SOURCE), Some(0));
1556 assert_eq!(initial.field_slot(VALUE_SOURCE), Some(1));
1557 assert_eq!(initial.output_field_slot("value"), Some(1));
1558 let initial_patch = initial
1559 .bind_write_fields(vec![(
1560 VALUE_SOURCE.to_string(),
1561 DynamicWriteCell::Value(InputValue::Nat64(7)),
1562 )])
1563 .expect("source-bound patch should lower");
1564 assert_eq!(
1565 initial_patch.fields(),
1566 &[(2, 1, DynamicWriteCell::Value(InputValue::Nat64(7)))]
1567 );
1568
1569 publish(
1570 &session,
1571 AcceptedSchemaRevision::INITIAL,
1572 AcceptedSchemaRevision::new(2),
1573 BTreeMap::from([
1574 (
1575 entity_tag,
1576 snapshot(
1577 ENTITY_SOURCE,
1578 "RenamedEntity",
1579 vec![
1580 nat64_field(1, "id", 0),
1581 nat64_field(2, "renamed_value", 1),
1582 nat64_field(3, "value", 2),
1583 ],
1584 ),
1585 ),
1586 (
1587 other_entity_tag,
1588 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
1589 ),
1590 ]),
1591 BTreeMap::from([
1592 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1593 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
1594 (
1595 (entity_tag, field_source(REPLACEMENT_SOURCE)),
1596 FieldId::new(3),
1597 ),
1598 (
1599 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
1600 FieldId::new(1),
1601 ),
1602 ]),
1603 );
1604
1605 assert!(
1606 !session
1607 .typed_entity_binding_is_current(&initial)
1608 .expect("renamed binding currentness should inspect")
1609 );
1610 let renamed = session
1611 .issue_typed_entity_binding(ENTITY_SOURCE, &[request(ID_SOURCE), request(VALUE_SOURCE)])
1612 .expect("renamed source-bound adapter should rebind");
1613 assert_eq!(renamed.entity(), "RenamedEntity");
1614 assert_eq!(renamed.field_slot(VALUE_SOURCE), Some(1));
1615 assert_eq!(renamed.output_field_slot("renamed_value"), Some(1));
1616 assert_eq!(renamed.output_field_slot("value"), None);
1617
1618 publish(
1619 &session,
1620 AcceptedSchemaRevision::new(2),
1621 AcceptedSchemaRevision::new(3),
1622 BTreeMap::from([
1623 (
1624 entity_tag,
1625 snapshot(
1626 ENTITY_SOURCE,
1627 "RenamedEntity",
1628 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
1629 ),
1630 ),
1631 (
1632 other_entity_tag,
1633 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
1634 ),
1635 ]),
1636 BTreeMap::from([
1637 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1638 (
1639 (entity_tag, field_source(REPLACEMENT_SOURCE)),
1640 FieldId::new(2),
1641 ),
1642 (
1643 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
1644 FieldId::new(1),
1645 ),
1646 ]),
1647 );
1648
1649 assert!(matches!(
1650 session.issue_typed_entity_binding(
1651 ENTITY_SOURCE,
1652 &[request(ID_SOURCE), request(VALUE_SOURCE)],
1653 ),
1654 Err(DynamicTypedBindingError::FieldUnavailable),
1655 ));
1656 assert!(
1657 !session
1658 .typed_entity_binding_is_current(&renamed)
1659 .expect("removed source binding should become stale")
1660 );
1661
1662 let replacement = session
1663 .issue_typed_entity_binding(
1664 ENTITY_SOURCE,
1665 &[request(ID_SOURCE), request(REPLACEMENT_SOURCE)],
1666 )
1667 .expect("explicit replacement source should bind");
1668 assert!(
1669 session
1670 .execute_trusted_typed_mutation(
1671 &replacement,
1672 &DynamicTypedMutation::Insert {
1673 patch: initial_patch
1674 },
1675 )
1676 .expect("cross-binding patch should fail closed")
1677 .is_none()
1678 );
1679 let patch = replacement
1680 .bind_write_fields(vec![
1681 (
1682 ID_SOURCE.to_string(),
1683 DynamicWriteCell::Value(InputValue::Nat64(1)),
1684 ),
1685 (
1686 REPLACEMENT_SOURCE.to_string(),
1687 DynamicWriteCell::Value(InputValue::Nat64(9)),
1688 ),
1689 ])
1690 .expect("replacement source write should bind by accepted IDs and slots");
1691 let result = session
1692 .execute_trusted_typed_mutation(&replacement, &DynamicTypedMutation::Insert { patch })
1693 .expect("typed insert should use the accepted mutation pipeline")
1694 .expect("replacement binding should remain current");
1695 assert_eq!(result.entity, "RenamedEntity");
1696 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
1697 assert_eq!(
1698 result.rows,
1699 vec![vec![
1700 crate::value::OutputValue::Nat64(1),
1701 crate::value::OutputValue::Nat64(9)
1702 ]]
1703 );
1704 assert_eq!(result.affected_rows, 1);
1705
1706 let second_patch = replacement
1707 .bind_write_fields(vec![
1708 (
1709 ID_SOURCE.to_string(),
1710 DynamicWriteCell::Value(InputValue::Nat64(2)),
1711 ),
1712 (
1713 REPLACEMENT_SOURCE.to_string(),
1714 DynamicWriteCell::Value(InputValue::Nat64(10)),
1715 ),
1716 ])
1717 .expect("second source-bound patch should lower");
1718 session
1719 .execute_trusted_typed_mutation(
1720 &replacement,
1721 &DynamicTypedMutation::Insert {
1722 patch: second_patch,
1723 },
1724 )
1725 .expect("second typed insert should use the accepted mutation pipeline")
1726 .expect("replacement binding should remain current");
1727
1728 {
1729 let query = crate::db::DynamicQuery::new("RenamedEntity")
1730 .select(["id", "value"])
1731 .order_by(crate::db::asc("id"))
1732 .limit(1);
1733 let result = session
1734 .execute_trusted_dynamic_query(&query)
1735 .expect("SQL-free dynamic execution should use accepted authority");
1736 assert_eq!(result.entity, "RenamedEntity");
1737 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
1738 assert_eq!(
1739 result.rows,
1740 vec![vec![
1741 crate::value::OutputValue::Nat64(1),
1742 crate::value::OutputValue::Nat64(9)
1743 ]]
1744 );
1745 assert_eq!(result.row_count, 1);
1746 assert_query_diagnostic(
1747 session
1748 .execute_trusted_dynamic_query(&query.cursor("00"))
1749 .expect_err("scalar execution must reject grouped cursor state"),
1750 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
1751 icydb_diagnostic_code::ErrorOrigin::Query,
1752 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
1753 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
1754 },
1755 );
1756 assert_query_diagnostic(
1757 session
1758 .execute_public_dynamic_grouped_query(
1759 &crate::db::DynamicQuery::new("RenamedEntity").grouped_limits(1, 1024),
1760 )
1761 .expect_err("grouped execution must reject scalar query state"),
1762 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
1763 icydb_diagnostic_code::ErrorOrigin::Query,
1764 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
1765 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
1766 },
1767 );
1768
1769 let grouped_query = crate::db::DynamicQuery::new("RenamedEntity")
1770 .filter(crate::db::FieldRef::new("id").eq(1_u64))
1771 .group_by("value")
1772 .aggregate(crate::db::count())
1773 .grouped_limits(1, 1024)
1774 .limit(1);
1775 let grouped = session
1776 .execute_public_dynamic_grouped_query(&grouped_query)
1777 .expect("SQL-free grouped execution should use accepted authority");
1778 let typed_grouped = session
1779 .execute_public_dynamic_grouped_query_for_typed_binding(
1780 &replacement,
1781 &grouped_query,
1782 )
1783 .expect("typed grouped execution should inspect accepted authority")
1784 .expect("replacement binding should remain current");
1785 assert_eq!(typed_grouped, grouped);
1786 assert!(
1787 session
1788 .execute_public_dynamic_grouped_query_for_typed_binding(
1789 &renamed,
1790 &grouped_query,
1791 )
1792 .expect("stale grouped binding should inspect accepted authority")
1793 .is_none(),
1794 "stale typed grouped bindings must fail closed before execution"
1795 );
1796 assert_eq!(grouped.entity, "RenamedEntity");
1797 assert_eq!(grouped.row_count, 1);
1798 assert_eq!(grouped.rows.len(), 1);
1799 assert_eq!(
1800 grouped.rows[0].group_key(),
1801 &[crate::value::OutputValue::Nat64(9)]
1802 );
1803 assert_eq!(
1804 grouped.rows[0].aggregate_values(),
1805 &[crate::value::OutputValue::Nat64(1)]
1806 );
1807 assert_eq!(grouped.next_cursor, None);
1808
1809 assert_query_diagnostic(
1810 session
1811 .execute_public_dynamic_grouped_query(&grouped_query.clone().select(["value"]))
1812 .expect_err("grouped output must reject scalar selection"),
1813 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
1814 icydb_diagnostic_code::ErrorOrigin::Query,
1815 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
1816 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
1817 },
1818 );
1819 assert_query_diagnostic(
1820 session
1821 .execute_public_dynamic_grouped_query(
1822 &crate::db::DynamicQuery::new("RenamedEntity")
1823 .group_by("value")
1824 .aggregate(crate::db::count()),
1825 )
1826 .expect_err("public grouped execution must require explicit limits"),
1827 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
1828 icydb_diagnostic_code::ErrorOrigin::Query,
1829 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
1830 reason:
1831 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
1832 },
1833 );
1834 assert_query_diagnostic(
1835 session
1836 .execute_trusted_dynamic_grouped_query(
1837 &crate::db::DynamicQuery::new("RenamedEntity")
1838 .group_by("value")
1839 .aggregate(crate::db::count())
1840 .grouped_limits(0, 1024),
1841 )
1842 .expect_err("trusted grouped execution must reject zero limits"),
1843 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
1844 icydb_diagnostic_code::ErrorOrigin::Query,
1845 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
1846 reason:
1847 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
1848 },
1849 );
1850 assert_query_diagnostic(
1851 session
1852 .execute_public_dynamic_grouped_query(&grouped_query.grouped_limits(101, 1024))
1853 .expect_err("public grouped execution must enforce its group budget"),
1854 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
1855 icydb_diagnostic_code::ErrorOrigin::Query,
1856 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
1857 reason:
1858 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryExceedsBudget,
1859 },
1860 );
1861
1862 let paged_query = crate::db::DynamicQuery::new("RenamedEntity")
1863 .group_by("value")
1864 .aggregate(crate::db::count())
1865 .grouped_limits(2, 1024)
1866 .limit(1);
1867 assert_query_diagnostic(
1868 session
1869 .execute_public_dynamic_grouped_query(&paged_query)
1870 .expect_err("public grouped execution must reject an unbounded full scan"),
1871 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
1872 icydb_diagnostic_code::ErrorOrigin::Query,
1873 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
1874 reason:
1875 icydb_diagnostic_code::QueryReadAdmissionCode::UnboundedFullScanRejected,
1876 },
1877 );
1878 let first_page = session
1879 .execute_trusted_dynamic_grouped_query(&paged_query)
1880 .expect("SQL-free grouped first page should execute");
1881 assert_eq!(first_page.row_count, 1);
1882 assert_eq!(
1883 first_page.rows[0].group_key(),
1884 &[crate::value::OutputValue::Nat64(9)]
1885 );
1886 let cursor = first_page
1887 .next_cursor
1888 .expect("first grouped page should return a continuation cursor");
1889 assert_query_diagnostic(
1890 session
1891 .execute_trusted_dynamic_grouped_query(
1892 &paged_query.clone().cursor(format!("{cursor}0")),
1893 )
1894 .expect_err("tampered grouped cursor must fail closed"),
1895 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
1896 icydb_diagnostic_code::ErrorOrigin::Cursor,
1897 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
1898 kind: icydb_diagnostic_code::QueryErrorKind::InvalidContinuationCursor,
1899 },
1900 );
1901 let second_page = session
1902 .execute_trusted_dynamic_grouped_query(&paged_query.cursor(cursor))
1903 .expect("SQL-free grouped continuation should execute");
1904 assert_eq!(second_page.row_count, 1);
1905 assert_eq!(
1906 second_page.rows[0].group_key(),
1907 &[crate::value::OutputValue::Nat64(10)]
1908 );
1909 assert_eq!(second_page.next_cursor, None);
1910 }
1911 }
1912}
1913
1914#[cfg(test)]
1915mod mixed_relation_batch_tests {
1916 use super::{DbSession, DynamicMutation, DynamicStructuralPatch, DynamicWriteCell};
1917 use crate::{
1918 db::{
1919 data::DataStore,
1920 index::IndexStore,
1921 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
1922 schema::{
1923 AcceptedConstraintCatalog, AcceptedFieldKind, AcceptedSchemaRevision, FieldId,
1924 FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
1925 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
1926 PersistedRelationEdgeSnapshot, PersistedSchemaSnapshot, RelationId, ScalarCodec,
1927 SchemaFieldSlot, SchemaIndexId, SchemaInsertDefault, SchemaRowLayout, SchemaStore,
1928 SchemaVersion, accepted_schema_candidate_with_field_bindings_for_tests,
1929 },
1930 },
1931 error::{ConstraintDiagnosticKind, ErrorClass},
1932 traits::{CanisterKind, Path},
1933 types::EntityTag,
1934 value::{InputValue, OutputValue},
1935 };
1936 use icydb_schema::FieldSourceKey;
1937 use std::{cell::RefCell, collections::BTreeMap};
1938
1939 const STORE_PATH: &str = "session::write::mixed_relation_batch_tests::Store";
1940 const ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node";
1941 const ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::id";
1942 const PARENT_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::parent_id";
1943 const CODE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::code";
1944 const ENTITY_NAME: &str = "MixedRelationNode";
1945 const ENTITY_TAG: EntityTag = EntityTag::new(94);
1946 const OTHER_ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other";
1947 const OTHER_ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::id";
1948 const OTHER_VALUE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::value";
1949 const OTHER_ENTITY_NAME: &str = "MixedRelationOther";
1950 const OTHER_ENTITY_TAG: EntityTag = EntityTag::new(95);
1951
1952 struct TestCanister;
1953
1954 impl Path for TestCanister {
1955 const PATH: &'static str = "session::write::mixed_relation_batch_tests::Canister";
1956 }
1957
1958 impl CanisterKind for TestCanister {
1959 const COMMIT_MEMORY_ID: u8 = 47;
1960 const COMMIT_STABLE_KEY: &'static str = "icydb.mixed_relation_batch_tests.commit.v1";
1961 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 48;
1962 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
1963 "icydb.mixed_relation_batch_tests.integrity.progress.v1";
1964 }
1965
1966 thread_local! {
1967 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
1968 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
1969 static SCHEMA_STORE: RefCell<SchemaStore> =
1970 const { RefCell::new(SchemaStore::init_heap()) };
1971 static STORE_REGISTRY: StoreRegistry = {
1972 let mut registry = StoreRegistry::new();
1973 registry.register_store(
1974 STORE_PATH,
1975 &DATA_STORE,
1976 &INDEX_STORE,
1977 &SCHEMA_STORE,
1978 StoreAllocationIdentities::absent(),
1979 StoreRuntimeStorageCapabilities::heap(),
1980 ).expect("mixed relation test store should register");
1981 registry
1982 };
1983 }
1984
1985 fn source_key(source: &str) -> FieldSourceKey {
1986 FieldSourceKey::try_new(source).expect("mixed relation field source should admit")
1987 }
1988
1989 fn relation_snapshot() -> PersistedSchemaSnapshot {
1990 let fields = vec![
1991 PersistedFieldSnapshot::new_initial(
1992 FieldId::new(1),
1993 "id".to_string(),
1994 SchemaFieldSlot::new(0),
1995 AcceptedFieldKind::Nat64,
1996 Vec::new(),
1997 false,
1998 SchemaInsertDefault::None,
1999 FieldStorageDecode::ByKind,
2000 LeafCodec::Scalar(ScalarCodec::Nat64),
2001 ),
2002 PersistedFieldSnapshot::new_initial(
2003 FieldId::new(2),
2004 "parent_id".to_string(),
2005 SchemaFieldSlot::new(1),
2006 AcceptedFieldKind::Relation {
2007 target_path: ENTITY_SOURCE.to_string(),
2008 target_entity_name: ENTITY_NAME.to_string(),
2009 target_entity_tag: ENTITY_TAG,
2010 target_store_path: STORE_PATH.to_string(),
2011 key_kind: Box::new(AcceptedFieldKind::Nat64),
2012 },
2013 Vec::new(),
2014 true,
2015 SchemaInsertDefault::None,
2016 FieldStorageDecode::ByKind,
2017 LeafCodec::Scalar(ScalarCodec::Nat64),
2018 ),
2019 PersistedFieldSnapshot::new_initial(
2020 FieldId::new(3),
2021 "code".to_string(),
2022 SchemaFieldSlot::new(2),
2023 AcceptedFieldKind::Nat64,
2024 Vec::new(),
2025 false,
2026 SchemaInsertDefault::None,
2027 FieldStorageDecode::ByKind,
2028 LeafCodec::Scalar(ScalarCodec::Nat64),
2029 ),
2030 ];
2031 let relation = PersistedRelationEdgeSnapshot::new(
2032 RelationId::new(1).expect("mixed relation identity should be non-zero"),
2033 "parent".to_string(),
2034 ENTITY_SOURCE.to_string(),
2035 vec![FieldId::new(2)],
2036 );
2037 let snapshot = PersistedSchemaSnapshot::new_with_indexes(
2038 SchemaVersion::initial(),
2039 ENTITY_SOURCE.to_string(),
2040 ENTITY_NAME.to_string(),
2041 FieldId::new(1),
2042 SchemaRowLayout::initial(
2043 fields
2044 .iter()
2045 .map(|field| (field.id(), field.slot()))
2046 .collect(),
2047 ),
2048 fields,
2049 vec![PersistedIndexSnapshot::new(
2050 SchemaIndexId::new(1).expect("mixed unique index identity should be non-zero"),
2051 1,
2052 "by_code".to_string(),
2053 STORE_PATH.to_string(),
2054 true,
2055 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
2056 FieldId::new(3),
2057 SchemaFieldSlot::new(2),
2058 vec!["code".to_string()],
2059 AcceptedFieldKind::Nat64,
2060 false,
2061 )]),
2062 None,
2063 )],
2064 )
2065 .with_relations(vec![relation]);
2066 let constraints = AcceptedConstraintCatalog::initial(
2067 snapshot.fields(),
2068 snapshot.indexes(),
2069 snapshot.relations(),
2070 )
2071 .expect("mixed relation constraints should close");
2072 snapshot.with_constraint_catalog(constraints)
2073 }
2074
2075 fn other_snapshot() -> PersistedSchemaSnapshot {
2076 let fields = vec![
2077 PersistedFieldSnapshot::new_initial(
2078 FieldId::new(1),
2079 "id".to_string(),
2080 SchemaFieldSlot::new(0),
2081 AcceptedFieldKind::Nat64,
2082 Vec::new(),
2083 false,
2084 SchemaInsertDefault::None,
2085 FieldStorageDecode::ByKind,
2086 LeafCodec::Scalar(ScalarCodec::Nat64),
2087 ),
2088 PersistedFieldSnapshot::new_initial(
2089 FieldId::new(2),
2090 "value".to_string(),
2091 SchemaFieldSlot::new(1),
2092 AcceptedFieldKind::Nat64,
2093 Vec::new(),
2094 false,
2095 SchemaInsertDefault::None,
2096 FieldStorageDecode::ByKind,
2097 LeafCodec::Scalar(ScalarCodec::Nat64),
2098 ),
2099 ];
2100 PersistedSchemaSnapshot::new(
2101 SchemaVersion::initial(),
2102 OTHER_ENTITY_SOURCE.to_string(),
2103 OTHER_ENTITY_NAME.to_string(),
2104 FieldId::new(1),
2105 SchemaRowLayout::initial(
2106 fields
2107 .iter()
2108 .map(|field| (field.id(), field.slot()))
2109 .collect(),
2110 ),
2111 fields,
2112 )
2113 }
2114
2115 fn initialize() -> DbSession<TestCanister> {
2116 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2117 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2118 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2119 let session = DbSession::<TestCanister>::new(&STORE_REGISTRY);
2120 session
2121 .db
2122 .ensure_recovered_state()
2123 .expect("mixed relation database should initialize");
2124 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
2125 STORE_PATH,
2126 AcceptedSchemaRevision::INITIAL,
2127 BTreeMap::from([
2128 (ENTITY_TAG, relation_snapshot()),
2129 (OTHER_ENTITY_TAG, other_snapshot()),
2130 ]),
2131 BTreeMap::from([
2132 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
2133 ((ENTITY_TAG, source_key(PARENT_SOURCE)), FieldId::new(2)),
2134 ((ENTITY_TAG, source_key(CODE_SOURCE)), FieldId::new(3)),
2135 (
2136 (OTHER_ENTITY_TAG, source_key(OTHER_ID_SOURCE)),
2137 FieldId::new(1),
2138 ),
2139 (
2140 (OTHER_ENTITY_TAG, source_key(OTHER_VALUE_SOURCE)),
2141 FieldId::new(2),
2142 ),
2143 ]),
2144 );
2145 let store = session
2146 .db
2147 .store_handle(STORE_PATH)
2148 .expect("mixed relation store should resolve");
2149 crate::db::commit::publish_accepted_schema_candidate(
2150 STORE_PATH,
2151 store,
2152 AcceptedSchemaRevision::NONE,
2153 &candidate,
2154 )
2155 .expect("mixed relation candidate should publish");
2156 session
2157 }
2158
2159 fn patch(id: Option<u64>, parent: Option<u64>, code: Option<u64>) -> DynamicStructuralPatch {
2160 let mut fields = Vec::new();
2161 if let Some(id) = id {
2162 fields.push((
2163 "id".to_string(),
2164 DynamicWriteCell::Value(InputValue::Nat64(id)),
2165 ));
2166 }
2167 fields.push((
2168 "parent_id".to_string(),
2169 parent.map_or(DynamicWriteCell::Null, |parent| {
2170 DynamicWriteCell::Value(InputValue::Nat64(parent))
2171 }),
2172 ));
2173 if let Some(code) = code {
2174 fields.push((
2175 "code".to_string(),
2176 DynamicWriteCell::Value(InputValue::Nat64(code)),
2177 ));
2178 }
2179 DynamicStructuralPatch::new(fields)
2180 }
2181
2182 fn insert(id: u64, parent: Option<u64>) -> DynamicMutation {
2183 insert_with_code(id, parent, id)
2184 }
2185
2186 fn insert_with_code(id: u64, parent: Option<u64>, code: u64) -> DynamicMutation {
2187 DynamicMutation::Insert {
2188 entity: ENTITY_NAME.to_string(),
2189 patch: patch(Some(id), parent, Some(code)),
2190 }
2191 }
2192
2193 fn update_parent(id: u64, parent: Option<u64>) -> DynamicMutation {
2194 DynamicMutation::Update {
2195 entity: ENTITY_NAME.to_string(),
2196 key: InputValue::Nat64(id),
2197 patch: patch(None, parent, None),
2198 }
2199 }
2200
2201 fn update_code(id: u64, code: u64) -> DynamicMutation {
2202 DynamicMutation::Update {
2203 entity: ENTITY_NAME.to_string(),
2204 key: InputValue::Nat64(id),
2205 patch: DynamicStructuralPatch::new(vec![(
2206 "code".to_string(),
2207 DynamicWriteCell::Value(InputValue::Nat64(code)),
2208 )]),
2209 }
2210 }
2211
2212 fn delete(id: u64) -> DynamicMutation {
2213 DynamicMutation::Delete {
2214 entity: ENTITY_NAME.to_string(),
2215 key: InputValue::Nat64(id),
2216 }
2217 }
2218
2219 fn expected_row(id: u64, parent: Option<u64>) -> Vec<OutputValue> {
2220 expected_row_with_code(id, parent, id)
2221 }
2222
2223 fn expected_row_with_code(id: u64, parent: Option<u64>, code: u64) -> Vec<OutputValue> {
2224 vec![
2225 OutputValue::Nat64(id),
2226 parent.map_or(OutputValue::Null, OutputValue::Nat64),
2227 OutputValue::Nat64(code),
2228 ]
2229 }
2230
2231 fn other_patch(id: Option<u64>, value: u64) -> DynamicStructuralPatch {
2232 let mut fields = Vec::new();
2233 if let Some(id) = id {
2234 fields.push((
2235 "id".to_string(),
2236 DynamicWriteCell::Value(InputValue::Nat64(id)),
2237 ));
2238 }
2239 fields.push((
2240 "value".to_string(),
2241 DynamicWriteCell::Value(InputValue::Nat64(value)),
2242 ));
2243 DynamicStructuralPatch::new(fields)
2244 }
2245
2246 fn assert_relation_violation(error: &crate::error::InternalError) {
2247 let diagnostic = error
2248 .constraint_diagnostic()
2249 .expect("relation violations should retain their accepted constraint");
2250 assert_eq!(
2251 diagnostic.constraint_kind(),
2252 ConstraintDiagnosticKind::Relation,
2253 );
2254 }
2255
2256 #[test]
2257 fn mixed_relation_validation_uses_the_complete_final_row_overlay() {
2258 let session = initialize();
2259 session
2260 .execute_trusted_dynamic_mutation_batch(vec![insert(1, None), insert(2, Some(1))])
2261 .expect("the initial relation should commit");
2262
2263 let blocked = session
2264 .execute_trusted_dynamic_mutation(&delete(1))
2265 .expect_err("an unaffected committed source must block target deletion");
2266 assert_relation_violation(&blocked);
2267
2268 let deleted = session
2269 .execute_trusted_dynamic_mutation_batch(vec![delete(2), delete(1)])
2270 .expect("a source and its target should delete atomically");
2271 assert_eq!(
2272 deleted.rows,
2273 vec![expected_row(2, Some(1)), expected_row(1, None)],
2274 );
2275
2276 session
2277 .execute_trusted_dynamic_mutation_batch(vec![insert(3, None), insert(4, Some(3))])
2278 .expect("the update-away fixture should commit");
2279 let updated_away = session
2280 .execute_trusted_dynamic_mutation_batch(vec![update_parent(4, None), delete(3)])
2281 .expect("an updated final source may release a deleted target");
2282 assert_eq!(
2283 updated_away.rows,
2284 vec![expected_row(4, None), expected_row(3, None)],
2285 );
2286
2287 session
2288 .execute_trusted_dynamic_mutation_batch(vec![insert(5, None), insert(6, Some(5))])
2289 .expect("the retained-reference fixture should commit");
2290 let retained = session
2291 .execute_trusted_dynamic_mutation_batch(vec![update_parent(6, Some(5)), delete(5)])
2292 .expect_err("a final updated source must still block target deletion");
2293 assert_relation_violation(&retained);
2294
2295 session
2296 .execute_trusted_dynamic_mutation(&insert(7, None))
2297 .expect("the inserted-reference fixture target should commit");
2298 let inserted_reference = session
2299 .execute_trusted_dynamic_mutation_batch(vec![insert(8, Some(7)), delete(7)])
2300 .expect_err("a final inserted source must not reference a deleted target");
2301 assert_relation_violation(&inserted_reference);
2302
2303 let inserted_target = session
2304 .execute_trusted_dynamic_mutation_batch(vec![insert(10, Some(9)), insert(9, None)])
2305 .expect("an inserted relation should see its batch-final target");
2306 assert_eq!(
2307 inserted_target.rows,
2308 vec![expected_row(10, Some(9)), expected_row(9, None)],
2309 );
2310
2311 session
2312 .execute_trusted_dynamic_mutation(&insert(11, None))
2313 .expect("the updated-reference fixture source should commit");
2314 let updated_target = session
2315 .execute_trusted_dynamic_mutation_batch(vec![
2316 update_parent(11, Some(12)),
2317 insert(12, None),
2318 ])
2319 .expect("an updated relation should see its batch-final target");
2320 assert_eq!(
2321 updated_target.rows,
2322 vec![expected_row(11, Some(12)), expected_row(12, None)],
2323 );
2324 }
2325
2326 #[test]
2327 fn mixed_batch_rejects_cross_entity_missing_and_collision_then_honors_replace() {
2328 let session = initialize();
2329 session
2330 .execute_trusted_dynamic_mutation(&insert(1, None))
2331 .expect("the primary mixed fixture row should commit");
2332 session
2333 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
2334 entity: OTHER_ENTITY_NAME.to_string(),
2335 patch: other_patch(Some(1), 10),
2336 })
2337 .expect("the secondary mixed fixture row should commit");
2338
2339 let mixed_entity = session
2340 .execute_trusted_dynamic_mutation_batch(vec![
2341 update_code(1, 11),
2342 DynamicMutation::Update {
2343 entity: OTHER_ENTITY_NAME.to_string(),
2344 key: InputValue::Nat64(1),
2345 patch: other_patch(None, 11),
2346 },
2347 ])
2348 .expect_err("one atomic batch must not cross accepted entities");
2349 assert_eq!(mixed_entity.class(), ErrorClass::Conflict);
2350
2351 let missing = session
2352 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 12), delete(99)])
2353 .expect_err("a late missing delete must reject the earlier staged update");
2354 assert_eq!(missing.class(), ErrorClass::NotFound);
2355
2356 session
2357 .execute_trusted_dynamic_mutation(&insert(2, None))
2358 .expect("the collision fixture should commit");
2359 let collision = session
2360 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 13), insert(2, None)])
2361 .expect_err("an insert collision must reject the earlier staged update");
2362 assert_eq!(collision.class(), ErrorClass::Conflict);
2363 let failures_unchanged = session
2364 .execute_trusted_dynamic_mutation(&update_code(1, 1))
2365 .expect("failed batches must preserve the original unique value");
2366 assert_eq!(failures_unchanged.affected_rows, 0);
2367
2368 let replaced = session
2369 .execute_trusted_dynamic_mutation_batch(vec![
2370 update_code(1, 14),
2371 DynamicMutation::Replace {
2372 entity: ENTITY_NAME.to_string(),
2373 key: InputValue::Nat64(99),
2374 patch: patch(None, None, Some(99)),
2375 },
2376 ])
2377 .expect("ordinary caller-key replace should insert its absent final row");
2378 assert_eq!(
2379 replaced.rows,
2380 vec![
2381 expected_row_with_code(1, None, 14),
2382 expected_row_with_code(99, None, 99),
2383 ],
2384 );
2385
2386 let unchanged = session
2387 .execute_trusted_dynamic_mutation(&update_code(1, 14))
2388 .expect("the successful mixed replace must publish its preceding update");
2389 assert_eq!(unchanged.affected_rows, 0);
2390 let other_unchanged = session
2391 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
2392 entity: OTHER_ENTITY_NAME.to_string(),
2393 key: InputValue::Nat64(1),
2394 patch: other_patch(None, 10),
2395 })
2396 .expect("cross-entity rejection must preserve the secondary row");
2397 assert_eq!(other_unchanged.affected_rows, 0);
2398 }
2399
2400 #[test]
2401 fn mixed_batch_unique_swap_and_delete_release_use_the_final_overlay() {
2402 let session = initialize();
2403 session
2404 .execute_trusted_dynamic_mutation_batch(vec![
2405 insert_with_code(1, None, 10),
2406 insert_with_code(2, None, 20),
2407 ])
2408 .expect("the unique-overlay fixture should commit");
2409
2410 let swapped = session
2411 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 20), update_code(2, 10)])
2412 .expect("two final rows should atomically swap unique memberships");
2413 assert_eq!(
2414 swapped.rows,
2415 vec![
2416 expected_row_with_code(1, None, 20),
2417 expected_row_with_code(2, None, 10),
2418 ],
2419 );
2420
2421 let released = session
2422 .execute_trusted_dynamic_mutation_batch(vec![delete(1), insert_with_code(3, None, 20)])
2423 .expect("a delete should release unique membership to a final inserted row");
2424 assert_eq!(
2425 released.rows,
2426 vec![
2427 expected_row_with_code(1, None, 20),
2428 expected_row_with_code(3, None, 20),
2429 ],
2430 );
2431 }
2432}
2433
2434#[cfg(test)]
2435mod identity_pre_key_tests {
2436 use super::{
2437 AcceptedMutationIntentPatch, AcceptedRowLayoutRuntimeContract, AcceptedStructuralMutation,
2438 AcceptedStructuralMutationTarget, DbSession, DynamicMutation, DynamicStructuralPatch,
2439 DynamicTypedFieldBindingRequest, DynamicTypedFieldType, DynamicTypedMutation,
2440 DynamicWriteCell, FieldSlot, MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
2441 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
2442 add_structural_mutation_staged_bytes, checked_pre_key_candidate_count,
2443 insert_key_exists_after_generation, validate_structural_mutation_result_bytes,
2444 };
2445 use crate::{
2446 db::{
2447 commit::{database_incarnation_id, forget_recovered_domain_for_tests},
2448 data::DataStore,
2449 executor::{MutationCommitInterruption, interrupt_next_mutation_commit_for_tests},
2450 index::IndexStore,
2451 integrity::{
2452 PhysicalUnitCheckpoint, QuickIntegrityStatus, RowInspectionLimits,
2453 execute_quick_integrity, execute_row_integrity_page,
2454 },
2455 journal::JournalTailStore,
2456 registry::{
2457 StoreAllocationIdentities, StoreAllocationIdentity, StoreRegistry,
2458 StoreRuntimeStorageCapabilities,
2459 },
2460 schema::{
2461 AcceptedFieldKind, AcceptedSchemaRevision, FieldId, FieldInsertGeneration,
2462 FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
2463 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
2464 PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy,
2465 SchemaIndexId, SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
2466 accepted_schema_candidate_with_field_bindings_for_tests,
2467 },
2468 write_context::MutationMode,
2469 },
2470 error::{ErrorClass, ErrorOrigin, InternalError},
2471 testing::test_memory,
2472 traits::{CanisterKind, Path},
2473 types::{EntityTag, Timestamp},
2474 value::{InputValue, OutputValue, Value},
2475 };
2476 use icydb_schema::{FieldSourceKey, ScalarType};
2477 use std::{cell::RefCell, collections::BTreeMap, time::Instant};
2478
2479 const STORE_PATH: &str = "session::write::identity_pre_key_tests::Store";
2480 const ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::Entity";
2481 const ID_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::id";
2482 const PAYLOAD_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::payload";
2483 const ENTITY_NAME: &str = "IdentityRow";
2484 const ENTITY_TAG: EntityTag = EntityTag::new(93);
2485 const JOURNALED_STORE_PATH: &str = "session::write::identity_pre_key_tests::JournaledStore";
2486
2487 struct TestCanister;
2488
2489 impl Path for TestCanister {
2490 const PATH: &'static str = "session::write::identity_pre_key_tests::Canister";
2491 }
2492
2493 impl CanisterKind for TestCanister {
2494 const COMMIT_MEMORY_ID: u8 = 45;
2495 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.commit.v1";
2496 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 46;
2497 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
2498 "icydb.identity_pre_key_tests.integrity.progress.v1";
2499 }
2500
2501 thread_local! {
2502 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
2503 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
2504 static SCHEMA_STORE: RefCell<SchemaStore> =
2505 const { RefCell::new(SchemaStore::init_heap()) };
2506 static STORE_REGISTRY: StoreRegistry = {
2507 let mut registry = StoreRegistry::new();
2508 registry.register_store(
2509 STORE_PATH,
2510 &DATA_STORE,
2511 &INDEX_STORE,
2512 &SCHEMA_STORE,
2513 StoreAllocationIdentities::absent(),
2514 StoreRuntimeStorageCapabilities::heap(),
2515 ).expect("identity pre-key test store should register");
2516 registry
2517 };
2518 static JOURNALED_DATA_STORE: RefCell<DataStore> =
2519 RefCell::new(DataStore::init_journaled(test_memory(186)));
2520 static JOURNALED_INDEX_STORE: RefCell<IndexStore> =
2521 RefCell::new(IndexStore::init_journaled(test_memory(187)));
2522 static JOURNALED_SCHEMA_STORE: RefCell<SchemaStore> =
2523 RefCell::new(SchemaStore::init_journaled(test_memory(188)));
2524 static JOURNALED_TAIL_STORE: RefCell<JournalTailStore> =
2525 RefCell::new(JournalTailStore::init(test_memory(189)));
2526 static JOURNALED_STORE_REGISTRY: StoreRegistry = {
2527 let mut registry = StoreRegistry::new();
2528 registry.register_journaled_store(
2529 JOURNALED_STORE_PATH,
2530 &JOURNALED_DATA_STORE,
2531 &JOURNALED_INDEX_STORE,
2532 &JOURNALED_SCHEMA_STORE,
2533 &JOURNALED_TAIL_STORE,
2534 StoreAllocationIdentities::new_journaled(
2535 StoreAllocationIdentity::new(186, "icydb.test.identity-range.data.v1"),
2536 StoreAllocationIdentity::new(187, "icydb.test.identity-range.index.v1"),
2537 StoreAllocationIdentity::new(188, "icydb.test.identity-range.schema.v1"),
2538 StoreAllocationIdentity::new(189, "icydb.test.identity-range.journal.v1"),
2539 ),
2540 StoreRuntimeStorageCapabilities::journaled(),
2541 ).expect("identity range journaled store should register");
2542 registry
2543 };
2544 }
2545
2546 struct JournaledTestCanister;
2547
2548 impl Path for JournaledTestCanister {
2549 const PATH: &'static str = "session::write::identity_pre_key_tests::JournaledCanister";
2550 }
2551
2552 impl CanisterKind for JournaledTestCanister {
2553 const COMMIT_MEMORY_ID: u8 = 190;
2554 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_range_tests.commit.v1";
2555 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 191;
2556 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
2557 "icydb.identity_range_tests.integrity.progress.v1";
2558 }
2559
2560 fn source_key(source: &str) -> FieldSourceKey {
2561 FieldSourceKey::try_new(source).expect("identity test field source should admit")
2562 }
2563
2564 fn identity_snapshot(store_path: &str) -> PersistedSchemaSnapshot {
2565 let fields = vec![
2566 PersistedFieldSnapshot::new_initial_with_write_policy(
2567 FieldId::new(1),
2568 "id".to_string(),
2569 SchemaFieldSlot::new(0),
2570 AcceptedFieldKind::Nat64,
2571 Vec::new(),
2572 false,
2573 SchemaInsertDefault::None,
2574 SchemaFieldWritePolicy::from_model_policies(
2575 Some(FieldInsertGeneration::Identity),
2576 None,
2577 ),
2578 FieldStorageDecode::ByKind,
2579 LeafCodec::Scalar(ScalarCodec::Nat64),
2580 ),
2581 PersistedFieldSnapshot::new_initial(
2582 FieldId::new(2),
2583 "payload".to_string(),
2584 SchemaFieldSlot::new(1),
2585 AcceptedFieldKind::Nat64,
2586 Vec::new(),
2587 false,
2588 SchemaInsertDefault::None,
2589 FieldStorageDecode::ByKind,
2590 LeafCodec::Scalar(ScalarCodec::Nat64),
2591 ),
2592 ];
2593 PersistedSchemaSnapshot::new_with_indexes(
2594 SchemaVersion::initial(),
2595 ENTITY_SOURCE.to_string(),
2596 ENTITY_NAME.to_string(),
2597 FieldId::new(1),
2598 SchemaRowLayout::initial(
2599 fields
2600 .iter()
2601 .map(|field| (field.id(), field.slot()))
2602 .collect(),
2603 ),
2604 fields,
2605 vec![PersistedIndexSnapshot::new(
2606 SchemaIndexId::new(1).expect("identity test index ID should admit"),
2607 1,
2608 "by_payload".to_string(),
2609 store_path.to_string(),
2610 false,
2611 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
2612 FieldId::new(2),
2613 SchemaFieldSlot::new(1),
2614 vec!["payload".to_string()],
2615 AcceptedFieldKind::Nat64,
2616 false,
2617 )]),
2618 None,
2619 )],
2620 )
2621 }
2622
2623 fn initialize() -> DbSession<TestCanister> {
2624 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2625 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2626 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2627 let session = DbSession::<TestCanister>::new(&STORE_REGISTRY);
2628 session
2629 .db
2630 .ensure_recovered_state()
2631 .expect("identity pre-key test database should initialize");
2632 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
2633 STORE_PATH,
2634 AcceptedSchemaRevision::INITIAL,
2635 BTreeMap::from([(ENTITY_TAG, identity_snapshot(STORE_PATH))]),
2636 BTreeMap::from([
2637 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
2638 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
2639 ]),
2640 );
2641 let store = session
2642 .db
2643 .store_handle(STORE_PATH)
2644 .expect("identity pre-key test store should resolve");
2645 crate::db::commit::publish_accepted_schema_candidate(
2646 STORE_PATH,
2647 store,
2648 AcceptedSchemaRevision::NONE,
2649 &candidate,
2650 )
2651 .expect("identity candidate should publish with explicit zero state");
2652 session
2653 }
2654
2655 fn initialize_journaled() -> DbSession<JournaledTestCanister> {
2656 let session = DbSession::<JournaledTestCanister>::new(&JOURNALED_STORE_REGISTRY);
2657 session
2658 .db
2659 .ensure_recovered_state()
2660 .expect("journaled identity database should initialize");
2661 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
2662 JOURNALED_STORE_PATH,
2663 AcceptedSchemaRevision::INITIAL,
2664 BTreeMap::from([(ENTITY_TAG, identity_snapshot(JOURNALED_STORE_PATH))]),
2665 BTreeMap::from([
2666 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
2667 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
2668 ]),
2669 );
2670 let store = session
2671 .db
2672 .store_handle(JOURNALED_STORE_PATH)
2673 .expect("journaled identity store should resolve");
2674 crate::db::commit::publish_accepted_schema_candidate(
2675 JOURNALED_STORE_PATH,
2676 store,
2677 AcceptedSchemaRevision::NONE,
2678 &candidate,
2679 )
2680 .expect("journaled identity candidate should publish");
2681 session
2682 }
2683
2684 fn payload_patch(value: u64) -> AcceptedMutationIntentPatch {
2685 AcceptedMutationIntentPatch::new()
2686 .set_authored(FieldSlot::from_validated_index(1), InputValue::Nat64(value))
2687 }
2688
2689 fn dynamic_payload_patch(value: u64) -> DynamicStructuralPatch {
2690 DynamicStructuralPatch::new(vec![(
2691 "payload".to_string(),
2692 DynamicWriteCell::Value(InputValue::Nat64(value)),
2693 )])
2694 }
2695
2696 fn expected_dynamic_row(id: u64, payload: u64) -> Vec<OutputValue> {
2697 vec![OutputValue::Nat64(id), OutputValue::Nat64(payload)]
2698 }
2699
2700 fn assert_dynamic_payload(session: &DbSession<TestCanister>, key: u64, expected_payload: u64) {
2701 let unchanged = session
2702 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
2703 entity: ENTITY_NAME.to_string(),
2704 key: InputValue::Nat64(key),
2705 patch: dynamic_payload_patch(expected_payload),
2706 })
2707 .expect("the expected row should remain readable through a no-op update");
2708 assert_eq!(unchanged.affected_rows, 0);
2709 assert_eq!(
2710 unchanged.rows,
2711 vec![expected_dynamic_row(key, expected_payload)],
2712 );
2713 }
2714
2715 fn batch(values: &[u64]) -> Vec<AcceptedStructuralMutation> {
2716 values
2717 .iter()
2718 .map(|value| {
2719 AcceptedStructuralMutation::save(
2720 MutationMode::Insert,
2721 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
2722 payload_patch(*value),
2723 )
2724 })
2725 .collect()
2726 }
2727
2728 fn assert_identity_boundary(error: &InternalError) {
2729 assert_eq!(error.class(), ErrorClass::Unsupported);
2730 assert_eq!(error.origin(), ErrorOrigin::Identity);
2731 }
2732
2733 #[test]
2734 fn generated_candidate_collision_is_identity_corruption_before_generic_uniqueness() {
2735 let generated = insert_key_exists_after_generation(true);
2736 assert_eq!(generated.class(), ErrorClass::Corruption);
2737 assert_eq!(generated.origin(), ErrorOrigin::Identity);
2738
2739 let ordinary = insert_key_exists_after_generation(false);
2740 assert_ne!(ordinary.origin(), ErrorOrigin::Identity);
2741 }
2742
2743 #[cfg(target_pointer_width = "64")]
2744 #[test]
2745 fn pre_key_candidate_count_rejects_values_beyond_the_persisted_u32_bound() {
2746 let error = checked_pre_key_candidate_count(
2747 usize::try_from(u64::from(u32::MAX) + 1).expect("64-bit usize should hold u32 + 1"),
2748 )
2749 .expect_err("candidate counts beyond u32 must reject");
2750 assert_identity_boundary(&error);
2751 }
2752
2753 #[test]
2754 #[expect(
2755 clippy::too_many_lines,
2756 reason = "one holding lifecycle proves split, merge, transfer, late-failure neutrality, result order, and Identity state"
2757 )]
2758 fn mixed_structural_batch_preserves_holding_conservation_and_failure_atomicity() {
2759 let session = initialize();
2760 let seeded = session
2761 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
2762 .expect("seed rows should commit");
2763 assert_eq!(seeded.affected_rows, 1);
2764
2765 let split = session
2766 .execute_trusted_dynamic_mutation_batch(vec![
2767 DynamicMutation::Update {
2768 entity: ENTITY_NAME.to_string(),
2769 key: InputValue::Nat64(1),
2770 patch: dynamic_payload_patch(60),
2771 },
2772 DynamicMutation::Insert {
2773 entity: ENTITY_NAME.to_string(),
2774 patch: dynamic_payload_patch(40),
2775 },
2776 ])
2777 .expect("one holding should split atomically");
2778 assert_eq!(split.affected_rows, 2);
2779 assert_eq!(
2780 split.rows,
2781 vec![expected_dynamic_row(1, 60), expected_dynamic_row(2, 40),],
2782 "split after-images must retain input order and exact quantity",
2783 );
2784
2785 let rejected_split = session
2786 .execute_trusted_dynamic_mutation_batch(vec![
2787 DynamicMutation::Update {
2788 entity: ENTITY_NAME.to_string(),
2789 key: InputValue::Nat64(1),
2790 patch: dynamic_payload_patch(50),
2791 },
2792 DynamicMutation::Insert {
2793 entity: ENTITY_NAME.to_string(),
2794 patch: DynamicStructuralPatch::new(Vec::new()),
2795 },
2796 ])
2797 .expect_err("an invalid split output must reject the staged source update");
2798 assert_eq!(rejected_split.class(), ErrorClass::Unsupported);
2799 assert_eq!(rejected_split.origin(), ErrorOrigin::Executor);
2800 assert_dynamic_payload(&session, 1, 60);
2801 assert_dynamic_payload(&session, 2, 40);
2802
2803 let transfer = session
2804 .execute_trusted_dynamic_mutation_batch(vec![
2805 DynamicMutation::Update {
2806 entity: ENTITY_NAME.to_string(),
2807 key: InputValue::Nat64(1),
2808 patch: dynamic_payload_patch(70),
2809 },
2810 DynamicMutation::Update {
2811 entity: ENTITY_NAME.to_string(),
2812 key: InputValue::Nat64(2),
2813 patch: dynamic_payload_patch(30),
2814 },
2815 ])
2816 .expect("distinct transfer patches should share one atomic batch");
2817 assert_eq!(
2818 transfer.rows,
2819 vec![expected_dynamic_row(1, 70), expected_dynamic_row(2, 30),],
2820 "the transfer must preserve the exact total quantity",
2821 );
2822
2823 let merge = session
2824 .execute_trusted_dynamic_mutation_batch(vec![
2825 DynamicMutation::Delete {
2826 entity: ENTITY_NAME.to_string(),
2827 key: InputValue::Nat64(2),
2828 },
2829 DynamicMutation::Update {
2830 entity: ENTITY_NAME.to_string(),
2831 key: InputValue::Nat64(1),
2832 patch: dynamic_payload_patch(100),
2833 },
2834 ])
2835 .expect("two holdings should merge atomically");
2836 assert_eq!(
2837 merge.rows,
2838 vec![expected_dynamic_row(2, 30), expected_dynamic_row(1, 100),],
2839 "delete before-images and update after-images must retain input order",
2840 );
2841
2842 let resplit = session
2843 .execute_trusted_dynamic_mutation_batch(vec![
2844 DynamicMutation::Update {
2845 entity: ENTITY_NAME.to_string(),
2846 key: InputValue::Nat64(1),
2847 patch: dynamic_payload_patch(60),
2848 },
2849 DynamicMutation::Insert {
2850 entity: ENTITY_NAME.to_string(),
2851 patch: dynamic_payload_patch(40),
2852 },
2853 ])
2854 .expect("the merged holding should split again");
2855 assert_eq!(
2856 resplit.rows,
2857 vec![expected_dynamic_row(1, 60), expected_dynamic_row(3, 40),],
2858 );
2859
2860 let rejected_merge = session
2861 .execute_trusted_dynamic_mutation_batch(vec![
2862 DynamicMutation::Delete {
2863 entity: ENTITY_NAME.to_string(),
2864 key: InputValue::Nat64(3),
2865 },
2866 DynamicMutation::Update {
2867 entity: ENTITY_NAME.to_string(),
2868 key: InputValue::Nat64(99),
2869 patch: dynamic_payload_patch(100),
2870 },
2871 ])
2872 .expect_err("a late missing merge target must preserve the earlier staged delete");
2873 assert_eq!(rejected_merge.class(), ErrorClass::NotFound);
2874 assert_dynamic_payload(&session, 1, 60);
2875 assert_dynamic_payload(&session, 3, 40);
2876
2877 SCHEMA_STORE.with(|store| {
2878 let cursor = store
2879 .borrow()
2880 .identity_statement_cursor(
2881 database_incarnation_id().expect("database incarnation should remain readable"),
2882 ENTITY_TAG,
2883 FieldId::new(1),
2884 &AcceptedFieldKind::Nat64,
2885 )
2886 .expect("mixed Identity state should remain readable");
2887 assert_eq!(cursor.expected_high_water(), 3);
2888 assert!(!cursor.has_allocations());
2889 });
2890 }
2891
2892 #[test]
2893 fn mixed_structural_batch_rejects_duplicate_holding_targets_without_mutation() {
2894 let session = initialize();
2895 session
2896 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
2897 .expect("the holding fixture should initialize");
2898
2899 let duplicate = session
2900 .execute_trusted_dynamic_mutation_batch(vec![
2901 DynamicMutation::Update {
2902 entity: ENTITY_NAME.to_string(),
2903 key: InputValue::Nat64(1),
2904 patch: dynamic_payload_patch(60),
2905 },
2906 DynamicMutation::Delete {
2907 entity: ENTITY_NAME.to_string(),
2908 key: InputValue::Nat64(1),
2909 },
2910 ])
2911 .expect_err("duplicate targets across operation kinds must reject");
2912 assert!(matches!(
2913 duplicate.diagnostic().detail(),
2914 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
2915 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchDuplicateKey,
2916 }),
2917 ));
2918 assert_dynamic_payload(&session, 1, 100);
2919 }
2920
2921 #[test]
2922 fn mixed_structural_batch_rejects_empty_and_over_bound_before_resolution() {
2923 let session = initialize();
2924 let empty = session
2925 .execute_trusted_dynamic_mutation_batch(Vec::new())
2926 .expect_err("an empty public batch must reject");
2927 assert!(matches!(
2928 empty.diagnostic().detail(),
2929 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
2930 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchEmpty,
2931 }),
2932 ));
2933
2934 let requests = (0..=MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS)
2935 .map(|_| DynamicMutation::Delete {
2936 entity: ENTITY_NAME.to_string(),
2937 key: InputValue::Nat64(1),
2938 })
2939 .collect();
2940 let over_bound = session
2941 .execute_trusted_dynamic_mutation_batch(requests)
2942 .expect_err("operation cap plus one must reject before row resolution");
2943 assert!(matches!(
2944 over_bound.diagnostic().detail(),
2945 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
2946 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchTooManyItems,
2947 }),
2948 ));
2949 }
2950
2951 #[test]
2952 fn mixed_structural_batch_staged_byte_bound_uses_checked_exact_boundary() {
2953 let mut exact = 0;
2954 add_structural_mutation_staged_bytes(
2955 &mut exact,
2956 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES],
2957 )
2958 .expect("the exact staged-byte boundary should admit");
2959 assert_eq!(exact, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
2960
2961 let error = add_structural_mutation_staged_bytes(&mut exact, [1])
2962 .expect_err("one byte above the staged-byte boundary must reject");
2963 assert!(matches!(
2964 error.diagnostic().detail(),
2965 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
2966 boundary:
2967 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchStagedBytesExceeded,
2968 }),
2969 ));
2970
2971 validate_structural_mutation_result_bytes(MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES)
2972 .expect("the exact result-byte boundary should admit");
2973 let error = validate_structural_mutation_result_bytes(
2974 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1,
2975 )
2976 .expect_err("one byte above the result-byte boundary must reject");
2977 assert!(matches!(
2978 error.diagnostic().detail(),
2979 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
2980 boundary:
2981 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchResultBytesExceeded,
2982 }),
2983 ));
2984 }
2985
2986 #[expect(
2987 clippy::too_many_lines,
2988 reason = "one lifecycle proves shared materialization and every maintained frontend against the same zero-state owner"
2989 )]
2990 #[test]
2991 fn identity_insert_frontends_share_one_committed_range_without_rejected_consumption() {
2992 let session = initialize();
2993 let catalog = session
2994 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
2995 .expect("identity catalog should resolve");
2996 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
2997 .expect("identity row layout should build");
2998 let initial_description = session
2999 .try_describe_entity_by_name(ENTITY_NAME)
3000 .expect("accepted Identity description should resolve");
3001 let initial_identity = initial_description
3002 .identity()
3003 .expect("accepted Identity policy should be described");
3004 assert_eq!(initial_identity.field(), "id");
3005 assert_eq!(initial_identity.generator(), "Identity::next");
3006 assert_eq!(initial_identity.accepted_kind(), "nat64");
3007 assert_eq!(initial_identity.minimum(), 1);
3008 assert_eq!(initial_identity.maximum(), u128::from(u64::MAX));
3009 assert_eq!(initial_identity.high_water(), 0);
3010 assert_eq!(initial_identity.remaining(), u128::from(u64::MAX));
3011 assert!(!initial_identity.exhausted());
3012
3013 let rejected = session
3014 .execute_accepted_structural_save_batch(
3015 &catalog,
3016 &descriptor,
3017 batch(&[1_000, 2_000]),
3018 Timestamp::from_millis(6),
3019 |_| Err::<(), _>(InternalError::executor_unsupported()),
3020 )
3021 .expect_err("a rejected precommit result must not publish its tentative range");
3022 assert_eq!(rejected.class(), ErrorClass::Unsupported);
3023 assert_eq!(DATA_STORE.with(|store| store.borrow().len()), 0);
3024
3025 let rows = session
3026 .execute_accepted_structural_save_batch(
3027 &catalog,
3028 &descriptor,
3029 batch(&[10, 20, 30]),
3030 Timestamp::from_millis(7),
3031 Ok,
3032 )
3033 .expect("one accepted batch should commit rows and one identity range");
3034 assert_eq!(
3035 rows.into_iter().map(|row| row.values).collect::<Vec<_>>(),
3036 vec![
3037 vec![Value::Nat64(1), Value::Nat64(10)],
3038 vec![Value::Nat64(2), Value::Nat64(20)],
3039 vec![Value::Nat64(3), Value::Nat64(30)],
3040 ],
3041 );
3042
3043 let dynamic = session
3044 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
3045 entity: ENTITY_NAME.to_string(),
3046 patch: DynamicStructuralPatch::new(vec![(
3047 "payload".to_string(),
3048 DynamicWriteCell::Value(InputValue::Nat64(40)),
3049 )]),
3050 })
3051 .expect("dynamic omission should commit through shared Identity generation");
3052 assert_eq!(dynamic.affected_rows, 1);
3053
3054 for request in [
3055 DynamicMutation::Insert {
3056 entity: ENTITY_NAME.to_string(),
3057 patch: DynamicStructuralPatch::new(vec![
3058 (
3059 "id".to_string(),
3060 DynamicWriteCell::Value(InputValue::Nat64(41)),
3061 ),
3062 (
3063 "payload".to_string(),
3064 DynamicWriteCell::Value(InputValue::Nat64(42)),
3065 ),
3066 ]),
3067 },
3068 DynamicMutation::Update {
3069 entity: ENTITY_NAME.to_string(),
3070 key: InputValue::Nat64(1),
3071 patch: DynamicStructuralPatch::new(vec![(
3072 "id".to_string(),
3073 DynamicWriteCell::Default,
3074 )]),
3075 },
3076 ] {
3077 let error = session
3078 .execute_trusted_dynamic_mutation(&request)
3079 .expect_err("structural Identity authorship and regeneration must reject");
3080 assert_eq!(error.class(), ErrorClass::Unsupported);
3081 assert_eq!(error.origin(), ErrorOrigin::Executor);
3082 }
3083
3084 let binding = session
3085 .issue_typed_entity_binding(
3086 ENTITY_SOURCE,
3087 &[
3088 DynamicTypedFieldBindingRequest::new(
3089 ID_SOURCE.to_string(),
3090 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3091 false,
3092 ),
3093 DynamicTypedFieldBindingRequest::new(
3094 PAYLOAD_SOURCE.to_string(),
3095 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3096 false,
3097 ),
3098 ],
3099 )
3100 .expect("typed output should bind the Identity field");
3101 let typed_patch = binding
3102 .bind_write_fields(vec![(
3103 PAYLOAD_SOURCE.to_string(),
3104 DynamicWriteCell::Value(InputValue::Nat64(50)),
3105 )])
3106 .expect("typed payload should lower");
3107 let typed = session
3108 .execute_trusted_typed_mutation(
3109 &binding,
3110 &DynamicTypedMutation::Insert { patch: typed_patch },
3111 )
3112 .expect("typed omission should commit through shared Identity generation");
3113 assert_eq!(
3114 typed
3115 .expect("typed insert should return one mutation result")
3116 .affected_rows,
3117 1,
3118 );
3119 let explicit_typed_patch = binding
3120 .bind_write_fields(vec![
3121 (
3122 ID_SOURCE.to_string(),
3123 DynamicWriteCell::Value(InputValue::Nat64(51)),
3124 ),
3125 (
3126 PAYLOAD_SOURCE.to_string(),
3127 DynamicWriteCell::Value(InputValue::Nat64(52)),
3128 ),
3129 ])
3130 .expect("the low-level binding should retain exact authored intent");
3131 let explicit_typed_error = session
3132 .execute_trusted_typed_mutation(
3133 &binding,
3134 &DynamicTypedMutation::Insert {
3135 patch: explicit_typed_patch,
3136 },
3137 )
3138 .expect_err("typed Identity authorship must reject before allocation");
3139 assert_eq!(explicit_typed_error.class(), ErrorClass::Unsupported);
3140 assert_eq!(explicit_typed_error.origin(), ErrorOrigin::Executor);
3141
3142 let replace_error = session
3143 .execute_trusted_dynamic_mutation(&DynamicMutation::Replace {
3144 entity: ENTITY_NAME.to_string(),
3145 key: InputValue::Nat64(99),
3146 patch: DynamicStructuralPatch::new(vec![(
3147 "payload".to_string(),
3148 DynamicWriteCell::Value(InputValue::Nat64(60)),
3149 )]),
3150 })
3151 .expect_err("save-as-insert with a chosen Identity must reject");
3152 assert_eq!(replace_error.class(), ErrorClass::Unsupported);
3153 assert_eq!(replace_error.origin(), ErrorOrigin::Executor);
3154
3155 #[cfg(feature = "sql")]
3156 {
3157 for sql in [
3158 "INSERT INTO IdentityRow (payload) VALUES (70) RETURNING id, payload",
3159 "INSERT INTO IdentityRow (id, payload) VALUES (DEFAULT, 80) RETURNING id",
3160 ] {
3161 let _result = session
3162 .execute_trusted_sql_mutation(sql)
3163 .expect("SQL omission and DEFAULT should commit Identity generation");
3164 }
3165
3166 let error = session
3167 .execute_trusted_sql_mutation(
3168 "INSERT INTO IdentityRow (id, payload) VALUES (42, 90)",
3169 )
3170 .expect_err("an explicit SQL Identity value must reject before allocation");
3171 let diagnostic = error.diagnostic();
3172 assert_eq!(
3173 diagnostic.code(),
3174 icydb_diagnostic_code::DiagnosticCode::QuerySqlWriteBoundary,
3175 );
3176 assert!(matches!(
3177 diagnostic.detail(),
3178 Some(icydb_diagnostic_code::DiagnosticDetail::SqlWriteBoundary {
3179 boundary: icydb_diagnostic_code::SqlWriteBoundaryCode::ExplicitGeneratedField,
3180 }),
3181 ));
3182 }
3183
3184 let expected_committed = if cfg!(feature = "sql") { 7 } else { 5 };
3185 assert_eq!(
3186 DATA_STORE.with(|store| store.borrow().len()),
3187 expected_committed
3188 );
3189 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("committed writes must leave active state readable");
3199 assert_eq!(cursor.expected_high_water(), u128::from(expected_committed),);
3200 assert!(!cursor.has_allocations());
3201 });
3202 let committed_description = session
3203 .try_describe_entity_by_name(ENTITY_NAME)
3204 .expect("committed Identity description should resolve");
3205 let committed_identity = committed_description
3206 .identity()
3207 .expect("accepted Identity policy should remain described");
3208 assert_eq!(
3209 committed_identity.high_water(),
3210 u128::from(expected_committed),
3211 );
3212 assert_eq!(
3213 committed_identity.remaining(),
3214 u128::from(u64::MAX - expected_committed),
3215 );
3216 assert!(!committed_identity.exhausted());
3217 }
3218
3219 #[test]
3220 #[expect(
3221 clippy::too_many_lines,
3222 reason = "one ordered scenario exercises every durable interruption boundary, guarded recovery, derived rebuild, and both integrity tiers"
3223 )]
3224 fn journaled_identity_recovery_quiesces_every_publication_interruption_before_reallocation() {
3225 let session = initialize_journaled();
3226 let catalog = session
3227 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
3228 .expect("journaled identity catalog should resolve");
3229 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
3230 .expect("journaled identity row layout should build");
3231
3232 for (ordinal, interruption) in [
3233 MutationCommitInterruption::MarkerPersisted,
3234 MutationCommitInterruption::JournalPublished,
3235 MutationCommitInterruption::RowsPublished,
3236 MutationCommitInterruption::StateMaterialized,
3237 ]
3238 .into_iter()
3239 .enumerate()
3240 {
3241 interrupt_next_mutation_commit_for_tests(interruption);
3242 let interrupted = session.execute_accepted_structural_save_batch(
3243 &catalog,
3244 &descriptor,
3245 batch(&[u64::try_from(ordinal).expect("ordinal should fit")]),
3246 Timestamp::from_millis(8),
3247 Ok,
3248 );
3249 assert!(
3250 interrupted.is_err(),
3251 "the selected durable boundary should interrupt",
3252 );
3253
3254 let committed = session
3255 .execute_accepted_structural_save_batch(
3256 &catalog,
3257 &descriptor,
3258 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
3259 Timestamp::from_millis(9),
3260 Ok,
3261 )
3262 .expect("the next mutation must recover before allocating");
3263 let expected_high_water =
3264 u64::try_from((ordinal + 1) * 2).expect("small test high-water should fit");
3265 assert_eq!(
3266 committed
3267 .into_iter()
3268 .map(|row| row.values)
3269 .collect::<Vec<_>>(),
3270 vec![vec![
3271 Value::Nat64(expected_high_water),
3272 Value::Nat64(100 + u64::try_from(ordinal).expect("ordinal should fit")),
3273 ]],
3274 );
3275 assert_eq!(
3276 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
3277 expected_high_water,
3278 );
3279 JOURNALED_SCHEMA_STORE.with(|store| {
3280 let cursor = store
3281 .borrow()
3282 .identity_statement_cursor(
3283 database_incarnation_id()
3284 .expect("database incarnation should remain readable"),
3285 ENTITY_TAG,
3286 FieldId::new(1),
3287 &AcceptedFieldKind::Nat64,
3288 )
3289 .expect("guarded recovery must leave quiescent active state");
3290 assert_eq!(
3291 cursor.expected_high_water(),
3292 u128::from(expected_high_water),
3293 );
3294 assert!(!cursor.has_allocations());
3295 });
3296 }
3297
3298 for (ordinal, (interruption, deleted_key)) in [
3299 (MutationCommitInterruption::MarkerPersisted, 2),
3300 (MutationCommitInterruption::JournalPublished, 4),
3301 (MutationCommitInterruption::RowPrefixPublished, 6),
3302 (MutationCommitInterruption::RowsPublished, 8),
3303 (MutationCommitInterruption::StateMaterialized, 7),
3304 ]
3305 .into_iter()
3306 .enumerate()
3307 {
3308 let expected_payload =
3309 501 + u64::try_from(ordinal).expect("small interruption ordinal should fit");
3310 interrupt_next_mutation_commit_for_tests(interruption);
3311 let interrupted = session.execute_trusted_dynamic_mutation_batch(vec![
3312 DynamicMutation::Update {
3313 entity: ENTITY_NAME.to_string(),
3314 key: InputValue::Nat64(1),
3315 patch: dynamic_payload_patch(expected_payload),
3316 },
3317 DynamicMutation::Delete {
3318 entity: ENTITY_NAME.to_string(),
3319 key: InputValue::Nat64(deleted_key),
3320 },
3321 ]);
3322 assert!(
3323 interrupted.is_err(),
3324 "the selected caller-key mixed publication boundary should interrupt",
3325 );
3326 let recovered_update = session
3327 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
3328 entity: ENTITY_NAME.to_string(),
3329 key: InputValue::Nat64(1),
3330 patch: dynamic_payload_patch(expected_payload),
3331 })
3332 .expect("guarded reentry should complete the marker-authorized mixed batch");
3333 assert_eq!(
3334 recovered_update.affected_rows, 0,
3335 "the recovered update must already expose its admitted final image",
3336 );
3337 let recovered_delete = session
3338 .execute_trusted_dynamic_mutation(&DynamicMutation::Delete {
3339 entity: ENTITY_NAME.to_string(),
3340 key: InputValue::Nat64(deleted_key),
3341 })
3342 .expect_err("the recovered delete must already be materialized");
3343 assert_eq!(recovered_delete.class(), ErrorClass::NotFound);
3344 JOURNALED_SCHEMA_STORE.with(|store| {
3345 let cursor = store
3346 .borrow()
3347 .identity_statement_cursor(
3348 database_incarnation_id()
3349 .expect("database incarnation should remain readable"),
3350 ENTITY_TAG,
3351 FieldId::new(1),
3352 &AcceptedFieldKind::Nat64,
3353 )
3354 .expect("caller-key recovery must preserve active Identity state");
3355 assert_eq!(cursor.expected_high_water(), 8);
3356 assert!(!cursor.has_allocations());
3357 });
3358 }
3359
3360 forget_recovered_domain_for_tests(&session.db)
3361 .expect("the final journal tail should remain recoverable");
3362 session
3363 .db
3364 .ensure_recovered_state()
3365 .expect("derived rebuild must not allocate another identity");
3366
3367 let quick = execute_quick_integrity(&session.db, catalog.inspection_plan())
3368 .expect("quiescent Identity control inventory should be inspectable");
3369 assert_eq!(quick.status(), &QuickIntegrityStatus::CompleteClean);
3370 let row_page = execute_row_integrity_page(
3371 &session.db,
3372 catalog.inspection_plan(),
3373 PhysicalUnitCheckpoint::BeforeFirst,
3374 RowInspectionLimits::standard(),
3375 )
3376 .expect("Identity rows should remain within committed high-water");
3377 assert!(row_page.exhausted());
3378 assert!(row_page.findings().is_empty());
3379
3380 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 3);
3381 assert!(
3382 JOURNALED_INDEX_STORE.with(|store| !store.borrow().is_empty()),
3383 "derived index rebuild should restore witnesses without allocating identities",
3384 );
3385 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
3386 JOURNALED_SCHEMA_STORE.with(|store| {
3387 let cursor = store
3388 .borrow()
3389 .identity_statement_cursor(
3390 database_incarnation_id().expect("database incarnation should remain readable"),
3391 ENTITY_TAG,
3392 FieldId::new(1),
3393 &AcceptedFieldKind::Nat64,
3394 )
3395 .expect("folded identity state should reopen without allocating");
3396 assert_eq!(cursor.expected_high_water(), 8);
3397 assert!(!cursor.has_allocations());
3398 });
3399 }
3400
3401 #[test]
3402 #[ignore = "release-closeout native timing probe for one marker-authorized Identity recovery"]
3403 fn identity_recovery_closeout_reports_guarded_reentry_time() {
3404 let session = initialize_journaled();
3405 let catalog = session
3406 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
3407 .expect("journaled identity catalog should resolve");
3408 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
3409 .expect("journaled identity row layout should build");
3410
3411 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::RowsPublished);
3412 let interrupted = session.execute_accepted_structural_save_batch(
3413 &catalog,
3414 &descriptor,
3415 batch(&[1]),
3416 Timestamp::from_millis(10),
3417 Ok,
3418 );
3419 assert!(
3420 interrupted.is_err(),
3421 "the selected publication boundary should interrupt",
3422 );
3423
3424 let start = Instant::now();
3425 let committed = session
3426 .execute_accepted_structural_save_batch(
3427 &catalog,
3428 &descriptor,
3429 batch(&[2]),
3430 Timestamp::from_millis(11),
3431 Ok,
3432 )
3433 .expect("guarded reentry should recover before allocation");
3434 let elapsed = start.elapsed();
3435 assert_eq!(
3436 committed
3437 .into_iter()
3438 .map(|row| row.values)
3439 .collect::<Vec<_>>(),
3440 vec![vec![Value::Nat64(2), Value::Nat64(2)]],
3441 );
3442
3443 println!(
3444 "identity recovery closeout: guarded_reentry_nanos={}",
3445 elapsed.as_nanos(),
3446 );
3447 }
3448}
3449
3450#[cfg(test)]
3451mod targeted_rule_mutation_tests {
3452 use super::{
3453 DbSession, DynamicMutation, DynamicStructuralPatch, DynamicTypedFieldBindingRequest,
3454 DynamicTypedFieldType, DynamicTypedMutation, DynamicWriteCell,
3455 };
3456 use crate::{
3457 db::{
3458 data::{DataStore, encode_input_value_for_candidate_field_contract},
3459 index::IndexStore,
3460 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
3461 schema::{
3462 AcceptedCheckLiteralV1, AcceptedCompositeCatalog, AcceptedFieldDecodeContract,
3463 AcceptedFieldKind, AcceptedNamedTypeIdentity, AcceptedRuleOperation,
3464 AcceptedRuleTarget, AcceptedSchemaRevision, AcceptedSourceBindingCatalog,
3465 ConstraintOrigin, FieldId, FieldStorageDecode, FieldWriteManagement, LeafCodec,
3466 PersistedFieldSnapshot, PersistedNestedLeafSnapshot, PersistedSchemaSnapshot,
3467 ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy, SchemaInsertDefault,
3468 SchemaRowLayout, SchemaStore, SchemaVersion,
3469 accepted_schema_candidate_with_catalogs_for_tests,
3470 build_record_newtype_composite_catalog_for_tests,
3471 empty_accepted_enum_catalog_for_tests, enum_catalog::ValueAdmissionBudget,
3472 },
3473 },
3474 error::{
3475 ConstraintDiagnostic, ConstraintDiagnosticKind, ConstraintValuePathComponent,
3476 InternalError,
3477 },
3478 traits::{CanisterKind, Path},
3479 types::EntityTag,
3480 value::InputValue,
3481 };
3482 use icydb_schema::{
3483 ConstraintSourceKey, EntitySourceKey, FieldSourceKey, ScalarType, TypeSourceKey,
3484 };
3485 use std::{cell::RefCell, collections::BTreeMap};
3486
3487 const STORE_PATH: &str = "session::write::targeted_rule_mutation_tests::Store";
3488 const ENTITY_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity";
3489 const ID_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::id";
3490 const PROFILE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::profile";
3491 const UPDATED_AT_SOURCE: &str =
3492 "session::write::targeted_rule_mutation_tests::Entity::updated_at";
3493 const PROFILE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Profile";
3494 const DEGREE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Degree";
3495 const DEGREE_MEMBER_SOURCE: &str =
3496 "session::write::targeted_rule_mutation_tests::Profile::degree";
3497 const DEGREE_RULE_SOURCE: &str =
3498 "session::write::targeted_rule_mutation_tests::Profile::degree_multiple";
3499
3500 struct TestCanister;
3501
3502 impl Path for TestCanister {
3503 const PATH: &'static str = "session::write::targeted_rule_mutation_tests::Canister";
3504 }
3505
3506 impl CanisterKind for TestCanister {
3507 const COMMIT_MEMORY_ID: u8 = 43;
3508 const COMMIT_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.commit.v1";
3509 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 44;
3510 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3511 "icydb.targeted_mutation_tests.integrity.progress.v1";
3512 }
3513
3514 thread_local! {
3515 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
3516 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
3517 static SCHEMA_STORE: RefCell<SchemaStore> =
3518 const { RefCell::new(SchemaStore::init_heap()) };
3519 static STORE_REGISTRY: StoreRegistry = {
3520 let mut registry = StoreRegistry::new();
3521 registry.register_store(
3522 STORE_PATH,
3523 &DATA_STORE,
3524 &INDEX_STORE,
3525 &SCHEMA_STORE,
3526 StoreAllocationIdentities::absent(),
3527 StoreRuntimeStorageCapabilities::heap(),
3528 ).expect("targeted mutation test store should register");
3529 registry
3530 };
3531 }
3532
3533 fn source<T, E: std::fmt::Debug>(raw: &str, parse: impl FnOnce(String) -> Result<T, E>) -> T {
3534 parse(raw.to_string()).expect("test source identity should admit")
3535 }
3536
3537 fn profile_input(degree: u64) -> InputValue {
3538 InputValue::Map(vec![(
3539 InputValue::Text("degree".to_string()),
3540 InputValue::Nat64(degree),
3541 )])
3542 }
3543
3544 fn structural_patch(id: u64, degree: u64) -> DynamicStructuralPatch {
3545 DynamicStructuralPatch::new(vec![
3546 (
3547 "id".to_string(),
3548 DynamicWriteCell::Value(InputValue::Nat64(id)),
3549 ),
3550 (
3551 "profile".to_string(),
3552 DynamicWriteCell::Value(profile_input(degree)),
3553 ),
3554 ])
3555 }
3556
3557 fn encoded_value(
3558 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
3559 composite_catalog: &AcceptedCompositeCatalog,
3560 name: &str,
3561 kind: &AcceptedFieldKind,
3562 storage_decode: FieldStorageDecode,
3563 leaf_codec: LeafCodec,
3564 value: InputValue,
3565 ) -> Vec<u8> {
3566 let field = AcceptedFieldDecodeContract::new(name, kind, false, storage_decode, leaf_codec);
3567 encode_input_value_for_candidate_field_contract(
3568 enum_catalog,
3569 composite_catalog,
3570 field,
3571 value,
3572 &mut ValueAdmissionBudget::standard(),
3573 )
3574 .expect("test accepted value should encode")
3575 }
3576
3577 fn nat64_literal(
3578 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
3579 composite_catalog: &AcceptedCompositeCatalog,
3580 value: u64,
3581 ) -> AcceptedCheckLiteralV1 {
3582 let kind = AcceptedFieldKind::Nat64;
3583 AcceptedCheckLiteralV1::from_accepted_parts(
3584 kind.clone(),
3585 FieldStorageDecode::ByKind,
3586 LeafCodec::Scalar(ScalarCodec::Nat64),
3587 encoded_value(
3588 enum_catalog,
3589 composite_catalog,
3590 "degree_bound",
3591 &kind,
3592 FieldStorageDecode::ByKind,
3593 LeafCodec::Scalar(ScalarCodec::Nat64),
3594 InputValue::Nat64(value),
3595 ),
3596 )
3597 }
3598
3599 fn targeted_diagnostic(error: &InternalError) -> &ConstraintDiagnostic {
3600 let diagnostic = error
3601 .constraint_diagnostic()
3602 .expect("targeted mutation should retain a public diagnostic");
3603 assert_eq!(
3604 diagnostic.constraint_kind(),
3605 ConstraintDiagnosticKind::TargetedRule
3606 );
3607 assert_eq!(diagnostic.field_paths(), &["profile".to_string()]);
3608 assert_eq!(
3609 diagnostic
3610 .value_path()
3611 .expect("targeted mutation should retain its typed value path")
3612 .components(),
3613 &[
3614 ConstraintValuePathComponent::RootField { field_id: 2 },
3615 ConstraintValuePathComponent::RecordMember {
3616 composite_type_id: 1,
3617 member_id: 1,
3618 },
3619 ],
3620 );
3621 diagnostic
3622 }
3623
3624 #[expect(
3625 clippy::too_many_lines,
3626 reason = "one end-to-end fixture proves every maintained write frontend converges on the same accepted targeted-rule schedule"
3627 )]
3628 #[test]
3629 fn targeted_rules_converge_across_dynamic_typed_sql_default_timestamp_and_batch_writes() {
3630 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3631 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3632 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3633
3634 let entity_tag = EntityTag::new(93);
3635 let enum_catalog = empty_accepted_enum_catalog_for_tests();
3636 let (composite_catalog, profile_type, degree_type, degree_member) =
3637 build_record_newtype_composite_catalog_for_tests(
3638 "tests::TargetedProfile".to_string(),
3639 "degree".to_string(),
3640 "tests::TargetedDegree".to_string(),
3641 AcceptedFieldKind::Nat64,
3642 &enum_catalog,
3643 )
3644 .expect("targeted mutation composites should close");
3645 let profile_kind = AcceptedFieldKind::Composite {
3646 type_id: profile_type,
3647 };
3648 let profile_default = encoded_value(
3649 &enum_catalog,
3650 &composite_catalog,
3651 "profile",
3652 &profile_kind,
3653 FieldStorageDecode::CatalogValue,
3654 LeafCodec::Structural,
3655 profile_input(12),
3656 );
3657 let fields = vec![
3658 PersistedFieldSnapshot::new_initial(
3659 FieldId::new(1),
3660 "id".to_string(),
3661 SchemaFieldSlot::new(0),
3662 AcceptedFieldKind::Nat64,
3663 Vec::new(),
3664 false,
3665 SchemaInsertDefault::None,
3666 FieldStorageDecode::ByKind,
3667 LeafCodec::Scalar(ScalarCodec::Nat64),
3668 ),
3669 PersistedFieldSnapshot::new_initial(
3670 FieldId::new(2),
3671 "profile".to_string(),
3672 SchemaFieldSlot::new(1),
3673 profile_kind,
3674 vec![PersistedNestedLeafSnapshot::new(
3675 vec!["degree".to_string()],
3676 AcceptedFieldKind::Composite {
3677 type_id: degree_type,
3678 },
3679 false,
3680 )],
3681 false,
3682 SchemaInsertDefault::SlotPayload(profile_default),
3683 FieldStorageDecode::CatalogValue,
3684 LeafCodec::Structural,
3685 ),
3686 PersistedFieldSnapshot::new_initial_with_write_policy(
3687 FieldId::new(3),
3688 "updated_at".to_string(),
3689 SchemaFieldSlot::new(2),
3690 AcceptedFieldKind::Timestamp,
3691 Vec::new(),
3692 false,
3693 SchemaInsertDefault::None,
3694 SchemaFieldWritePolicy::from_model_policies(
3695 None,
3696 Some(FieldWriteManagement::UpdatedAt),
3697 ),
3698 FieldStorageDecode::ByKind,
3699 LeafCodec::Scalar(ScalarCodec::Timestamp),
3700 ),
3701 ];
3702 let mut snapshot = PersistedSchemaSnapshot::new(
3703 SchemaVersion::initial(),
3704 ENTITY_SOURCE.to_string(),
3705 "TargetedMutation".to_string(),
3706 FieldId::new(1),
3707 SchemaRowLayout::initial(
3708 fields
3709 .iter()
3710 .map(|field| (field.id(), field.slot()))
3711 .collect(),
3712 ),
3713 fields,
3714 );
3715 let constraint_catalog = snapshot
3716 .constraint_catalog()
3717 .clone()
3718 .with_added_targeted_rule(
3719 "profile_degree_multiple".to_string(),
3720 ConstraintOrigin::Generated,
3721 AcceptedRuleTarget::new(
3722 FieldId::new(2),
3723 AcceptedNamedTypeIdentity::Composite(degree_type),
3724 ),
3725 AcceptedRuleOperation::MultipleOf {
3726 divisor: nat64_literal(&enum_catalog, &composite_catalog, 5),
3727 },
3728 )
3729 .expect("targeted mutation rule should allocate");
3730 let targeted_rule_id = constraint_catalog
3731 .constraints()
3732 .last()
3733 .expect("targeted mutation rule should persist")
3734 .id();
3735 snapshot = snapshot.with_constraint_catalog(constraint_catalog);
3736
3737 let entity_source = source(ENTITY_SOURCE, EntitySourceKey::try_new);
3738 let id_source = source(ID_SOURCE, FieldSourceKey::try_new);
3739 let profile_source = source(PROFILE_SOURCE, FieldSourceKey::try_new);
3740 let updated_at_source = source(UPDATED_AT_SOURCE, FieldSourceKey::try_new);
3741 let profile_type_source = source(PROFILE_TYPE_SOURCE, TypeSourceKey::try_new);
3742 let degree_type_source = source(DEGREE_TYPE_SOURCE, TypeSourceKey::try_new);
3743 let degree_member_source = source(DEGREE_MEMBER_SOURCE, FieldSourceKey::try_new);
3744 let degree_rule_source = source(DEGREE_RULE_SOURCE, ConstraintSourceKey::try_new);
3745 let source_bindings = AcceptedSourceBindingCatalog::initial_for_tests(
3746 BTreeMap::from([(entity_source, entity_tag)]),
3747 BTreeMap::from([
3748 ((entity_tag, id_source), FieldId::new(1)),
3749 ((entity_tag, profile_source), FieldId::new(2)),
3750 ((entity_tag, updated_at_source), FieldId::new(3)),
3751 ]),
3752 BTreeMap::from([((entity_tag, degree_rule_source), targeted_rule_id)]),
3753 BTreeMap::new(),
3754 BTreeMap::new(),
3755 )
3756 .with_initial_named_types_for_tests(
3757 BTreeMap::from([
3758 (
3759 profile_type_source,
3760 AcceptedNamedTypeIdentity::Composite(profile_type),
3761 ),
3762 (
3763 degree_type_source,
3764 AcceptedNamedTypeIdentity::Composite(degree_type),
3765 ),
3766 ]),
3767 BTreeMap::new(),
3768 BTreeMap::from([((profile_type, degree_member_source), degree_member)]),
3769 );
3770 let candidate = accepted_schema_candidate_with_catalogs_for_tests(
3771 STORE_PATH,
3772 AcceptedSchemaRevision::INITIAL,
3773 enum_catalog,
3774 composite_catalog,
3775 source_bindings,
3776 BTreeMap::from([(entity_tag, snapshot)]),
3777 );
3778
3779 let session = DbSession::<TestCanister>::new(&STORE_REGISTRY);
3780 session
3781 .db
3782 .ensure_recovered_state()
3783 .expect("targeted mutation test database should initialize");
3784 let store = session
3785 .db
3786 .store_handle(STORE_PATH)
3787 .expect("targeted mutation test store should resolve");
3788 crate::db::commit::publish_accepted_schema_candidate(
3789 STORE_PATH,
3790 store,
3791 AcceptedSchemaRevision::NONE,
3792 &candidate,
3793 )
3794 .expect("targeted mutation candidate should publish");
3795
3796 let dynamic_error = session
3797 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
3798 entity: "TargetedMutation".to_string(),
3799 patch: structural_patch(1, 12),
3800 })
3801 .expect_err("dynamic write must enforce the targeted rule");
3802 let dynamic_diagnostic = targeted_diagnostic(&dynamic_error);
3803 assert_eq!(dynamic_diagnostic.constraint_id(), targeted_rule_id.get());
3804
3805 let binding = session
3806 .issue_typed_entity_binding(
3807 ENTITY_SOURCE,
3808 &[
3809 DynamicTypedFieldBindingRequest::new(
3810 ID_SOURCE.to_string(),
3811 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3812 false,
3813 ),
3814 DynamicTypedFieldBindingRequest::new(
3815 PROFILE_SOURCE.to_string(),
3816 DynamicTypedFieldType::Named(PROFILE_TYPE_SOURCE.to_string()),
3817 false,
3818 ),
3819 DynamicTypedFieldBindingRequest::new(
3820 UPDATED_AT_SOURCE.to_string(),
3821 DynamicTypedFieldType::Scalar(ScalarType::Timestamp),
3822 false,
3823 ),
3824 ],
3825 )
3826 .expect("targeted typed binding should issue");
3827 let typed_patch = binding
3828 .bind_write_fields(vec![
3829 (
3830 ID_SOURCE.to_string(),
3831 DynamicWriteCell::Value(InputValue::Nat64(2)),
3832 ),
3833 (
3834 PROFILE_SOURCE.to_string(),
3835 DynamicWriteCell::Value(profile_input(12)),
3836 ),
3837 ])
3838 .expect("targeted typed patch should bind");
3839 let typed_error = session
3840 .execute_trusted_typed_mutation(
3841 &binding,
3842 &DynamicTypedMutation::Insert { patch: typed_patch },
3843 )
3844 .expect_err("typed write must enforce the targeted rule");
3845 assert_eq!(
3846 targeted_diagnostic(&typed_error).constraint_id(),
3847 targeted_rule_id.get()
3848 );
3849
3850 #[cfg(feature = "sql")]
3851 {
3852 let sql_error = session
3853 .execute_trusted_sql_mutation("INSERT INTO TargetedMutation (id) VALUES (3)")
3854 .expect_err("SQL default resolution must enforce the targeted rule");
3855 let crate::db::QueryError::Execute(execute) = sql_error else {
3856 panic!("targeted SQL write should fail at shared execution admission");
3857 };
3858 assert_eq!(
3859 targeted_diagnostic(execute.as_internal()).constraint_id(),
3860 targeted_rule_id.get()
3861 );
3862 }
3863
3864 session
3865 .execute_trusted_dynamic_mutation_batch(vec![
3866 DynamicMutation::Insert {
3867 entity: "TargetedMutation".to_string(),
3868 patch: structural_patch(4, 5),
3869 },
3870 DynamicMutation::Insert {
3871 entity: "TargetedMutation".to_string(),
3872 patch: structural_patch(5, 12),
3873 },
3874 ])
3875 .expect_err("one invalid targeted value must reject the whole batch");
3876 assert_eq!(
3877 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
3878 Some(0),
3879 "no frontend or earlier valid batch row may escape targeted admission",
3880 );
3881
3882 let admitted = session
3883 .execute_trusted_dynamic_mutation_batch(vec![
3884 DynamicMutation::Insert {
3885 entity: "TargetedMutation".to_string(),
3886 patch: structural_patch(6, 5),
3887 },
3888 DynamicMutation::Insert {
3889 entity: "TargetedMutation".to_string(),
3890 patch: structural_patch(7, 10),
3891 },
3892 ])
3893 .expect("compliant targeted values should share one accepted batch");
3894 let [first, second] = admitted.rows.as_slice() else {
3895 panic!("the mixed targeted batch should return two rows");
3896 };
3897 let first_timestamp = first
3898 .get(2)
3899 .expect("the first mixed row should contain its managed timestamp");
3900 assert!(matches!(
3901 first_timestamp,
3902 crate::value::OutputValue::Timestamp(_)
3903 ));
3904 assert_eq!(
3905 second.get(2),
3906 Some(first_timestamp),
3907 "one accepted mixed batch must materialize one managed timestamp",
3908 );
3909 assert_eq!(
3910 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
3911 Some(2),
3912 );
3913 }
3914}