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