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,
24 commit_structural_row_ops_with_mutation_progress_for_path,
25 commit_structural_row_ops_with_window_for_path, mutation_key_exists_error,
26 },
27 integrity::MutationProgressRecordOp,
28 schema::{
29 AcceptedFieldKind, AcceptedIdentityAllocation, AcceptedRowLayoutRuntimeContract,
30 FieldId, FieldInsertGeneration, IdentityStatementCursor, lower_field_type,
31 output_value_from_runtime,
32 },
33 write_context::{AcceptedWriteContext, MutationMode},
34 },
35 error::{InternalError, MutationDiagnosticContext},
36 metrics::sink::{MetricsEvent, SaveMutationKind, record},
37 traits::CanisterKind,
38 types::{CurrentTimestamp, Timestamp},
39 value::{InputValue, Value},
40};
41use icydb_schema::{EntitySourceKey, FieldSourceKey, FieldType, TypeSourceKey};
42
43#[derive(Clone, Debug, Eq, PartialEq)]
44struct AcceptedIdentityInsertField {
45 field_id: FieldId,
46 field_slot: usize,
47 accepted_kind: AcceptedFieldKind,
48}
49
50struct AcceptedStructuralMutationCommitOptions {
51 capture_output_values: bool,
52 packing: AcceptedStructuralMutationPacking,
53}
54
55impl AcceptedStructuralMutationCommitOptions {
56 const fn standard() -> Self {
57 Self {
58 capture_output_values: true,
59 packing: AcceptedStructuralMutationPacking::Complete,
60 }
61 }
62
63 #[cfg(test)]
64 const fn with_mutation_progress() -> Self {
65 Self {
66 capture_output_values: false,
67 packing: AcceptedStructuralMutationPacking::Complete,
68 }
69 }
70
71 const fn bounded_prefix() -> Self {
72 Self {
73 capture_output_values: false,
74 packing: AcceptedStructuralMutationPacking::BoundedPrefix,
75 }
76 }
77}
78
79#[derive(Clone, Copy)]
80enum AcceptedStructuralMutationPacking {
81 Complete,
82 BoundedPrefix,
83}
84
85pub(in crate::db::session) enum AcceptedStructuralMutationCommitDirective {
86 Standard,
87 WithMutationProgress(MutationProgressRecordOp),
88 Skip,
89}
90
91pub(in crate::db::session) enum AcceptedStructuralMutationTarget {
94 ResolveFromAfterImage,
95 Expected(Box<DecodedDataStoreKey>),
96 ExpectedLoaded(AcceptedLoadedStructuralRow),
97}
98
99pub(in crate::db::session) struct AcceptedLoadedStructuralRow {
102 key: Box<DecodedDataStoreKey>,
103 row: RawRow,
104}
105
106impl AcceptedLoadedStructuralRow {
107 pub(in crate::db::session) fn from_validated_parts(
108 key: DecodedDataStoreKey,
109 row: RawRow,
110 ) -> Self {
111 Self {
112 key: Box::new(key),
113 row,
114 }
115 }
116
117 fn into_parts(self) -> (DecodedDataStoreKey, RawRow) {
118 (*self.key, self.row)
119 }
120}
121
122impl AcceptedStructuralMutationTarget {
123 pub(in crate::db::session) fn expected(key: DecodedDataStoreKey) -> Self {
124 Self::Expected(Box::new(key))
125 }
126
127 pub(in crate::db::session) const fn expected_loaded(row: AcceptedLoadedStructuralRow) -> Self {
130 Self::ExpectedLoaded(row)
131 }
132}
133
134pub(in crate::db::session) enum AcceptedStructuralMutation {
137 Save {
138 mode: MutationMode,
139 target: AcceptedStructuralMutationTarget,
140 patch: AcceptedMutationIntentPatch,
141 },
142 Delete {
143 key: Box<DecodedDataStoreKey>,
144 },
145}
146
147impl AcceptedStructuralMutation {
148 pub(in crate::db::session) const fn save(
149 mode: MutationMode,
150 target: AcceptedStructuralMutationTarget,
151 patch: AcceptedMutationIntentPatch,
152 ) -> Self {
153 Self::Save {
154 mode,
155 target,
156 patch,
157 }
158 }
159
160 pub(in crate::db::session) fn delete(key: DecodedDataStoreKey) -> Self {
161 Self::Delete { key: Box::new(key) }
162 }
163}
164
165const MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS: usize = 4_096;
166pub(in crate::db::session) const STRUCTURAL_MUTATION_BATCH_STAGED_BYTES_POLICY: u32 =
167 16 * 1024 * 1024;
168pub(in crate::db::session) const MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES: usize =
169 STRUCTURAL_MUTATION_BATCH_STAGED_BYTES_POLICY as usize;
170const MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES: usize = 1024 * 1024;
171
172#[derive(Clone, Copy, Debug, Eq, PartialEq)]
173pub(in crate::db::session) struct AcceptedStructuralMutationPackingReport {
174 admitted_mutations: usize,
175 stopped_before_candidate: bool,
176 candidate_exceeds_batch_policy: bool,
177}
178
179impl AcceptedStructuralMutationPackingReport {
180 #[must_use]
181 pub(in crate::db::session) const fn admitted_mutations(self) -> usize {
182 self.admitted_mutations
183 }
184
185 #[must_use]
186 pub(in crate::db::session) const fn stopped_before_candidate(self) -> bool {
187 self.stopped_before_candidate
188 }
189
190 #[must_use]
191 pub(in crate::db::session) const fn candidate_exceeds_batch_policy(self) -> bool {
192 self.candidate_exceeds_batch_policy
193 }
194}
195
196fn structural_mutation_staged_charge(
197 lengths: impl IntoIterator<Item = usize>,
198) -> Result<usize, InternalError> {
199 lengths.into_iter().try_fold(0_usize, |total, length| {
200 total.checked_add(length).ok_or_else(|| {
201 InternalError::mutation_batch_staged_bytes_exceeded(
202 None,
203 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
204 )
205 })
206 })
207}
208
209fn add_structural_mutation_staged_bytes(
210 total: &mut usize,
211 lengths: impl IntoIterator<Item = usize>,
212) -> Result<(), InternalError> {
213 let charge = structural_mutation_staged_charge(lengths)?;
214 *total = total.checked_add(charge).ok_or_else(|| {
215 InternalError::mutation_batch_staged_bytes_exceeded(
216 None,
217 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
218 )
219 })?;
220 if *total > MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES {
221 return Err(InternalError::mutation_batch_staged_bytes_exceeded(
222 Some(*total),
223 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
224 ));
225 }
226 Ok(())
227}
228
229fn admit_structural_mutation_staged_charge(
230 total: &mut usize,
231 lengths: impl IntoIterator<Item = usize>,
232 packing: AcceptedStructuralMutationPacking,
233) -> Result<AcceptedStructuralMutationStagedAdmission, InternalError> {
234 if matches!(packing, AcceptedStructuralMutationPacking::Complete) {
235 add_structural_mutation_staged_bytes(total, lengths)?;
236 return Ok(AcceptedStructuralMutationStagedAdmission::Admitted);
237 }
238
239 let charge = structural_mutation_staged_charge(lengths)?;
240 if charge > MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES {
241 return Ok(AcceptedStructuralMutationStagedAdmission::CandidateExceedsPolicy);
242 }
243 let Some(next_total) = total.checked_add(charge) else {
244 return Ok(AcceptedStructuralMutationStagedAdmission::PageFull);
245 };
246 if next_total > MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES {
247 return Ok(AcceptedStructuralMutationStagedAdmission::PageFull);
248 }
249 *total = next_total;
250 Ok(AcceptedStructuralMutationStagedAdmission::Admitted)
251}
252
253#[derive(Clone, Copy, Debug, Eq, PartialEq)]
254enum AcceptedStructuralMutationStagedAdmission {
255 Admitted,
256 PageFull,
257 CandidateExceedsPolicy,
258}
259
260fn validate_structural_mutation_result_bytes(encoded_bytes: usize) -> Result<(), InternalError> {
261 if encoded_bytes > MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES {
262 return Err(InternalError::mutation_batch_result_bytes_exceeded(
263 encoded_bytes,
264 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES,
265 ));
266 }
267 Ok(())
268}
269
270pub(in crate::db::session) struct AcceptedStructuralMutationRow {
272 values: Vec<Value>,
273 logical_changed: bool,
274}
275
276impl AcceptedStructuralMutationRow {
277 #[cfg(any(feature = "sql", test))]
278 pub(in crate::db::session) fn into_values(self) -> Vec<Value> {
279 self.values
280 }
281
282 pub(in crate::db::session) const fn logical_changed(&self) -> bool {
283 self.logical_changed
284 }
285}
286
287const fn dynamic_mutation_mode(request: &DynamicMutation) -> Option<MutationMode> {
288 match request {
289 DynamicMutation::Insert { .. } => Some(MutationMode::Insert),
290 DynamicMutation::Update { .. } => Some(MutationMode::Update),
291 DynamicMutation::Replace { .. } => Some(MutationMode::Replace),
292 DynamicMutation::Delete { .. } => None,
293 }
294}
295
296const fn dynamic_typed_mutation_mode(request: &DynamicTypedMutation) -> MutationMode {
297 match request {
298 DynamicTypedMutation::Insert { .. } => MutationMode::Insert,
299 DynamicTypedMutation::Update { .. } => MutationMode::Update,
300 DynamicTypedMutation::Replace { .. } => MutationMode::Replace,
301 }
302}
303
304const fn diagnostic_mutation_operation(
305 mode: MutationMode,
306) -> icydb_diagnostic_code::DiagnosticMutationOperation {
307 match mode {
308 MutationMode::Insert => icydb_diagnostic_code::DiagnosticMutationOperation::Insert,
309 MutationMode::Replace => icydb_diagnostic_code::DiagnosticMutationOperation::Replace,
310 MutationMode::Update => icydb_diagnostic_code::DiagnosticMutationOperation::Update,
311 }
312}
313
314const fn mutation_diagnostic_context(
315 entity_tag: crate::types::EntityTag,
316 mode: MutationMode,
317 batch_position: u32,
318) -> MutationDiagnosticContext {
319 MutationDiagnosticContext::new(
320 entity_tag.value(),
321 diagnostic_mutation_operation(mode),
322 batch_position,
323 )
324}
325
326const fn dynamic_write_context(operation_timestamp: Timestamp) -> AcceptedWriteContext {
327 AcceptedWriteContext::new(operation_timestamp)
328}
329
330fn insert_key_exists_after_generation(identity_generated: bool) -> InternalError {
331 if identity_generated {
332 InternalError::identity_state_corruption()
333 } else {
334 mutation_key_exists_error()
335 }
336}
337
338fn dynamic_key(
339 entity_tag: crate::types::EntityTag,
340 key: &InputValue,
341) -> Result<DecodedDataStoreKey, InternalError> {
342 let value = key
343 .clone()
344 .try_into_runtime_non_enum()
345 .ok_or_else(InternalError::executor_unsupported)?;
346 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
347}
348
349fn lower_dynamic_patch(
350 entity_path: &str,
351 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
352 patch: &DynamicStructuralPatch,
353 mode: MutationMode,
354 mutation_context: MutationDiagnosticContext,
355) -> Result<AcceptedMutationIntentPatch, InternalError> {
356 let mut lowered = AcceptedMutationIntentPatch::new();
357 for (field_name, cell) in patch.fields() {
358 let slot = descriptor
359 .field_slot_index_by_name(field_name)
360 .ok_or_else(|| {
361 InternalError::mutation_structural_field_unknown(entity_path, field_name)
362 })?;
363 let field = descriptor
364 .field_for_slot_index(slot)
365 .ok_or_else(InternalError::executor_invariant)?;
366 if !matches!(cell, DynamicWriteCell::Omitted)
367 && (field.write_policy().insert_generation().is_some()
368 || field.write_policy().write_management().is_some())
369 {
370 return Err(InternalError::mutation_database_owned_field_explicit(
371 mutation_context,
372 field.field_id().get(),
373 ));
374 }
375 let slot = FieldSlot::from_validated_index(slot);
376 lowered = match cell {
377 DynamicWriteCell::Omitted => lowered,
378 DynamicWriteCell::Default => match mode {
379 MutationMode::Insert | MutationMode::Replace => {
380 lowered.set_explicit_insert_default(slot)
381 }
382 MutationMode::Update => lowered.set_explicit_update_default(slot),
383 },
384 DynamicWriteCell::Null => lowered.set_authored(slot, InputValue::Null),
385 DynamicWriteCell::Value(value) => lowered.set_authored(slot, value.clone()),
386 };
387 }
388 Ok(lowered)
389}
390
391fn lower_dynamic_mutation_intent(
392 entity_tag: crate::types::EntityTag,
393 entity_path: &str,
394 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
395 request: &DynamicMutation,
396 batch_position: u32,
397) -> Result<(AcceptedStructuralMutation, Option<SaveMutationKind>), InternalError> {
398 match request {
399 DynamicMutation::Insert { patch, .. } => {
400 let mode = MutationMode::Insert;
401 Ok((
402 AcceptedStructuralMutation::save(
403 mode,
404 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
405 lower_dynamic_patch(
406 entity_path,
407 descriptor,
408 patch,
409 mode,
410 mutation_diagnostic_context(entity_tag, mode, batch_position),
411 )?,
412 ),
413 Some(SaveMutationKind::Insert),
414 ))
415 }
416 DynamicMutation::Update { key, patch, .. }
417 | DynamicMutation::Replace { key, patch, .. } => {
418 let mode =
419 dynamic_mutation_mode(request).ok_or_else(InternalError::executor_invariant)?;
420 let kind = match mode {
421 MutationMode::Insert => SaveMutationKind::Insert,
422 MutationMode::Replace => SaveMutationKind::Replace,
423 MutationMode::Update => SaveMutationKind::Update,
424 };
425 Ok((
426 AcceptedStructuralMutation::save(
427 mode,
428 AcceptedStructuralMutationTarget::expected(dynamic_key(entity_tag, key)?),
429 lower_dynamic_patch(
430 entity_path,
431 descriptor,
432 patch,
433 mode,
434 mutation_diagnostic_context(entity_tag, mode, batch_position),
435 )?,
436 ),
437 Some(kind),
438 ))
439 }
440 DynamicMutation::Delete { key, .. } => Ok((
441 AcceptedStructuralMutation::delete(dynamic_key(entity_tag, key)?),
442 None,
443 )),
444 }
445}
446
447fn lower_typed_patch(
448 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
449 patch: &DynamicTypedStructuralPatch,
450 mode: MutationMode,
451 mutation_context: MutationDiagnosticContext,
452) -> Result<AcceptedMutationIntentPatch, InternalError> {
453 let mut lowered = AcceptedMutationIntentPatch::new();
454 for (field_id, slot, cell) in patch.fields() {
455 let slot_index = usize::from(*slot);
456 let field = descriptor
457 .field_for_slot_index(slot_index)
458 .ok_or_else(InternalError::store_invariant)?;
459 if field.field_id().get() != *field_id {
460 return Err(InternalError::store_invariant());
461 }
462 if !matches!(cell, DynamicWriteCell::Omitted)
463 && (field.write_policy().insert_generation().is_some()
464 || field.write_policy().write_management().is_some())
465 {
466 return Err(InternalError::mutation_database_owned_field_explicit(
467 mutation_context,
468 field.field_id().get(),
469 ));
470 }
471 let slot = FieldSlot::from_validated_index(slot_index);
472 lowered = match cell {
473 DynamicWriteCell::Omitted => lowered,
474 DynamicWriteCell::Default => match mode {
475 MutationMode::Insert | MutationMode::Replace => {
476 lowered.set_explicit_insert_default(slot)
477 }
478 MutationMode::Update => lowered.set_explicit_update_default(slot),
479 },
480 DynamicWriteCell::Null => lowered.set_authored(slot, InputValue::Null),
481 DynamicWriteCell::Value(value) => lowered.set_authored(slot, value.clone()),
482 };
483 }
484 Ok(lowered)
485}
486
487fn preserve_dynamic_replacement_identity(
488 key: &DecodedDataStoreKey,
489 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
490 mut patch: AcceptedMutationIntentPatch,
491) -> Result<AcceptedMutationIntentPatch, InternalError> {
492 let primary_key_slots = descriptor.primary_key_slot_indices();
493 let runtime_key = key.primary_key_runtime_value();
494 let components = match runtime_key {
495 Value::List(values) if primary_key_slots.len() > 1 => values,
496 value if primary_key_slots.len() == 1 => vec![value],
497 _ => return Err(InternalError::executor_invariant()),
498 };
499 if components.len() != primary_key_slots.len() {
500 return Err(InternalError::executor_invariant());
501 }
502
503 for (slot, value) in primary_key_slots.iter().copied().zip(components) {
504 let _ = descriptor
505 .field_for_slot_index(slot)
506 .ok_or_else(InternalError::executor_invariant)?;
507 let has_explicit_intent = patch
508 .entries()
509 .iter()
510 .any(|entry| entry.slot().index() == slot);
511 if has_explicit_intent {
512 continue;
513 }
514 let value = InputValue::try_from_runtime_non_enum(&value)
515 .ok_or_else(InternalError::executor_invariant)?;
516 patch =
517 patch.set_preserved_replacement_identity(FieldSlot::from_validated_index(slot), value);
518 }
519
520 Ok(patch)
521}
522
523fn accepted_identity_insert_field(
527 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
528) -> Result<Option<AcceptedIdentityInsertField>, InternalError> {
529 let mut identity = None;
530 for field in descriptor.fields() {
531 if field.write_policy().insert_generation() != Some(FieldInsertGeneration::Identity) {
532 continue;
533 }
534 let field_slot = usize::from(field.slot().get());
535 if identity
536 .replace(AcceptedIdentityInsertField {
537 field_id: field.field_id(),
538 field_slot,
539 accepted_kind: field.kind().clone(),
540 })
541 .is_some()
542 || descriptor.primary_key_slot_indices() != [field_slot]
543 {
544 return Err(InternalError::identity_corruption());
545 }
546 }
547 Ok(identity)
548}
549
550fn checked_pre_key_candidate_count(count: usize) -> Result<u32, InternalError> {
551 u32::try_from(count).map_err(|_| InternalError::identity_candidate_count_exhausted())
552}
553
554fn validate_identity_materialization(
555 entity_tag: crate::types::EntityTag,
556 identity_field: &AcceptedIdentityInsertField,
557 candidate: &AcceptedPreKeyInsert,
558 allocation: &AcceptedIdentityAllocation,
559 data_key: &DecodedDataStoreKey,
560 reader: &StructuralSlotReader<'_>,
561) -> Result<(), InternalError> {
562 let owner = allocation.owner();
563 let slot_value = reader.required_cached_value(identity_field.field_slot)?;
564 if candidate.entity_tag() != entity_tag
565 || candidate.input_ordinal() != allocation.input_ordinal()
566 || owner.entity_tag() != entity_tag
567 || owner.field_id() != identity_field.field_id
568 || allocation.field_slot() != identity_field.field_slot
569 || slot_value != allocation.value()
570 || data_key.primary_key_runtime_value() != *allocation.value()
571 {
572 return Err(InternalError::identity_corruption());
573 }
574 Ok(())
575}
576
577fn data_key_from_row(
578 entity_tag: crate::types::EntityTag,
579 contract: &StructuralRowContract,
580 row: &RawRow,
581) -> Result<DecodedDataStoreKey, InternalError> {
582 let reader =
583 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(row, contract)?;
584 let values = contract
585 .primary_key_slot_indices()
586 .iter()
587 .map(|slot| reader.required_cached_value(*slot).cloned())
588 .collect::<Result<Vec<_>, _>>()?;
589 let value = match values.as_slice() {
590 [value] => value.clone(),
591 _ => Value::List(values),
592 };
593 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
594}
595
596#[cfg(feature = "sql")]
597pub(in crate::db::session) fn structural_data_key_from_runtime_values(
598 entity_tag: crate::types::EntityTag,
599 values: Vec<Value>,
600) -> Result<DecodedDataStoreKey, InternalError> {
601 let value = match values.as_slice() {
602 [value] => value.clone(),
603 _ => Value::List(values),
604 };
605 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
606}
607
608fn validated_existing_row(
609 store: crate::db::registry::StoreHandle,
610 data_key: &DecodedDataStoreKey,
611 contract: &StructuralRowContract,
612) -> Result<Option<RawRow>, InternalError> {
613 let raw_key = data_key.to_raw()?;
614 let row = store.with_data(|data| data.get(&raw_key));
615 if let Some(row) = row.as_ref() {
616 let reader =
617 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(row, contract)?;
618 reader.validate_primary_key(data_key)?;
619 }
620 Ok(row)
621}
622
623fn prepare_dynamic_mutation_result(
624 catalog: &AcceptedSchemaCatalogContext,
625 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
626 rows: Vec<AcceptedStructuralMutationRow>,
627 enforce_mixed_batch_result_bound: bool,
628) -> Result<DynamicMutationResult, InternalError> {
629 let affected_rows = rows.iter().try_fold(0_u32, |total, row| {
630 total
631 .checked_add(u32::from(row.logical_changed()))
632 .ok_or_else(InternalError::executor_invariant)
633 })?;
634 let columns = descriptor
635 .fields()
636 .iter()
637 .map(|field| field.name().to_string())
638 .collect();
639 let rows = rows
640 .into_iter()
641 .map(|row| {
642 row.values
643 .iter()
644 .map(|value| {
645 output_value_from_runtime(catalog.enum_catalog(), value)
646 .map_err(|_| InternalError::store_invariant())
647 })
648 .collect::<Result<Vec<_>, _>>()
649 })
650 .collect::<Result<Vec<_>, _>>()?;
651 let result = DynamicMutationResult {
652 entity: catalog.snapshot().entity_name().to_string(),
653 columns,
654 rows,
655 affected_rows,
656 };
657 if enforce_mixed_batch_result_bound {
658 let encoded =
659 candid::encode_one(&result).map_err(|_| InternalError::executor_invariant())?;
660 validate_structural_mutation_result_bytes(encoded.len())?;
661 }
662 Ok(result)
663}
664
665fn dynamic_typed_field_type(
666 field_type: DynamicTypedFieldType,
667) -> Result<FieldType, DynamicTypedBindingError> {
668 match field_type {
669 DynamicTypedFieldType::Scalar(scalar) => Ok(FieldType::Scalar(scalar)),
670 DynamicTypedFieldType::List(item) => {
671 Ok(FieldType::List(Box::new(dynamic_typed_field_type(*item)?)))
672 }
673 DynamicTypedFieldType::Named(source_key) => TypeSourceKey::try_new(source_key)
674 .map(FieldType::Named)
675 .map_err(|_| DynamicTypedBindingError::FieldUnavailable),
676 }
677}
678
679fn typed_adapter_field_kind_matches(
680 accepted: &AcceptedFieldKind,
681 expected: &AcceptedFieldKind,
682) -> bool {
683 if accepted == expected {
684 return true;
685 }
686 match (accepted, expected) {
687 (AcceptedFieldKind::Relation { key_kind, .. }, expected) => {
688 typed_adapter_field_kind_matches(key_kind, expected)
689 }
690 (AcceptedFieldKind::List(accepted), AcceptedFieldKind::List(expected)) => {
691 typed_adapter_field_kind_matches(accepted, expected)
692 }
693 _ => false,
694 }
695}
696
697impl<C: CanisterKind> DbSession<C> {
698 pub fn issue_typed_entity_binding(
700 &self,
701 entity_source_key: &str,
702 field_requests: &[DynamicTypedFieldBindingRequest],
703 ) -> Result<DynamicTypedEntityBinding, DynamicTypedBindingError> {
704 let entity_source = EntitySourceKey::try_new(entity_source_key)
705 .map_err(|_| DynamicTypedBindingError::FieldUnavailable)?;
706 let field_requests = field_requests
707 .iter()
708 .map(|request| {
709 Ok((
710 FieldSourceKey::try_new(request.source_key.clone())
711 .map_err(|_| DynamicTypedBindingError::FieldUnavailable)?,
712 dynamic_typed_field_type(request.field_type.clone())?,
713 request.nullable,
714 ))
715 })
716 .collect::<Result<Vec<_>, DynamicTypedBindingError>>()?;
717 let catalog = self
718 .find_accepted_schema_catalog_context_for_entity_source_key(entity_source.as_str())?
719 .ok_or(DynamicTypedBindingError::FieldUnavailable)?;
720 let identity = catalog.identity();
721 if identity.entity_path() != entity_source.as_str() {
722 return Err(InternalError::store_invariant().into());
723 }
724 let store = self.db.recovered_store(identity.store_path())?;
725 let bundle = store
726 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
727 .ok_or_else(InternalError::store_invariant)?;
728 let entity_tag = identity.entity_tag();
729 if bundle.source_bindings().entity(&entity_source) != Some(entity_tag)
730 || bundle.revision() != catalog.revision()
731 {
732 return Err(InternalError::store_invariant().into());
733 }
734 let snapshot = bundle
735 .entity_snapshots()
736 .get(&entity_tag)
737 .ok_or_else(InternalError::store_invariant)?;
738 let row_contract = catalog.inspection_plan().row_contract();
739 let mut fields = Vec::with_capacity(field_requests.len());
740 for (source, field_type, nullable) in &field_requests {
741 let field_id = bundle
742 .source_bindings()
743 .field(entity_tag, source)
744 .ok_or(DynamicTypedBindingError::FieldUnavailable)?;
745 let field = snapshot
746 .fields()
747 .iter()
748 .find(|field| field.id() == field_id)
749 .ok_or_else(InternalError::store_invariant)?;
750 let runtime_field =
751 row_contract.required_accepted_field_contract(usize::from(field.slot().get()))?;
752 if runtime_field.field_id() != field_id {
753 return Err(InternalError::store_invariant().into());
754 }
755 let expected_kind = lower_field_type(field_type, bundle.source_bindings())
756 .map_err(|_| DynamicTypedBindingError::IncompatibleField)?;
757 if field.nullable() != *nullable
758 || !typed_adapter_field_kind_matches(field.kind(), &expected_kind)
759 {
760 return Err(DynamicTypedBindingError::IncompatibleField);
761 }
762 fields.push((
763 source.as_str().to_string(),
764 field_id.get(),
765 field.slot().get(),
766 field.name().to_string(),
767 ));
768 }
769 let adapter_names = bundle.typed_adapter_names()?;
770
771 DynamicTypedEntityBinding::new(
772 database_incarnation_id()?.to_bytes(),
773 entity_source.as_str().to_string(),
774 snapshot.entity_name().to_string(),
775 entity_tag.value(),
776 catalog.revision().get(),
777 catalog.fingerprint(),
778 row_contract.current_layout_version().get(),
779 fields,
780 adapter_names.named_types,
781 adapter_names.enum_variants,
782 adapter_names.composite_fields,
783 )
784 .map_err(Into::into)
785 }
786
787 pub(in crate::db::session) fn current_typed_entity_binding_catalog(
788 &self,
789 binding: &DynamicTypedEntityBinding,
790 ) -> Result<Option<AcceptedSchemaCatalogContext>, InternalError> {
791 if database_incarnation_id()?.to_bytes() != binding.database_incarnation {
792 return Ok(None);
793 }
794 let Some(catalog) = self.find_accepted_schema_catalog_context_for_entity_source_key(
795 binding.entity_source.as_str(),
796 )?
797 else {
798 return Ok(None);
799 };
800 let row_contract = catalog.inspection_plan().row_contract();
801 let identity = catalog.identity();
802 if identity.entity_path() != binding.entity_source.as_str()
803 || identity.entity_tag().value() != binding.entity_tag
804 || catalog.revision().get() != binding.accepted_revision
805 || catalog.fingerprint() != binding.accepted_fingerprint
806 || row_contract.current_layout_version().get() != binding.entity_generation
807 {
808 return Ok(None);
809 }
810 let entity_source = EntitySourceKey::try_new(binding.entity_source.clone())
811 .map_err(|_| InternalError::store_invariant())?;
812 let store = self.db.recovered_store(identity.store_path())?;
813 let bundle = store
814 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
815 .ok_or_else(InternalError::store_invariant)?;
816 if bundle.revision() != catalog.revision()
817 || bundle.source_bindings().entity(&entity_source) != Some(identity.entity_tag())
818 {
819 return Ok(None);
820 }
821 let snapshot = bundle
822 .entity_snapshots()
823 .get(&identity.entity_tag())
824 .ok_or_else(InternalError::store_invariant)?;
825 for (source_key, expected_field_id, expected_slot) in binding.field_identity_bindings() {
826 let source = FieldSourceKey::try_new(source_key)
827 .map_err(|_| InternalError::store_invariant())?;
828 let Some(field_id) = bundle
829 .source_bindings()
830 .field(identity.entity_tag(), &source)
831 else {
832 return Ok(None);
833 };
834 let Some(field) = snapshot
835 .fields()
836 .iter()
837 .find(|field| field.id() == field_id)
838 else {
839 return Err(InternalError::store_invariant());
840 };
841 if field_id.get() != expected_field_id || field.slot().get() != expected_slot {
842 return Ok(None);
843 }
844 }
845 Ok(Some(catalog))
846 }
847
848 pub fn typed_entity_binding_is_current(
850 &self,
851 binding: &DynamicTypedEntityBinding,
852 ) -> Result<bool, InternalError> {
853 self.current_typed_entity_binding_catalog(binding)
854 .map(|catalog| catalog.is_some())
855 }
856
857 #[cfg(feature = "sql")]
860 pub(in crate::db::session) fn execute_accepted_structural_delete_batch(
861 &self,
862 catalog: &AcceptedSchemaCatalogContext,
863 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
864 keys: Vec<DecodedDataStoreKey>,
865 precommit_validation: impl FnOnce(&[Vec<Value>]) -> Result<(), InternalError>,
866 ) -> Result<Vec<Vec<Value>>, InternalError> {
867 let mutations = keys
868 .into_iter()
869 .map(AcceptedStructuralMutation::delete)
870 .collect::<Vec<_>>();
871 let mutation_capacity = mutations.len();
872 let mut mutations = mutations.into_iter();
873 self.execute_accepted_structural_mutation_batch_inner(
874 catalog,
875 descriptor,
876 mutation_capacity,
877 0,
878 || Ok(mutations.next()),
879 Timestamp::now(),
880 AcceptedStructuralMutationCommitOptions::standard(),
881 |rows, _report| {
882 let rows = rows
883 .into_iter()
884 .map(AcceptedStructuralMutationRow::into_values)
885 .collect::<Vec<_>>();
886 precommit_validation(rows.as_slice())?;
887 Ok((rows, AcceptedStructuralMutationCommitDirective::Standard))
888 },
889 )
890 }
891
892 pub(in crate::db::session) fn execute_accepted_structural_save_batch<T>(
900 &self,
901 catalog: &AcceptedSchemaCatalogContext,
902 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
903 mutations: Vec<AcceptedStructuralMutation>,
904 operation_timestamp: Timestamp,
905 precommit_preparation: impl FnOnce(
906 Vec<AcceptedStructuralMutationRow>,
907 ) -> Result<T, InternalError>,
908 ) -> Result<T, InternalError> {
909 let mutation_capacity = mutations.len();
910 let identity_candidate_count = mutations
911 .iter()
912 .filter(|mutation| {
913 matches!(
914 mutation,
915 AcceptedStructuralMutation::Save {
916 mode: MutationMode::Insert,
917 target: AcceptedStructuralMutationTarget::ResolveFromAfterImage,
918 ..
919 }
920 )
921 })
922 .count();
923 let mut mutations = mutations.into_iter();
924 self.execute_accepted_structural_mutation_batch_inner(
925 catalog,
926 descriptor,
927 mutation_capacity,
928 identity_candidate_count,
929 || Ok(mutations.next()),
930 operation_timestamp,
931 AcceptedStructuralMutationCommitOptions::standard(),
932 |rows, _report| {
933 precommit_preparation(rows).map(|prepared| {
934 (
935 prepared,
936 AcceptedStructuralMutationCommitDirective::Standard,
937 )
938 })
939 },
940 )
941 }
942
943 #[cfg(test)]
945 pub(in crate::db::session) fn execute_accepted_structural_update_with_mutation_progress(
946 &self,
947 catalog: &AcceptedSchemaCatalogContext,
948 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
949 mutations: Vec<AcceptedStructuralMutation>,
950 operation_timestamp: Timestamp,
951 mutation_progress: MutationProgressRecordOp,
952 ) -> Result<usize, InternalError> {
953 let mutation_capacity = mutations.len();
954 let mut mutations = mutations.into_iter();
955 self.execute_accepted_structural_mutation_batch_inner(
956 catalog,
957 descriptor,
958 mutation_capacity,
959 0,
960 || Ok(mutations.next()),
961 operation_timestamp,
962 AcceptedStructuralMutationCommitOptions::with_mutation_progress(),
963 |rows, _report| {
964 Ok((
965 rows.len(),
966 AcceptedStructuralMutationCommitDirective::WithMutationProgress(
967 mutation_progress,
968 ),
969 ))
970 },
971 )
972 }
973
974 #[cfg(any(feature = "sql", test))]
977 pub(in crate::db::session) fn execute_accepted_structural_update_bounded_prefix<T>(
978 &self,
979 catalog: &AcceptedSchemaCatalogContext,
980 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
981 mutation_capacity: usize,
982 mut next_mutation: impl FnMut() -> Result<Option<AcceptedStructuralMutation>, InternalError>,
983 operation_timestamp: Timestamp,
984 precommit_preparation: impl FnOnce(
985 AcceptedStructuralMutationPackingReport,
986 ) -> Result<
987 (T, AcceptedStructuralMutationCommitDirective),
988 InternalError,
989 >,
990 ) -> Result<T, InternalError> {
991 self.execute_accepted_structural_mutation_batch_inner(
992 catalog,
993 descriptor,
994 mutation_capacity,
995 0,
996 &mut next_mutation,
997 operation_timestamp,
998 AcceptedStructuralMutationCommitOptions::bounded_prefix(),
999 |rows, report| {
1000 if rows.len() != report.admitted_mutations() {
1001 return Err(InternalError::executor_invariant());
1002 }
1003 precommit_preparation(report)
1004 },
1005 )
1006 }
1007
1008 #[expect(
1009 clippy::too_many_arguments,
1010 clippy::too_many_lines,
1011 reason = "one phased owner keeps accepted authority, mutation context, precommit preparation, output capture, and commit staging inseparable"
1012 )]
1013 fn execute_accepted_structural_mutation_batch_inner<T>(
1014 &self,
1015 catalog: &AcceptedSchemaCatalogContext,
1016 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
1017 mutation_capacity: usize,
1018 identity_candidate_count: usize,
1019 mut next_mutation: impl FnMut() -> Result<Option<AcceptedStructuralMutation>, InternalError>,
1020 operation_timestamp: Timestamp,
1021 options: AcceptedStructuralMutationCommitOptions,
1022 precommit_preparation: impl FnOnce(
1023 Vec<AcceptedStructuralMutationRow>,
1024 AcceptedStructuralMutationPackingReport,
1025 ) -> Result<
1026 (T, AcceptedStructuralMutationCommitDirective),
1027 InternalError,
1028 >,
1029 ) -> Result<T, InternalError> {
1030 let identity = catalog.identity();
1031 let AcceptedStructuralMutationCommitOptions {
1032 capture_output_values,
1033 packing,
1034 } = options;
1035 let entity_path = identity.entity_path();
1036 let store_path = identity.store_path();
1037 let row_decode_contract =
1038 descriptor.row_decode_contract(catalog.value_catalog_handle().clone());
1039 let row_contract = StructuralRowContract::from_accepted_decode_contract(
1040 entity_path,
1041 row_decode_contract.clone(),
1042 );
1043 let store = self.db.recovered_store(store_path)?;
1044 let write_context = dynamic_write_context(operation_timestamp);
1045 let identity_field = accepted_identity_insert_field(descriptor)?;
1046 let identity_incarnation = identity_field
1047 .as_ref()
1048 .map(|_| database_incarnation_id())
1049 .transpose()?;
1050 if mutation_capacity > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1051 return Err(InternalError::mutation_batch_too_many_items(
1052 mutation_capacity,
1053 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1054 ));
1055 }
1056 let _ = checked_pre_key_candidate_count(identity_candidate_count)?;
1057 let mut identity_cursor: Option<IdentityStatementCursor> = None;
1058 let mut identity_insert_ordinal = 0_u32;
1059 let mut scheduler = AcceptedMutationConstraintScheduler::new(
1060 entity_path,
1061 identity.entity_tag(),
1062 row_decode_contract.clone(),
1063 catalog.fingerprint(),
1064 catalog.fingerprint_method_version(),
1065 catalog.accepted_row_constraints(),
1066 mutation_capacity,
1067 );
1068 let mut output = Vec::with_capacity(mutation_capacity);
1069 let mut staged_bytes = 0_usize;
1070 let mut stopped_before_candidate = false;
1071 let mut candidate_exceeds_batch_policy = false;
1072 let mut input_index = 0_usize;
1073
1074 while let Some(mutation) = next_mutation()? {
1075 if input_index >= mutation_capacity {
1076 return Err(InternalError::mutation_batch_too_many_items(
1077 input_index.saturating_add(1),
1078 mutation_capacity,
1079 ));
1080 }
1081 let batch_input_ordinal = u32::try_from(input_index).map_err(|_| {
1082 InternalError::mutation_batch_too_many_items(
1083 mutation_capacity,
1084 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1085 )
1086 })?;
1087 input_index = input_index.saturating_add(1);
1088 let AcceptedStructuralMutation::Save {
1089 mode,
1090 target,
1091 patch: authored_patch,
1092 } = mutation
1093 else {
1094 let AcceptedStructuralMutation::Delete { key } = mutation else {
1095 return Err(InternalError::executor_invariant());
1096 };
1097 let before = validated_existing_row(store, &key, &row_contract)?
1098 .ok_or_else(|| InternalError::store_not_found(&key))?;
1099 let raw_key = key.to_raw()?;
1100 let canonical_before = canonical_row_from_raw_row_with_accepted_decode_contract(
1101 entity_path,
1102 row_decode_contract.clone(),
1103 &before,
1104 )?;
1105 let admission = admit_structural_mutation_staged_charge(
1106 &mut staged_bytes,
1107 [
1108 raw_key.as_bytes().len(),
1109 canonical_before.as_raw_row().as_bytes().len(),
1110 ],
1111 packing,
1112 )?;
1113 match admission {
1114 AcceptedStructuralMutationStagedAdmission::Admitted => {}
1115 AcceptedStructuralMutationStagedAdmission::PageFull => {
1116 stopped_before_candidate = true;
1117 break;
1118 }
1119 AcceptedStructuralMutationStagedAdmission::CandidateExceedsPolicy => {
1120 stopped_before_candidate = true;
1121 candidate_exceeds_batch_policy = true;
1122 break;
1123 }
1124 }
1125 scheduler.schedule_delete(
1126 CommitRowOp::new(
1127 entity_path,
1128 raw_key,
1129 Some(canonical_before.as_raw_row().as_bytes().to_vec()),
1130 None,
1131 catalog.fingerprint(),
1132 ),
1133 batch_input_ordinal,
1134 )?;
1135 let reader = StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
1136 canonical_before.as_raw_row(),
1137 &row_contract,
1138 )?;
1139 let values = if capture_output_values {
1140 let mut values = Vec::with_capacity(descriptor.fields().len());
1141 for field in descriptor.fields() {
1142 values.push(
1143 reader
1144 .required_cached_value(usize::from(field.slot().get()))?
1145 .clone(),
1146 );
1147 }
1148 values
1149 } else {
1150 Vec::new()
1151 };
1152 output.push(AcceptedStructuralMutationRow {
1153 values,
1154 logical_changed: true,
1155 });
1156 continue;
1157 };
1158 let mutation_context =
1159 mutation_diagnostic_context(identity.entity_tag(), mode, batch_input_ordinal);
1160 let (expected_key, preloaded_before, pre_key_insert, mut keyed_patch) = match target {
1161 AcceptedStructuralMutationTarget::ResolveFromAfterImage => {
1162 let candidate_ordinal =
1163 if identity_field.is_some() && matches!(mode, MutationMode::Insert) {
1164 identity_insert_ordinal
1165 } else {
1166 batch_input_ordinal
1167 };
1168 (
1169 None,
1170 None,
1171 Some(AcceptedPreKeyInsert::new(
1172 identity.entity_tag(),
1173 authored_patch,
1174 candidate_ordinal,
1175 )),
1176 None,
1177 )
1178 }
1179 AcceptedStructuralMutationTarget::Expected(key) => {
1180 (Some(*key), None, None, Some(authored_patch))
1181 }
1182 AcceptedStructuralMutationTarget::ExpectedLoaded(loaded) => {
1183 let (key, row) = loaded.into_parts();
1184 (Some(key), Some(row), None, Some(authored_patch))
1185 }
1186 };
1187 if matches!(mode, MutationMode::Replace)
1188 && let Some(key) = expected_key.as_ref()
1189 {
1190 let patch = keyed_patch
1191 .take()
1192 .ok_or_else(InternalError::executor_invariant)?;
1193 keyed_patch = Some(preserve_dynamic_replacement_identity(
1194 key, descriptor, patch,
1195 )?);
1196 }
1197 let patch = pre_key_insert
1198 .as_ref()
1199 .map(AcceptedPreKeyInsert::fields)
1200 .or(keyed_patch.as_ref())
1201 .ok_or_else(InternalError::executor_invariant)?;
1202 let before = match (expected_key.as_ref(), preloaded_before) {
1203 (Some(_), Some(row)) => Some(row),
1204 (Some(key), None) => validated_existing_row(store, key, &row_contract)?,
1205 (None, None) => None,
1206 (None, Some(_)) => return Err(InternalError::executor_invariant()),
1207 };
1208 match mode {
1209 MutationMode::Insert if before.is_some() => {
1210 return Err(mutation_key_exists_error());
1211 }
1212 MutationMode::Update if before.is_none() => {
1213 let key = expected_key
1214 .as_ref()
1215 .ok_or_else(InternalError::executor_invariant)?;
1216 return Err(InternalError::store_not_found(key));
1217 }
1218 MutationMode::Insert | MutationMode::Replace | MutationMode::Update => {}
1219 }
1220
1221 let identity_allocation = if let Some(identity_field) = identity_field.as_ref()
1222 && matches!(mode, MutationMode::Insert)
1223 && before.is_none()
1224 {
1225 let candidate = pre_key_insert.as_ref().ok_or_else(|| {
1226 InternalError::mutation_database_owned_field_explicit(
1227 mutation_context,
1228 identity_field.field_id.get(),
1229 )
1230 })?;
1231 if identity_cursor.is_none() {
1232 let incarnation = identity_incarnation
1233 .ok_or_else(InternalError::identity_state_corruption)?;
1234 identity_cursor = Some(store.with_schema(|schema_store| {
1235 schema_store.identity_statement_cursor(
1236 incarnation,
1237 identity.entity_tag(),
1238 identity_field.field_id,
1239 &identity_field.accepted_kind,
1240 )
1241 })?);
1242 }
1243 let allocation = identity_cursor
1244 .as_mut()
1245 .ok_or_else(InternalError::identity_state_corruption)?
1246 .allocate(identity_field.field_slot, candidate.input_ordinal())?;
1247 identity_insert_ordinal = identity_insert_ordinal
1248 .checked_add(1)
1249 .ok_or_else(InternalError::identity_candidate_count_exhausted)?;
1250 Some(allocation)
1251 } else if let Some(identity_field) = identity_field.as_ref()
1252 && matches!(mode, MutationMode::Replace)
1253 && before.is_none()
1254 {
1255 return Err(InternalError::mutation_database_owned_field_explicit(
1256 mutation_context,
1257 identity_field.field_id.get(),
1258 ));
1259 } else {
1260 None
1261 };
1262
1263 let resolved = match (mode, before.as_ref()) {
1264 (MutationMode::Insert | MutationMode::Replace, None) => {
1265 resolve_insert_structural_patch_with_accepted_contract(
1266 entity_path,
1267 row_decode_contract.clone(),
1268 catalog.fingerprint(),
1269 catalog.accepted_row_constraints(),
1270 patch,
1271 write_context,
1272 mutation_context,
1273 identity_allocation.as_ref(),
1274 )?
1275 }
1276 (MutationMode::Update, Some(before)) => {
1277 resolve_update_structural_patch_with_accepted_contract(
1278 entity_path,
1279 row_decode_contract.clone(),
1280 catalog.fingerprint(),
1281 catalog.accepted_row_constraints(),
1282 before,
1283 patch,
1284 write_context,
1285 mutation_context,
1286 )?
1287 }
1288 (MutationMode::Replace, Some(before)) => {
1289 resolve_existing_replace_structural_patch_with_accepted_contract(
1290 entity_path,
1291 row_decode_contract.clone(),
1292 catalog.fingerprint(),
1293 catalog.accepted_row_constraints(),
1294 before,
1295 patch,
1296 write_context,
1297 mutation_context,
1298 )?
1299 }
1300 (MutationMode::Insert, Some(_)) | (MutationMode::Update, None) => {
1301 return Err(InternalError::executor_invariant());
1302 }
1303 };
1304 let (after, provenance) = resolved.into_parts();
1305 let reader = StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
1306 after.as_raw_row(),
1307 &row_contract,
1308 )?;
1309 let data_key = match expected_key {
1310 Some(key) => {
1311 reader.validate_primary_key(&key)?;
1312 key
1313 }
1314 None => {
1315 data_key_from_row(identity.entity_tag(), &row_contract, after.as_raw_row())?
1316 }
1317 };
1318 if let Some(allocation) = identity_allocation.as_ref() {
1319 validate_identity_materialization(
1320 identity.entity_tag(),
1321 identity_field
1322 .as_ref()
1323 .ok_or_else(InternalError::identity_corruption)?,
1324 pre_key_insert
1325 .as_ref()
1326 .ok_or_else(InternalError::identity_corruption)?,
1327 allocation,
1328 &data_key,
1329 &reader,
1330 )?;
1331 }
1332 if matches!(mode, MutationMode::Insert)
1333 && validated_existing_row(store, &data_key, &row_contract)?.is_some()
1334 {
1335 return Err(insert_key_exists_after_generation(
1336 identity_allocation.is_some(),
1337 ));
1338 }
1339 let raw_key = data_key.to_raw()?;
1340 let canonical_before = before
1341 .as_ref()
1342 .map(|before| {
1343 canonical_row_from_raw_row_with_accepted_decode_contract(
1344 entity_path,
1345 row_decode_contract.clone(),
1346 before,
1347 )
1348 })
1349 .transpose()?;
1350 let logical_changed = canonical_before.as_ref().is_none_or(|before| {
1351 before.as_raw_row().as_bytes() != after.as_raw_row().as_bytes()
1352 });
1353 let physical_changed = before
1354 .as_ref()
1355 .is_none_or(|before| before.as_bytes() != after.as_raw_row().as_bytes());
1356 let admission = admit_structural_mutation_staged_charge(
1357 &mut staged_bytes,
1358 [
1359 raw_key.as_bytes().len(),
1360 canonical_before
1361 .as_ref()
1362 .map_or(0, |before| before.as_raw_row().as_bytes().len()),
1363 after.as_raw_row().as_bytes().len(),
1364 ],
1365 packing,
1366 )?;
1367 match admission {
1368 AcceptedStructuralMutationStagedAdmission::Admitted => {}
1369 AcceptedStructuralMutationStagedAdmission::PageFull => {
1370 stopped_before_candidate = true;
1371 break;
1372 }
1373 AcceptedStructuralMutationStagedAdmission::CandidateExceedsPolicy => {
1374 stopped_before_candidate = true;
1375 candidate_exceeds_batch_policy = true;
1376 break;
1377 }
1378 }
1379 let row_op = physical_changed.then(|| {
1380 CommitRowOp::new(
1381 entity_path,
1382 raw_key.clone(),
1383 canonical_before
1384 .as_ref()
1385 .map(|before| before.as_raw_row().as_bytes().to_vec()),
1386 Some(after.as_raw_row().as_bytes().to_vec()),
1387 catalog.fingerprint(),
1388 )
1389 });
1390 scheduler.schedule_save_after_image(
1391 mode,
1392 &data_key,
1393 after.as_raw_row(),
1394 provenance.as_slice(),
1395 row_op,
1396 batch_input_ordinal,
1397 )?;
1398 let values = if capture_output_values {
1399 let mut values = Vec::with_capacity(descriptor.fields().len());
1400 for field in descriptor.fields() {
1401 values.push(
1402 reader
1403 .required_cached_value(usize::from(field.slot().get()))?
1404 .clone(),
1405 );
1406 }
1407 values
1408 } else {
1409 Vec::new()
1410 };
1411 output.push(AcceptedStructuralMutationRow {
1412 values,
1413 logical_changed,
1414 });
1415 }
1416
1417 let report = AcceptedStructuralMutationPackingReport {
1418 admitted_mutations: output.len(),
1419 stopped_before_candidate,
1420 candidate_exceeds_batch_policy,
1421 };
1422 let batch = scheduler.finish();
1423 let (prepared, commit_directive) = precommit_preparation(output, report)?;
1424 let identity_ranges = identity_cursor
1425 .map(IdentityStatementCursor::into_range_advance)
1426 .transpose()?
1427 .into_iter()
1428 .flatten()
1429 .collect::<Vec<_>>();
1430 if !matches!(
1431 commit_directive,
1432 AcceptedStructuralMutationCommitDirective::Skip
1433 ) && batch.is_empty()
1434 && !identity_ranges.is_empty()
1435 {
1436 return Err(InternalError::identity_corruption());
1437 }
1438 match commit_directive {
1439 AcceptedStructuralMutationCommitDirective::Skip => {}
1440 AcceptedStructuralMutationCommitDirective::Standard if batch.is_empty() => {}
1441 AcceptedStructuralMutationCommitDirective::Standard => {
1442 commit_structural_row_ops_with_window_for_path(
1443 &self.db,
1444 entity_path,
1445 batch,
1446 identity_ranges,
1447 "accepted_structural_batch_apply",
1448 )?;
1449 }
1450 AcceptedStructuralMutationCommitDirective::WithMutationProgress(operation)
1451 if batch.is_empty() =>
1452 {
1453 let _ = operation;
1454 return Err(InternalError::executor_invariant());
1455 }
1456 AcceptedStructuralMutationCommitDirective::WithMutationProgress(operation) => {
1457 commit_structural_row_ops_with_mutation_progress_for_path(
1458 &self.db,
1459 entity_path,
1460 batch,
1461 identity_ranges,
1462 operation,
1463 "accepted_structural_batch_apply",
1464 )?;
1465 }
1466 }
1467 Ok(prepared)
1468 }
1469
1470 fn execute_one_accepted_save_mutation(
1471 &self,
1472 catalog: &AcceptedSchemaCatalogContext,
1473 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
1474 mode: MutationMode,
1475 target: AcceptedStructuralMutationTarget,
1476 patch: AcceptedMutationIntentPatch,
1477 ) -> Result<DynamicMutationResult, InternalError> {
1478 let identity = catalog.identity();
1479 let entity_path = identity.entity_path();
1480 let result = self.execute_accepted_structural_save_batch(
1481 catalog,
1482 descriptor,
1483 vec![AcceptedStructuralMutation::save(mode, target, patch)],
1484 Timestamp::now(),
1485 |rows| prepare_dynamic_mutation_result(catalog, descriptor, rows, false),
1486 )?;
1487 record(MetricsEvent::SaveMutation {
1488 entity_path: entity_path.into(),
1489 kind: match mode {
1490 MutationMode::Insert => SaveMutationKind::Insert,
1491 MutationMode::Replace => SaveMutationKind::Replace,
1492 MutationMode::Update => SaveMutationKind::Update,
1493 },
1494 rows_touched: u64::from(result.affected_rows),
1495 });
1496 Ok(result)
1497 }
1498
1499 pub fn execute_trusted_dynamic_mutation(
1506 &self,
1507 request: &DynamicMutation,
1508 ) -> Result<DynamicMutationResult, InternalError> {
1509 self.execute_trusted_dynamic_mutation_batch_with_result_policy(vec![request.clone()], false)
1510 }
1511
1512 pub fn execute_trusted_dynamic_mutation_batch(
1518 &self,
1519 requests: Vec<DynamicMutation>,
1520 ) -> Result<DynamicMutationResult, InternalError> {
1521 self.execute_trusted_dynamic_mutation_batch_with_result_policy(requests, true)
1522 }
1523
1524 fn execute_trusted_dynamic_mutation_batch_with_result_policy(
1525 &self,
1526 requests: Vec<DynamicMutation>,
1527 enforce_mixed_batch_result_bound: bool,
1528 ) -> Result<DynamicMutationResult, InternalError> {
1529 if requests.is_empty() {
1530 return Err(InternalError::mutation_batch_empty());
1531 }
1532 if requests.len() > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1533 return Err(InternalError::mutation_batch_too_many_items(
1534 requests.len(),
1535 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1536 ));
1537 }
1538 let first = requests
1539 .first()
1540 .ok_or_else(InternalError::mutation_batch_empty)?;
1541 if first.entity().is_empty() {
1542 return Err(InternalError::executor_unsupported());
1543 }
1544 let catalog = self.accepted_schema_catalog_context_for_entity_name(Some(first.entity()))?;
1545 let accepted_identity = catalog.identity();
1546 let descriptor =
1547 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1548 let mut mutations = Vec::with_capacity(requests.len());
1549 let mut save_kinds = Vec::with_capacity(requests.len());
1550
1551 for (batch_position, request) in requests.iter().enumerate() {
1552 let batch_position = u32::try_from(batch_position).map_err(|_| {
1553 InternalError::mutation_batch_too_many_items(
1554 requests.len(),
1555 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1556 )
1557 })?;
1558 if request.entity().is_empty() {
1559 return Err(InternalError::executor_unsupported());
1560 }
1561 let item_catalog =
1562 self.accepted_schema_catalog_context_for_entity_name(Some(request.entity()))?;
1563 if item_catalog.identity() != accepted_identity {
1564 return Err(InternalError::mutation_batch_entity_mismatch(
1565 batch_position,
1566 accepted_identity.entity_tag().value(),
1567 item_catalog.identity().entity_tag().value(),
1568 ));
1569 }
1570 let (mutation, save_kind) = lower_dynamic_mutation_intent(
1571 accepted_identity.entity_tag(),
1572 accepted_identity.entity_path(),
1573 &descriptor,
1574 request,
1575 batch_position,
1576 )?;
1577 mutations.push(mutation);
1578 save_kinds.push(save_kind);
1579 }
1580
1581 let entity_path = accepted_identity.entity_path_handle();
1582 let (result, metrics) = self.execute_accepted_structural_save_batch(
1583 &catalog,
1584 &descriptor,
1585 mutations,
1586 Timestamp::now(),
1587 |rows| {
1588 if rows.len() != save_kinds.len() {
1589 return Err(InternalError::executor_invariant());
1590 }
1591 let metrics = rows
1592 .iter()
1593 .zip(save_kinds)
1594 .filter_map(|(row, kind)| kind.map(|kind| (kind, row.logical_changed())))
1595 .collect::<Vec<_>>();
1596 let result = prepare_dynamic_mutation_result(
1597 &catalog,
1598 &descriptor,
1599 rows,
1600 enforce_mixed_batch_result_bound,
1601 )?;
1602 Ok((result, metrics))
1603 },
1604 )?;
1605 for (kind, logical_changed) in metrics {
1606 record(MetricsEvent::SaveMutation {
1607 entity_path: entity_path.clone(),
1608 kind,
1609 rows_touched: u64::from(logical_changed),
1610 });
1611 }
1612 Ok(result)
1613 }
1614
1615 #[doc(hidden)]
1618 pub fn execute_trusted_typed_mutation(
1619 &self,
1620 binding: &DynamicTypedEntityBinding,
1621 request: &DynamicTypedMutation,
1622 ) -> Result<Option<DynamicMutationResult>, InternalError> {
1623 let Some(catalog) = self.current_typed_entity_binding_catalog(binding)? else {
1624 return Ok(None);
1625 };
1626 let identity = catalog.identity();
1627 let descriptor =
1628 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1629 let mode = dynamic_typed_mutation_mode(request);
1630 let (target, patch) = match request {
1631 DynamicTypedMutation::Insert { patch } => (
1632 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
1633 patch,
1634 ),
1635 DynamicTypedMutation::Update { key, patch }
1636 | DynamicTypedMutation::Replace { key, patch } => (
1637 AcceptedStructuralMutationTarget::expected(dynamic_key(
1638 identity.entity_tag(),
1639 key,
1640 )?),
1641 patch,
1642 ),
1643 };
1644 if !patch.is_bound_to(binding) {
1645 return Ok(None);
1646 }
1647 let patch = lower_typed_patch(
1648 &descriptor,
1649 patch,
1650 mode,
1651 mutation_diagnostic_context(identity.entity_tag(), mode, 0),
1652 )?;
1653 self.execute_one_accepted_save_mutation(&catalog, &descriptor, mode, target, patch)
1654 .map(Some)
1655 }
1656
1657 pub fn execute_trusted_dynamic_insert_batch(
1663 &self,
1664 entity: &str,
1665 patches: Vec<DynamicStructuralPatch>,
1666 ) -> Result<DynamicMutationResult, InternalError> {
1667 let mutations = patches
1668 .into_iter()
1669 .map(|patch| DynamicMutation::Insert {
1670 entity: entity.to_string(),
1671 patch,
1672 })
1673 .collect();
1674 self.execute_trusted_dynamic_mutation_batch_with_result_policy(mutations, false)
1675 }
1676}
1677
1678#[cfg(test)]
1679mod typed_adapter_tests {
1680 use super::{
1681 AcceptedFieldKind, DbSession, DynamicTypedBindingError, DynamicTypedFieldBindingRequest,
1682 DynamicTypedFieldType, DynamicTypedMutation, DynamicWriteCell, dynamic_typed_field_type,
1683 typed_adapter_field_kind_matches,
1684 };
1685 use crate::{
1686 db::{
1687 data::DataStore,
1688 index::IndexStore,
1689 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
1690 schema::{
1691 AcceptedSchemaRevision, FieldId, FieldStorageDecode, LeafCodec,
1692 PersistedFieldSnapshot, PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot,
1693 SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
1694 accepted_schema_candidate_with_field_bindings_for_tests,
1695 },
1696 },
1697 traits::{CanisterKind, Path},
1698 types::EntityTag,
1699 value::InputValue,
1700 };
1701 use icydb_schema::{EntitySourceKey, FieldSourceKey, ScalarType};
1702 use std::{cell::RefCell, collections::BTreeMap};
1703
1704 const STORE_PATH: &str = "session::write::typed_adapter_tests::Store";
1705 const ENTITY_SOURCE: &str = "session::write::typed_adapter_tests::Entity";
1706 const OTHER_ENTITY_SOURCE: &str = "session::write::typed_adapter_tests::OtherEntity";
1707 const ID_SOURCE: &str = "session::write::typed_adapter_tests::Entity::id";
1708 const VALUE_SOURCE: &str = "session::write::typed_adapter_tests::Entity::value";
1709 const REPLACEMENT_SOURCE: &str =
1710 "session::write::typed_adapter_tests::Entity::replacement_value";
1711 const OTHER_ID_SOURCE: &str = "session::write::typed_adapter_tests::OtherEntity::id";
1712
1713 struct TestCanister;
1714
1715 impl Path for TestCanister {
1716 const PATH: &'static str = "session::write::typed_adapter_tests::Canister";
1717 }
1718
1719 impl CanisterKind for TestCanister {
1720 const COMMIT_MEMORY_ID: u8 = 41;
1721 const COMMIT_STABLE_KEY: &'static str = "icydb.typed_adapter_tests.commit.v1";
1722 const STARTUP_MEMORY_ID: u8 = 49;
1723 const STARTUP_STABLE_KEY: &'static str = "icydb.typed_adapter_tests.startup.control.v1";
1724 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 42;
1725 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
1726 "icydb.typed_adapter_tests.integrity.progress.v1";
1727 }
1728
1729 thread_local! {
1730 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
1731 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
1732 static SCHEMA_STORE: RefCell<SchemaStore> =
1733 const { RefCell::new(SchemaStore::init_heap()) };
1734 static STORE_REGISTRY: StoreRegistry = {
1735 let mut registry = StoreRegistry::new();
1736 registry.register_store(
1737 STORE_PATH,
1738 &DATA_STORE,
1739 &INDEX_STORE,
1740 &SCHEMA_STORE,
1741 StoreAllocationIdentities::absent(),
1742 StoreRuntimeStorageCapabilities::heap(),
1743 ).expect("typed adapter test store should register");
1744 registry
1745 };
1746 }
1747
1748 fn nat64_field(id: u32, name: &str, slot: u16) -> PersistedFieldSnapshot {
1749 PersistedFieldSnapshot::new_initial(
1750 FieldId::new(id),
1751 name.to_string(),
1752 SchemaFieldSlot::new(slot),
1753 AcceptedFieldKind::Nat64,
1754 Vec::new(),
1755 false,
1756 SchemaInsertDefault::None,
1757 FieldStorageDecode::ByKind,
1758 LeafCodec::Scalar(ScalarCodec::Nat64),
1759 )
1760 }
1761
1762 fn snapshot(
1763 entity_source: &str,
1764 entity_name: &str,
1765 fields: Vec<PersistedFieldSnapshot>,
1766 ) -> PersistedSchemaSnapshot {
1767 let layout = SchemaRowLayout::initial(
1768 fields
1769 .iter()
1770 .map(|field| (field.id(), field.slot()))
1771 .collect(),
1772 );
1773 PersistedSchemaSnapshot::new(
1774 SchemaVersion::initial(),
1775 entity_source.to_string(),
1776 entity_name.to_string(),
1777 FieldId::new(1),
1778 layout,
1779 fields,
1780 )
1781 }
1782
1783 fn field_source(source: &str) -> FieldSourceKey {
1784 FieldSourceKey::try_new(source).expect("typed field source should admit")
1785 }
1786
1787 fn entity_source(source: &str) -> EntitySourceKey {
1788 EntitySourceKey::try_new(source).expect("typed entity source should admit")
1789 }
1790
1791 fn publish(
1792 session: &DbSession<TestCanister>,
1793 expected: AcceptedSchemaRevision,
1794 revision: AcceptedSchemaRevision,
1795 snapshots: BTreeMap<EntityTag, PersistedSchemaSnapshot>,
1796 fields: BTreeMap<(EntityTag, FieldSourceKey), FieldId>,
1797 ) {
1798 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
1799 STORE_PATH, revision, snapshots, fields,
1800 );
1801 let store = session
1802 .db
1803 .store_handle(STORE_PATH)
1804 .expect("typed adapter test store should resolve");
1805 crate::db::commit::publish_accepted_schema_candidate(
1806 STORE_PATH, store, expected, &candidate,
1807 )
1808 .expect("typed binding candidate should publish");
1809 }
1810
1811 fn request(source: &str) -> DynamicTypedFieldBindingRequest {
1812 DynamicTypedFieldBindingRequest::new(
1813 source.to_string(),
1814 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
1815 false,
1816 )
1817 }
1818
1819 fn assert_query_diagnostic(
1820 error: crate::db::QueryError,
1821 code: icydb_diagnostic_code::DiagnosticCode,
1822 origin: icydb_diagnostic_code::ErrorOrigin,
1823 detail: icydb_diagnostic_code::DiagnosticDetail,
1824 ) {
1825 let diagnostic = error.diagnostic();
1826 assert_eq!(diagnostic.code(), code);
1827 assert_eq!(diagnostic.origin(), origin);
1828 assert_eq!(diagnostic.detail(), Some(&detail));
1829 }
1830
1831 #[test]
1832 fn typed_adapter_kind_matching_is_exact_but_accepts_relation_key_wrappers() {
1833 let relation = AcceptedFieldKind::Relation {
1834 target_path: "test::Target".to_string(),
1835 target_entity_name: "Target".to_string(),
1836 target_entity_tag: EntityTag::new(7),
1837 target_store_path: "test::Store".to_string(),
1838 key_kind: Box::new(AcceptedFieldKind::Nat64),
1839 };
1840
1841 assert!(typed_adapter_field_kind_matches(
1842 &relation,
1843 &AcceptedFieldKind::Nat64,
1844 ));
1845 assert!(typed_adapter_field_kind_matches(
1846 &AcceptedFieldKind::List(Box::new(relation)),
1847 &AcceptedFieldKind::List(Box::new(AcceptedFieldKind::Nat64)),
1848 ));
1849 assert!(!typed_adapter_field_kind_matches(
1850 &AcceptedFieldKind::Nat64,
1851 &AcceptedFieldKind::Nat32,
1852 ));
1853 }
1854
1855 #[test]
1856 fn typed_adapter_field_contract_rejects_invalid_named_source_identity() {
1857 assert!(matches!(
1858 dynamic_typed_field_type(DynamicTypedFieldType::Named(String::new())),
1859 Err(DynamicTypedBindingError::FieldUnavailable),
1860 ));
1861 assert!(matches!(
1862 dynamic_typed_field_type(DynamicTypedFieldType::Scalar(ScalarType::Nat16)),
1863 Ok(icydb_schema::FieldType::Scalar(ScalarType::Nat16)),
1864 ));
1865 }
1866
1867 #[expect(clippy::too_many_lines)]
1870 #[test]
1871 fn typed_binding_uses_accepted_ids_and_slots_across_renames_and_name_reuse() {
1872 let entity_tag = EntityTag::new(91);
1873 let other_entity_tag = EntityTag::new(92);
1874 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
1875 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
1876 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
1877
1878 let session = DbSession::<TestCanister>::new(
1879 &STORE_REGISTRY,
1880 &crate::db::RequestExecutionRoot::__new_runtime_root(),
1881 );
1882 session
1883 .db
1884 .drive_startup_recovery_page()
1885 .expect("typed adapter test database should initialize");
1886 publish(
1887 &session,
1888 AcceptedSchemaRevision::NONE,
1889 AcceptedSchemaRevision::INITIAL,
1890 BTreeMap::from([(
1891 entity_tag,
1892 snapshot(
1893 ENTITY_SOURCE,
1894 "Entity",
1895 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
1896 ),
1897 )]),
1898 BTreeMap::from([
1899 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1900 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
1901 ]),
1902 );
1903
1904 let initial_catalog = session
1905 .find_accepted_schema_catalog_context_for_entity_source_key(ENTITY_SOURCE)
1906 .expect("initial source catalog lookup should inspect")
1907 .expect("initial source catalog should exist");
1908 assert_eq!(initial_catalog.identity().entity_tag(), entity_tag);
1909 let initial = session
1910 .issue_typed_entity_binding(
1911 entity_source(ENTITY_SOURCE).as_str(),
1912 &[request(ID_SOURCE), request(VALUE_SOURCE)],
1913 )
1914 .expect("initial typed binding should issue");
1915 assert_eq!(initial.field_slot(ID_SOURCE), Some(0));
1916 assert_eq!(initial.field_slot(VALUE_SOURCE), Some(1));
1917 assert_eq!(initial.output_field_slot("value"), Some(1));
1918 let initial_patch = initial
1919 .bind_write_fields(vec![(
1920 VALUE_SOURCE.to_string(),
1921 DynamicWriteCell::Value(InputValue::Nat64(7)),
1922 )])
1923 .expect("source-bound patch should lower");
1924 assert_eq!(
1925 initial_patch.fields(),
1926 &[(2, 1, DynamicWriteCell::Value(InputValue::Nat64(7)))]
1927 );
1928
1929 publish(
1930 &session,
1931 AcceptedSchemaRevision::INITIAL,
1932 AcceptedSchemaRevision::new(2),
1933 BTreeMap::from([
1934 (
1935 entity_tag,
1936 snapshot(
1937 ENTITY_SOURCE,
1938 "RenamedEntity",
1939 vec![
1940 nat64_field(1, "id", 0),
1941 nat64_field(2, "renamed_value", 1),
1942 nat64_field(3, "value", 2),
1943 ],
1944 ),
1945 ),
1946 (
1947 other_entity_tag,
1948 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
1949 ),
1950 ]),
1951 BTreeMap::from([
1952 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1953 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
1954 (
1955 (entity_tag, field_source(REPLACEMENT_SOURCE)),
1956 FieldId::new(3),
1957 ),
1958 (
1959 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
1960 FieldId::new(1),
1961 ),
1962 ]),
1963 );
1964
1965 let stale_authority = session
1966 .ensure_accepted_schema_authority_is_current_for_store_path(
1967 STORE_PATH,
1968 initial_catalog.value_catalog_handle().authority(),
1969 )
1970 .expect_err("the initial accepted authority must be stale after revision two");
1971 assert_eq!(
1972 stale_authority.diagnostic_facts(),
1973 vec![
1974 (
1975 icydb_diagnostic_code::DiagnosticFactTag::ExpectedRevision,
1976 AcceptedSchemaRevision::INITIAL.get(),
1977 ),
1978 (
1979 icydb_diagnostic_code::DiagnosticFactTag::CurrentRevision,
1980 AcceptedSchemaRevision::new(2).get(),
1981 ),
1982 ],
1983 );
1984
1985 assert!(
1986 !session
1987 .typed_entity_binding_is_current(&initial)
1988 .expect("renamed binding currentness should inspect")
1989 );
1990 let renamed = session
1991 .issue_typed_entity_binding(ENTITY_SOURCE, &[request(ID_SOURCE), request(VALUE_SOURCE)])
1992 .expect("renamed source-bound adapter should rebind");
1993 assert_eq!(renamed.entity(), "RenamedEntity");
1994 assert_eq!(renamed.field_slot(VALUE_SOURCE), Some(1));
1995 assert_eq!(renamed.output_field_slot("renamed_value"), Some(1));
1996 assert_eq!(renamed.output_field_slot("value"), None);
1997
1998 publish(
1999 &session,
2000 AcceptedSchemaRevision::new(2),
2001 AcceptedSchemaRevision::new(3),
2002 BTreeMap::from([
2003 (
2004 entity_tag,
2005 snapshot(
2006 ENTITY_SOURCE,
2007 "RenamedEntity",
2008 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
2009 ),
2010 ),
2011 (
2012 other_entity_tag,
2013 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
2014 ),
2015 ]),
2016 BTreeMap::from([
2017 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
2018 (
2019 (entity_tag, field_source(REPLACEMENT_SOURCE)),
2020 FieldId::new(2),
2021 ),
2022 (
2023 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
2024 FieldId::new(1),
2025 ),
2026 ]),
2027 );
2028
2029 assert!(matches!(
2030 session.issue_typed_entity_binding(
2031 ENTITY_SOURCE,
2032 &[request(ID_SOURCE), request(VALUE_SOURCE)],
2033 ),
2034 Err(DynamicTypedBindingError::FieldUnavailable),
2035 ));
2036 assert!(
2037 !session
2038 .typed_entity_binding_is_current(&renamed)
2039 .expect("removed source binding should become stale")
2040 );
2041
2042 let replacement = session
2043 .issue_typed_entity_binding(
2044 ENTITY_SOURCE,
2045 &[request(ID_SOURCE), request(REPLACEMENT_SOURCE)],
2046 )
2047 .expect("explicit replacement source should bind");
2048 assert!(
2049 session
2050 .execute_trusted_typed_mutation(
2051 &replacement,
2052 &DynamicTypedMutation::Insert {
2053 patch: initial_patch
2054 },
2055 )
2056 .expect("cross-binding patch should fail closed")
2057 .is_none()
2058 );
2059 let patch = replacement
2060 .bind_write_fields(vec![
2061 (
2062 ID_SOURCE.to_string(),
2063 DynamicWriteCell::Value(InputValue::Nat64(1)),
2064 ),
2065 (
2066 REPLACEMENT_SOURCE.to_string(),
2067 DynamicWriteCell::Value(InputValue::Nat64(9)),
2068 ),
2069 ])
2070 .expect("replacement source write should bind by accepted IDs and slots");
2071 let result = session
2072 .execute_trusted_typed_mutation(&replacement, &DynamicTypedMutation::Insert { patch })
2073 .expect("typed insert should use the accepted mutation pipeline")
2074 .expect("replacement binding should remain current");
2075 assert_eq!(result.entity, "RenamedEntity");
2076 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
2077 assert_eq!(
2078 result.rows,
2079 vec![vec![
2080 crate::value::OutputValue::Nat64(1),
2081 crate::value::OutputValue::Nat64(9)
2082 ]]
2083 );
2084 assert_eq!(result.affected_rows, 1);
2085
2086 let second_patch = replacement
2087 .bind_write_fields(vec![
2088 (
2089 ID_SOURCE.to_string(),
2090 DynamicWriteCell::Value(InputValue::Nat64(2)),
2091 ),
2092 (
2093 REPLACEMENT_SOURCE.to_string(),
2094 DynamicWriteCell::Value(InputValue::Nat64(10)),
2095 ),
2096 ])
2097 .expect("second source-bound patch should lower");
2098 session
2099 .execute_trusted_typed_mutation(
2100 &replacement,
2101 &DynamicTypedMutation::Insert {
2102 patch: second_patch,
2103 },
2104 )
2105 .expect("second typed insert should use the accepted mutation pipeline")
2106 .expect("replacement binding should remain current");
2107
2108 {
2109 let query = crate::db::DynamicQuery::new("RenamedEntity")
2110 .select(["id", "value"])
2111 .order_by(crate::db::asc("id"))
2112 .limit(1);
2113 let result = session
2114 .execute_trusted_live_page(&query, None)
2115 .expect("SQL-free dynamic execution should use accepted authority");
2116 assert_eq!(result.entity, "RenamedEntity");
2117 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
2118 assert_eq!(
2119 result.rows,
2120 vec![vec![
2121 crate::value::OutputValue::Nat64(1),
2122 crate::value::OutputValue::Nat64(9)
2123 ]]
2124 );
2125 assert_eq!(result.row_count, 1);
2126 assert_query_diagnostic(
2127 session
2128 .execute_trusted_live_page(&query.cursor("00"), None)
2129 .expect_err("scalar execution must reject grouped cursor state"),
2130 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
2131 icydb_diagnostic_code::ErrorOrigin::Query,
2132 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
2133 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
2134 },
2135 );
2136 assert_query_diagnostic(
2137 session
2138 .execute_public_dynamic_grouped_query(
2139 &crate::db::DynamicQuery::new("RenamedEntity").grouped_limits(1, 1024),
2140 )
2141 .expect_err("grouped execution must reject scalar query state"),
2142 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
2143 icydb_diagnostic_code::ErrorOrigin::Query,
2144 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
2145 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
2146 },
2147 );
2148
2149 let grouped_query = crate::db::DynamicQuery::new("RenamedEntity")
2150 .filter(crate::db::FieldRef::new("id").eq(1_u64))
2151 .group_by("value")
2152 .aggregate(crate::db::count())
2153 .grouped_limits(1, 16 * 1024)
2154 .limit(1);
2155 let grouped = session
2156 .execute_public_dynamic_grouped_query(&grouped_query)
2157 .expect("SQL-free grouped execution should use accepted authority");
2158 let typed_grouped = session
2159 .execute_public_dynamic_grouped_query_for_typed_binding(
2160 &replacement,
2161 &grouped_query,
2162 )
2163 .expect("typed grouped execution should inspect accepted authority")
2164 .expect("replacement binding should remain current");
2165 assert_eq!(typed_grouped, grouped);
2166 assert!(
2167 session
2168 .execute_public_dynamic_grouped_query_for_typed_binding(
2169 &renamed,
2170 &grouped_query,
2171 )
2172 .expect("stale grouped binding should inspect accepted authority")
2173 .is_none(),
2174 "stale typed grouped bindings must fail closed before execution"
2175 );
2176 assert_eq!(grouped.entity, "RenamedEntity");
2177 assert_eq!(grouped.row_count, 1);
2178 assert_eq!(grouped.rows.len(), 1);
2179 assert_eq!(
2180 grouped.rows[0].group_key(),
2181 &[crate::value::OutputValue::Nat64(9)]
2182 );
2183 assert_eq!(
2184 grouped.rows[0].aggregate_values(),
2185 &[crate::value::OutputValue::Nat64(1)]
2186 );
2187 assert_eq!(grouped.next_cursor, None);
2188
2189 let grouped_state_error = session
2190 .execute_trusted_dynamic_grouped_query(&grouped_query.clone().grouped_limits(1, 1))
2191 .expect_err("grouped retained state must respect its explicit byte ceiling");
2192 assert!(matches!(
2193 grouped_state_error.diagnostic().detail(),
2194 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
2195 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
2196 })
2197 ));
2198 assert_eq!(
2199 grouped_state_error.diagnostic_facts()[0],
2200 (
2201 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
2202 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::GroupDistinctStateBytes.raw(),
2203 ),
2204 );
2205
2206 assert_query_diagnostic(
2207 session
2208 .execute_public_dynamic_grouped_query(&grouped_query.clone().select(["value"]))
2209 .expect_err("grouped output must reject scalar selection"),
2210 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
2211 icydb_diagnostic_code::ErrorOrigin::Query,
2212 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
2213 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
2214 },
2215 );
2216 assert_query_diagnostic(
2217 session
2218 .execute_public_dynamic_grouped_query(
2219 &crate::db::DynamicQuery::new("RenamedEntity")
2220 .group_by("value")
2221 .aggregate(crate::db::count()),
2222 )
2223 .expect_err("public grouped execution must require explicit limits"),
2224 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
2225 icydb_diagnostic_code::ErrorOrigin::Query,
2226 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
2227 reason:
2228 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
2229 },
2230 );
2231 assert_query_diagnostic(
2232 session
2233 .execute_trusted_dynamic_grouped_query(
2234 &crate::db::DynamicQuery::new("RenamedEntity")
2235 .group_by("value")
2236 .aggregate(crate::db::count())
2237 .grouped_limits(0, 1024),
2238 )
2239 .expect_err("trusted grouped execution must reject zero limits"),
2240 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
2241 icydb_diagnostic_code::ErrorOrigin::Query,
2242 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
2243 reason:
2244 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
2245 },
2246 );
2247 assert_query_diagnostic(
2248 session
2249 .execute_public_dynamic_grouped_query(&grouped_query.grouped_limits(101, 1024))
2250 .expect_err("public grouped execution must enforce its group budget"),
2251 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
2252 icydb_diagnostic_code::ErrorOrigin::Query,
2253 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
2254 reason:
2255 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryExceedsBudget,
2256 },
2257 );
2258
2259 let paged_query = crate::db::DynamicQuery::new("RenamedEntity")
2260 .group_by("value")
2261 .aggregate(crate::db::count())
2262 .grouped_limits(2, 16 * 1024)
2263 .limit(1);
2264 assert_query_diagnostic(
2265 session
2266 .execute_public_dynamic_grouped_query(&paged_query)
2267 .expect_err("public grouped execution must reject an unbounded full scan"),
2268 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
2269 icydb_diagnostic_code::ErrorOrigin::Query,
2270 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
2271 reason:
2272 icydb_diagnostic_code::QueryReadAdmissionCode::UnboundedFullScanRejected,
2273 },
2274 );
2275 let first_page = session
2276 .execute_trusted_dynamic_grouped_query(&paged_query)
2277 .expect("SQL-free grouped first page should execute");
2278 assert_eq!(first_page.row_count, 1);
2279 assert_eq!(
2280 first_page.rows[0].group_key(),
2281 &[crate::value::OutputValue::Nat64(9)]
2282 );
2283 let cursor = first_page
2284 .next_cursor
2285 .expect("first grouped page should return a continuation cursor");
2286 assert_query_diagnostic(
2287 session
2288 .execute_trusted_dynamic_grouped_query(
2289 &paged_query.clone().cursor(format!("{cursor}0")),
2290 )
2291 .expect_err("tampered grouped cursor must fail closed"),
2292 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
2293 icydb_diagnostic_code::ErrorOrigin::Cursor,
2294 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
2295 kind: icydb_diagnostic_code::QueryErrorKind::InvalidContinuationCursor,
2296 },
2297 );
2298 let second_page = session
2299 .execute_trusted_dynamic_grouped_query(&paged_query.cursor(cursor))
2300 .expect("SQL-free grouped continuation should execute");
2301 assert_eq!(second_page.row_count, 1);
2302 assert_eq!(
2303 second_page.rows[0].group_key(),
2304 &[crate::value::OutputValue::Nat64(10)]
2305 );
2306 assert_eq!(second_page.next_cursor, None);
2307 }
2308 }
2309}
2310
2311#[cfg(test)]
2312mod mixed_relation_batch_tests {
2313 use super::{DbSession, DynamicMutation, DynamicStructuralPatch, DynamicWriteCell};
2314 use crate::{
2315 db::{
2316 DynamicQuery, asc,
2317 data::DataStore,
2318 desc,
2319 index::IndexStore,
2320 query::expr::FilterExpr,
2321 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
2322 schema::{
2323 AcceptedConstraintCatalog, AcceptedFieldKind, AcceptedSchemaRevision, FieldId,
2324 FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
2325 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
2326 PersistedRelationEdgeSnapshot, PersistedSchemaSnapshot, RelationId, ScalarCodec,
2327 SchemaFieldSlot, SchemaIndexId, SchemaInsertDefault, SchemaRowLayout, SchemaStore,
2328 SchemaVersion, accepted_schema_candidate_with_field_bindings_for_tests,
2329 },
2330 },
2331 error::ErrorClass,
2332 traits::{CanisterKind, Path},
2333 types::EntityTag,
2334 value::{InputValue, OutputValue},
2335 };
2336 use icydb_schema::FieldSourceKey;
2337 use std::{cell::RefCell, collections::BTreeMap};
2338
2339 const STORE_PATH: &str = "session::write::mixed_relation_batch_tests::Store";
2340 const ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node";
2341 const ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::id";
2342 const PARENT_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::parent_id";
2343 const CODE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::code";
2344 const ENTITY_NAME: &str = "MixedRelationNode";
2345 const ENTITY_TAG: EntityTag = EntityTag::new(94);
2346 const OTHER_ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other";
2347 const OTHER_ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::id";
2348 const OTHER_VALUE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::value";
2349 const OTHER_ENTITY_NAME: &str = "MixedRelationOther";
2350 const OTHER_ENTITY_TAG: EntityTag = EntityTag::new(95);
2351
2352 struct TestCanister;
2353
2354 impl Path for TestCanister {
2355 const PATH: &'static str = "session::write::mixed_relation_batch_tests::Canister";
2356 }
2357
2358 impl CanisterKind for TestCanister {
2359 const COMMIT_MEMORY_ID: u8 = 47;
2360 const COMMIT_STABLE_KEY: &'static str = "icydb.mixed_relation_batch_tests.commit.v1";
2361 const STARTUP_MEMORY_ID: u8 = 50;
2362 const STARTUP_STABLE_KEY: &'static str =
2363 "icydb.mixed_relation_batch_tests.startup.control.v1";
2364 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 48;
2365 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
2366 "icydb.mixed_relation_batch_tests.integrity.progress.v1";
2367 }
2368
2369 thread_local! {
2370 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
2371 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
2372 static SCHEMA_STORE: RefCell<SchemaStore> =
2373 const { RefCell::new(SchemaStore::init_heap()) };
2374 static STORE_REGISTRY: StoreRegistry = {
2375 let mut registry = StoreRegistry::new();
2376 registry.register_store(
2377 STORE_PATH,
2378 &DATA_STORE,
2379 &INDEX_STORE,
2380 &SCHEMA_STORE,
2381 StoreAllocationIdentities::absent(),
2382 StoreRuntimeStorageCapabilities::heap(),
2383 ).expect("mixed relation test store should register");
2384 registry
2385 };
2386 }
2387
2388 fn source_key(source: &str) -> FieldSourceKey {
2389 FieldSourceKey::try_new(source).expect("mixed relation field source should admit")
2390 }
2391
2392 fn relation_snapshot() -> PersistedSchemaSnapshot {
2393 let fields = vec![
2394 PersistedFieldSnapshot::new_initial(
2395 FieldId::new(1),
2396 "id".to_string(),
2397 SchemaFieldSlot::new(0),
2398 AcceptedFieldKind::Nat64,
2399 Vec::new(),
2400 false,
2401 SchemaInsertDefault::None,
2402 FieldStorageDecode::ByKind,
2403 LeafCodec::Scalar(ScalarCodec::Nat64),
2404 ),
2405 PersistedFieldSnapshot::new_initial(
2406 FieldId::new(2),
2407 "parent_id".to_string(),
2408 SchemaFieldSlot::new(1),
2409 AcceptedFieldKind::Nat64,
2410 Vec::new(),
2411 true,
2412 SchemaInsertDefault::None,
2413 FieldStorageDecode::ByKind,
2414 LeafCodec::Scalar(ScalarCodec::Nat64),
2415 ),
2416 PersistedFieldSnapshot::new_initial(
2417 FieldId::new(3),
2418 "code".to_string(),
2419 SchemaFieldSlot::new(2),
2420 AcceptedFieldKind::Nat64,
2421 Vec::new(),
2422 false,
2423 SchemaInsertDefault::None,
2424 FieldStorageDecode::ByKind,
2425 LeafCodec::Scalar(ScalarCodec::Nat64),
2426 ),
2427 ];
2428 let relation = PersistedRelationEdgeSnapshot::new(
2429 RelationId::new(1).expect("mixed relation identity should be non-zero"),
2430 "parent".to_string(),
2431 ENTITY_SOURCE.to_string(),
2432 vec![FieldId::new(2)],
2433 );
2434 let snapshot = PersistedSchemaSnapshot::new_with_indexes(
2435 SchemaVersion::initial(),
2436 ENTITY_SOURCE.to_string(),
2437 ENTITY_NAME.to_string(),
2438 FieldId::new(1),
2439 SchemaRowLayout::initial(
2440 fields
2441 .iter()
2442 .map(|field| (field.id(), field.slot()))
2443 .collect(),
2444 ),
2445 fields,
2446 vec![PersistedIndexSnapshot::new(
2447 SchemaIndexId::new(1).expect("mixed unique index identity should be non-zero"),
2448 1,
2449 "by_code".to_string(),
2450 STORE_PATH.to_string(),
2451 true,
2452 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
2453 FieldId::new(3),
2454 SchemaFieldSlot::new(2),
2455 vec!["code".to_string()],
2456 AcceptedFieldKind::Nat64,
2457 false,
2458 )]),
2459 None,
2460 )],
2461 )
2462 .with_relations(vec![relation]);
2463 let constraints = AcceptedConstraintCatalog::initial(
2464 snapshot.fields(),
2465 snapshot.indexes(),
2466 snapshot.relations(),
2467 )
2468 .expect("mixed relation constraints should close");
2469 snapshot.with_constraint_catalog(constraints)
2470 }
2471
2472 fn other_snapshot() -> PersistedSchemaSnapshot {
2473 let fields = vec![
2474 PersistedFieldSnapshot::new_initial(
2475 FieldId::new(1),
2476 "id".to_string(),
2477 SchemaFieldSlot::new(0),
2478 AcceptedFieldKind::Nat64,
2479 Vec::new(),
2480 false,
2481 SchemaInsertDefault::None,
2482 FieldStorageDecode::ByKind,
2483 LeafCodec::Scalar(ScalarCodec::Nat64),
2484 ),
2485 PersistedFieldSnapshot::new_initial(
2486 FieldId::new(2),
2487 "value".to_string(),
2488 SchemaFieldSlot::new(1),
2489 AcceptedFieldKind::Nat64,
2490 Vec::new(),
2491 false,
2492 SchemaInsertDefault::None,
2493 FieldStorageDecode::ByKind,
2494 LeafCodec::Scalar(ScalarCodec::Nat64),
2495 ),
2496 ];
2497 PersistedSchemaSnapshot::new(
2498 SchemaVersion::initial(),
2499 OTHER_ENTITY_SOURCE.to_string(),
2500 OTHER_ENTITY_NAME.to_string(),
2501 FieldId::new(1),
2502 SchemaRowLayout::initial(
2503 fields
2504 .iter()
2505 .map(|field| (field.id(), field.slot()))
2506 .collect(),
2507 ),
2508 fields,
2509 )
2510 }
2511
2512 fn initialize() -> DbSession<TestCanister> {
2513 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2514 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2515 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2516 let session = DbSession::<TestCanister>::new(
2517 &STORE_REGISTRY,
2518 &crate::db::RequestExecutionRoot::__new_runtime_root(),
2519 );
2520 session
2521 .db
2522 .drive_startup_recovery_page()
2523 .expect("mixed relation database should initialize");
2524 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
2525 STORE_PATH,
2526 AcceptedSchemaRevision::INITIAL,
2527 BTreeMap::from([
2528 (ENTITY_TAG, relation_snapshot()),
2529 (OTHER_ENTITY_TAG, other_snapshot()),
2530 ]),
2531 BTreeMap::from([
2532 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
2533 ((ENTITY_TAG, source_key(PARENT_SOURCE)), FieldId::new(2)),
2534 ((ENTITY_TAG, source_key(CODE_SOURCE)), FieldId::new(3)),
2535 (
2536 (OTHER_ENTITY_TAG, source_key(OTHER_ID_SOURCE)),
2537 FieldId::new(1),
2538 ),
2539 (
2540 (OTHER_ENTITY_TAG, source_key(OTHER_VALUE_SOURCE)),
2541 FieldId::new(2),
2542 ),
2543 ]),
2544 );
2545 let store = session
2546 .db
2547 .store_handle(STORE_PATH)
2548 .expect("mixed relation store should resolve");
2549 crate::db::commit::publish_accepted_schema_candidate(
2550 STORE_PATH,
2551 store,
2552 AcceptedSchemaRevision::NONE,
2553 &candidate,
2554 )
2555 .expect("mixed relation candidate should publish");
2556 session
2557 }
2558
2559 fn patch(id: Option<u64>, parent: Option<u64>, code: Option<u64>) -> DynamicStructuralPatch {
2560 let mut fields = Vec::new();
2561 if let Some(id) = id {
2562 fields.push((
2563 "id".to_string(),
2564 DynamicWriteCell::Value(InputValue::Nat64(id)),
2565 ));
2566 }
2567 fields.push((
2568 "parent_id".to_string(),
2569 parent.map_or(DynamicWriteCell::Null, |parent| {
2570 DynamicWriteCell::Value(InputValue::Nat64(parent))
2571 }),
2572 ));
2573 if let Some(code) = code {
2574 fields.push((
2575 "code".to_string(),
2576 DynamicWriteCell::Value(InputValue::Nat64(code)),
2577 ));
2578 }
2579 DynamicStructuralPatch::new(fields)
2580 }
2581
2582 fn insert(id: u64, parent: Option<u64>) -> DynamicMutation {
2583 insert_with_code(id, parent, id)
2584 }
2585
2586 fn insert_with_code(id: u64, parent: Option<u64>, code: u64) -> DynamicMutation {
2587 DynamicMutation::Insert {
2588 entity: ENTITY_NAME.to_string(),
2589 patch: patch(Some(id), parent, Some(code)),
2590 }
2591 }
2592
2593 fn update_parent(id: u64, parent: Option<u64>) -> DynamicMutation {
2594 DynamicMutation::Update {
2595 entity: ENTITY_NAME.to_string(),
2596 key: InputValue::Nat64(id),
2597 patch: patch(None, parent, None),
2598 }
2599 }
2600
2601 fn update_code(id: u64, code: u64) -> DynamicMutation {
2602 DynamicMutation::Update {
2603 entity: ENTITY_NAME.to_string(),
2604 key: InputValue::Nat64(id),
2605 patch: DynamicStructuralPatch::new(vec![(
2606 "code".to_string(),
2607 DynamicWriteCell::Value(InputValue::Nat64(code)),
2608 )]),
2609 }
2610 }
2611
2612 fn delete(id: u64) -> DynamicMutation {
2613 DynamicMutation::Delete {
2614 entity: ENTITY_NAME.to_string(),
2615 key: InputValue::Nat64(id),
2616 }
2617 }
2618
2619 fn expected_row(id: u64, parent: Option<u64>) -> Vec<OutputValue> {
2620 expected_row_with_code(id, parent, id)
2621 }
2622
2623 fn expected_row_with_code(id: u64, parent: Option<u64>, code: u64) -> Vec<OutputValue> {
2624 vec![
2625 OutputValue::Nat64(id),
2626 parent.map_or(OutputValue::Null, OutputValue::Nat64),
2627 OutputValue::Nat64(code),
2628 ]
2629 }
2630
2631 fn other_patch(id: Option<u64>, value: u64) -> DynamicStructuralPatch {
2632 let mut fields = Vec::new();
2633 if let Some(id) = id {
2634 fields.push((
2635 "id".to_string(),
2636 DynamicWriteCell::Value(InputValue::Nat64(id)),
2637 ));
2638 }
2639 fields.push((
2640 "value".to_string(),
2641 DynamicWriteCell::Value(InputValue::Nat64(value)),
2642 ));
2643 DynamicStructuralPatch::new(fields)
2644 }
2645
2646 fn assert_relation_violation(error: &crate::error::InternalError) {
2647 assert!(error.diagnostic_facts().contains(&(
2648 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
2649 icydb_diagnostic_code::DiagnosticConstraintKind::Relation.raw(),
2650 )));
2651 }
2652
2653 #[test]
2654 fn live_pages_resume_mixed_projection_from_authenticated_hidden_order_values() {
2655 let session = initialize();
2656 session
2657 .execute_trusted_dynamic_mutation_batch(vec![
2658 insert_with_code(1, None, 10),
2659 insert_with_code(2, Some(1), 20),
2660 insert_with_code(3, None, 30),
2661 ])
2662 .expect("live-page rows should insert");
2663 let query = DynamicQuery::new(ENTITY_NAME)
2664 .select(["id"])
2665 .order_by(desc("code"));
2666
2667 let first = session
2668 .execute_public_live_page(&query, None)
2669 .expect("initial live page should execute");
2670 assert_eq!(
2671 first.rows,
2672 vec![vec![OutputValue::Nat64(3)], vec![OutputValue::Nat64(2)]]
2673 );
2674 let cursor = first
2675 .continuation
2676 .as_deref()
2677 .expect("unreturned matching row should produce continuation");
2678 let second = session
2679 .execute_public_live_page(&query, Some(cursor))
2680 .expect("authenticated live continuation should resume");
2681 assert_eq!(second.rows, vec![vec![OutputValue::Nat64(1)]]);
2682 assert_eq!(second.continuation, None);
2683
2684 let total_limit = session
2685 .execute_public_live_page(&query.clone().limit(2), None)
2686 .expect("total live-page limit should execute");
2687 assert_eq!(
2688 total_limit.rows,
2689 vec![vec![OutputValue::Nat64(3)], vec![OutputValue::Nat64(2)]],
2690 );
2691 assert_eq!(
2692 total_limit.continuation, None,
2693 "query LIMIT is a total traversal window rather than a page size",
2694 );
2695
2696 let three_row_window = query.clone().limit(3);
2697 let limited_first = session
2698 .execute_public_live_page(&three_row_window, None)
2699 .expect("first total-window page should execute");
2700 let limited_cursor = limited_first
2701 .continuation
2702 .as_deref()
2703 .expect("a partially consumed total window should continue");
2704 let limited_second = session
2705 .execute_public_live_page(&three_row_window, Some(limited_cursor))
2706 .expect("remaining total window should preserve the plan signature");
2707 assert_eq!(limited_second.rows, vec![vec![OutputValue::Nat64(1)]]);
2708 assert_eq!(limited_second.continuation, None);
2709
2710 let mixed_order = DynamicQuery::new(ENTITY_NAME)
2711 .select(["id"])
2712 .order_by(desc("parent_id"))
2713 .order_by(asc("id"));
2714 let mixed_first = session
2715 .execute_trusted_live_page(&mixed_order, None)
2716 .expect("mixed-direction nullable order should execute");
2717 assert_eq!(
2718 mixed_first.rows,
2719 vec![vec![OutputValue::Nat64(2)], vec![OutputValue::Nat64(1)]],
2720 );
2721 let mixed_cursor = mixed_first
2722 .continuation
2723 .as_deref()
2724 .expect("duplicate null order values should retain continuation");
2725 let mixed_second = session
2726 .execute_trusted_live_page(&mixed_order, Some(mixed_cursor))
2727 .expect("mixed-direction nullable order should resume");
2728 assert_eq!(mixed_second.rows, vec![vec![OutputValue::Nat64(3)]]);
2729 assert_eq!(mixed_second.continuation, None);
2730
2731 let mismatched_window = session
2732 .execute_public_live_page(&query.clone().limit(3), Some(cursor))
2733 .expect_err("a changed total limit must invalidate the continuation");
2734 assert_eq!(
2735 mismatched_window.diagnostic_code(),
2736 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
2737 );
2738
2739 let mut tampered = cursor.as_bytes().to_vec();
2740 let last = tampered.len().saturating_sub(1);
2741 tampered[last] = if tampered[last] == b'0' { b'1' } else { b'0' };
2742 let tampered = String::from_utf8(tampered).expect("hex cursor should remain UTF-8");
2743 let error = session
2744 .execute_public_live_page(&query, Some(tampered.as_str()))
2745 .expect_err("tampered cursor must fail closed");
2746 assert_eq!(
2747 error.diagnostic_code(),
2748 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
2749 );
2750 }
2751
2752 #[test]
2753 fn live_pages_resume_across_changed_output_work_envelopes() {
2754 let session = initialize();
2755 session
2756 .execute_trusted_dynamic_mutation_batch(vec![
2757 insert(1, None),
2758 insert(2, None),
2759 insert(3, None),
2760 ])
2761 .expect("output-envelope rows should insert");
2762 let query = DynamicQuery::new(ENTITY_NAME)
2763 .select(["id"])
2764 .order_by(desc("code"));
2765 let first = session
2766 .execute_trusted_live_page_with_result_bytes_limit_for_tests(&query, None, 32)
2767 .expect("small output envelope should publish the first bounded page");
2768 assert_eq!(first.rows, vec![vec![OutputValue::Nat64(3)]]);
2769 let continuation = first
2770 .continuation
2771 .expect("small output envelope should leave authenticated progress");
2772
2773 let second = session
2774 .execute_trusted_live_page_with_result_bytes_limit_for_tests(
2775 &query,
2776 Some(continuation.as_str()),
2777 64,
2778 )
2779 .unwrap_or_else(|error| {
2780 panic!(
2781 "larger output envelope should resume the same query: {error:?}, facts={:?}",
2782 error.diagnostic_facts(),
2783 )
2784 });
2785 assert_eq!(
2786 second.rows,
2787 vec![vec![OutputValue::Nat64(2)], vec![OutputValue::Nat64(1)]]
2788 );
2789 let second_continuation = second
2790 .continuation
2791 .as_deref()
2792 .expect("an exact-full page still needs to prove physical exhaustion");
2793 assert_ne!(first.work.envelope_identity, second.work.envelope_identity);
2794
2795 let terminal = session
2796 .execute_trusted_live_page_with_result_bytes_limit_for_tests(
2797 &query,
2798 Some(second_continuation),
2799 48,
2800 )
2801 .expect("a third finite envelope should prove exhaustion without replaying rows");
2802 assert!(terminal.rows.is_empty());
2803 assert_eq!(terminal.continuation, None);
2804 assert_ne!(
2805 second.work.envelope_identity,
2806 terminal.work.envelope_identity
2807 );
2808
2809 assert_eq!(
2810 [first.rows, second.rows, terminal.rows].concat(),
2811 vec![
2812 vec![OutputValue::Nat64(3)],
2813 vec![OutputValue::Nat64(2)],
2814 vec![OutputValue::Nat64(1)],
2815 ]
2816 );
2817 }
2818
2819 #[test]
2820 fn distinct_live_pages_resume_adjacent_groups_and_global_replay_end_to_end() {
2821 let session = initialize();
2822 session
2823 .execute_trusted_dynamic_mutation_batch(vec![
2824 insert(1, None),
2825 insert(2, None),
2826 insert(3, Some(1)),
2827 insert(4, Some(2)),
2828 insert(5, Some(1)),
2829 insert(6, Some(3)),
2830 insert(7, Some(2)),
2831 ])
2832 .expect("DISTINCT continuation rows should insert atomically");
2833
2834 let adjacent = DynamicQuery::new(ENTITY_NAME)
2835 .select(["parent_id"])
2836 .order_by(asc("parent_id"))
2837 .order_by(asc("id"))
2838 .distinct_for_internal_execution();
2839 let global = DynamicQuery::new(ENTITY_NAME)
2840 .select(["parent_id"])
2841 .order_by(asc("id"))
2842 .distinct_for_internal_execution();
2843
2844 let traverse = |query: &DynamicQuery, strategy: &str| {
2845 let mut continuation = None;
2846 let mut rows = Vec::new();
2847 let mut cursors = std::collections::BTreeSet::new();
2848 let mut pages = 0_u32;
2849 let mut entries_visited = 0_u64;
2850 loop {
2851 let page = session
2852 .execute_trusted_live_page(query, continuation.as_deref())
2853 .unwrap_or_else(|error| {
2854 panic!("{strategy} DISTINCT page should execute: {error:?}")
2855 });
2856 pages = pages.saturating_add(1);
2857 entries_visited = entries_visited.saturating_add(page.work.entries_visited);
2858 assert_eq!(page.row_count as usize, page.rows.len());
2859 assert_eq!(page.work.result_rows, page.row_count);
2860 rows.extend(page.rows);
2861 let Some(cursor) = page.continuation else {
2862 break;
2863 };
2864 assert!(
2865 cursors.insert(cursor.clone()),
2866 "{strategy} DISTINCT continuation must advance monotonically",
2867 );
2868 continuation = Some(cursor);
2869 assert!(pages < 8, "{strategy} DISTINCT traversal must terminate");
2870 }
2871
2872 (rows, pages, entries_visited)
2873 };
2874
2875 let expected = vec![
2876 vec![OutputValue::Null],
2877 vec![OutputValue::Nat64(1)],
2878 vec![OutputValue::Nat64(2)],
2879 vec![OutputValue::Nat64(3)],
2880 ];
2881 let (adjacent_rows, adjacent_pages, adjacent_entries) = traverse(&adjacent, "adjacent");
2882 let (global_rows, global_pages, global_entries) = traverse(&global, "global");
2883
2884 assert_eq!(adjacent_rows, expected);
2885 assert_eq!(global_rows, expected);
2886 assert_eq!(adjacent_pages, 2);
2887 assert_eq!(global_pages, 2);
2888 assert!(adjacent_entries > 0);
2889 assert!(global_entries > 0);
2890 }
2891
2892 #[test]
2893 fn selective_live_pages_publish_monotonic_empty_physical_progress() {
2894 let session = initialize();
2895 session
2896 .execute_trusted_dynamic_mutation_batch(
2897 (1..=9)
2898 .map(|id| {
2899 let parent = match id {
2900 1 => Some(2),
2901 9 => Some(1),
2902 _ => None,
2903 };
2904 insert(id, parent)
2905 })
2906 .collect(),
2907 )
2908 .expect("selective live-page rows should insert");
2909 let query = DynamicQuery::new(ENTITY_NAME)
2910 .select(["id"])
2911 .filter(FilterExpr::eq("parent_id", 1_u64))
2912 .order_by(asc("id"))
2913 .limit(1);
2914
2915 let first = session
2916 .execute_trusted_live_page(&query, None)
2917 .expect("first selective page should stop with physical progress");
2918 assert!(first.rows.is_empty());
2919 assert_eq!(first.work.entries_visited, 4);
2920 let first_cursor = first
2921 .continuation
2922 .expect("filtered physical progress must return a continuation");
2923
2924 let second = session
2925 .execute_trusted_live_page(&query, Some(first_cursor.as_str()))
2926 .expect("second selective page should resume after the first physical frontier");
2927 assert!(second.rows.is_empty());
2928 assert_eq!(second.work.entries_visited, 4);
2929 let second_cursor = second
2930 .continuation
2931 .expect("second filtered frontier must remain resumable");
2932 assert_ne!(second_cursor, first_cursor);
2933
2934 let third = session
2935 .execute_trusted_live_page(&query, Some(second_cursor.as_str()))
2936 .expect("final selective page should return the late match");
2937 assert_eq!(third.rows, vec![vec![OutputValue::Nat64(9)]]);
2938 assert_eq!(third.work.entries_visited, 1);
2939 assert_eq!(third.continuation, None);
2940
2941 let descending = DynamicQuery::new(ENTITY_NAME)
2942 .select(["id"])
2943 .filter(FilterExpr::eq("parent_id", 2_u64))
2944 .order_by(desc("id"))
2945 .limit(1);
2946 let descending_first = session
2947 .execute_trusted_live_page(&descending, None)
2948 .expect("descending selective page should stop with physical progress");
2949 assert!(descending_first.rows.is_empty());
2950 let descending_first_cursor = descending_first
2951 .continuation
2952 .expect("descending filtered progress must return a continuation");
2953 let descending_second = session
2954 .execute_trusted_live_page(&descending, Some(descending_first_cursor.as_str()))
2955 .expect("descending progress should resume after its physical frontier");
2956 assert!(descending_second.rows.is_empty());
2957 let descending_second_cursor = descending_second
2958 .continuation
2959 .expect("descending second frontier must remain resumable");
2960 assert_ne!(descending_second_cursor, descending_first_cursor);
2961 let descending_third = session
2962 .execute_trusted_live_page(&descending, Some(descending_second_cursor.as_str()))
2963 .expect("descending final page should return the late match");
2964 assert_eq!(descending_third.rows, vec![vec![OutputValue::Nat64(1)]]);
2965 assert_eq!(descending_third.continuation, None);
2966 }
2967
2968 #[test]
2969 fn accepted_relation_edges_drive_catalog_and_describe_introspection() {
2970 let session = initialize();
2971 let entities = session
2972 .show_entities()
2973 .expect("accepted entity catalog should resolve");
2974 let source = entities
2975 .iter()
2976 .find(|entity| entity.entity_name() == ENTITY_NAME)
2977 .expect("relation source should be listed");
2978 assert_eq!(source.relations(), 1);
2979
2980 let description = session
2981 .try_describe_entity_by_name(ENTITY_NAME)
2982 .expect("accepted relation source should describe");
2983 let [relation] = description.relations() else {
2984 panic!("accepted relation edge should produce one relation row");
2985 };
2986 assert_eq!(relation.field(), "parent_id");
2987 assert_eq!(relation.target_path(), ENTITY_SOURCE);
2988 assert_eq!(relation.target_entity_name(), ENTITY_NAME);
2989 assert_eq!(relation.target_store_path(), STORE_PATH);
2990 assert_eq!(
2991 relation.cardinality(),
2992 crate::db::EntityRelationCardinality::Single,
2993 );
2994 }
2995
2996 #[test]
2997 fn mixed_relation_validation_uses_the_complete_final_row_overlay() {
2998 let session = initialize();
2999 session
3000 .execute_trusted_dynamic_mutation_batch(vec![insert(1, None), insert(2, Some(1))])
3001 .expect("the initial relation should commit");
3002
3003 let blocked = session
3004 .execute_trusted_dynamic_mutation(&delete(1))
3005 .expect_err("an unaffected committed source must block target deletion");
3006 assert_relation_violation(&blocked);
3007
3008 let deleted = session
3009 .execute_trusted_dynamic_mutation_batch(vec![delete(2), delete(1)])
3010 .expect("a source and its target should delete atomically");
3011 assert_eq!(
3012 deleted.rows,
3013 vec![expected_row(2, Some(1)), expected_row(1, None)],
3014 );
3015
3016 session
3017 .execute_trusted_dynamic_mutation_batch(vec![insert(3, None), insert(4, Some(3))])
3018 .expect("the update-away fixture should commit");
3019 let updated_away = session
3020 .execute_trusted_dynamic_mutation_batch(vec![update_parent(4, None), delete(3)])
3021 .expect("an updated final source may release a deleted target");
3022 assert_eq!(
3023 updated_away.rows,
3024 vec![expected_row(4, None), expected_row(3, None)],
3025 );
3026
3027 session
3028 .execute_trusted_dynamic_mutation_batch(vec![insert(5, None), insert(6, Some(5))])
3029 .expect("the retained-reference fixture should commit");
3030 let retained = session
3031 .execute_trusted_dynamic_mutation_batch(vec![update_parent(6, Some(5)), delete(5)])
3032 .expect_err("a final updated source must still block target deletion");
3033 assert_relation_violation(&retained);
3034
3035 session
3036 .execute_trusted_dynamic_mutation(&insert(7, None))
3037 .expect("the inserted-reference fixture target should commit");
3038 let inserted_reference = session
3039 .execute_trusted_dynamic_mutation_batch(vec![insert(8, Some(7)), delete(7)])
3040 .expect_err("a final inserted source must not reference a deleted target");
3041 assert_relation_violation(&inserted_reference);
3042
3043 let inserted_target = session
3044 .execute_trusted_dynamic_mutation_batch(vec![insert(10, Some(9)), insert(9, None)])
3045 .expect("an inserted relation should see its batch-final target");
3046 assert_eq!(
3047 inserted_target.rows,
3048 vec![expected_row(10, Some(9)), expected_row(9, None)],
3049 );
3050
3051 session
3052 .execute_trusted_dynamic_mutation(&insert(11, None))
3053 .expect("the updated-reference fixture source should commit");
3054 let updated_target = session
3055 .execute_trusted_dynamic_mutation_batch(vec![
3056 update_parent(11, Some(12)),
3057 insert(12, None),
3058 ])
3059 .expect("an updated relation should see its batch-final target");
3060 assert_eq!(
3061 updated_target.rows,
3062 vec![expected_row(11, Some(12)), expected_row(12, None)],
3063 );
3064 }
3065
3066 #[test]
3067 fn mixed_batch_rejects_cross_entity_missing_and_collision_then_honors_replace() {
3068 let session = initialize();
3069 session
3070 .execute_trusted_dynamic_mutation(&insert(1, None))
3071 .expect("the primary mixed fixture row should commit");
3072 session
3073 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
3074 entity: OTHER_ENTITY_NAME.to_string(),
3075 patch: other_patch(Some(1), 10),
3076 })
3077 .expect("the secondary mixed fixture row should commit");
3078
3079 let mixed_entity = session
3080 .execute_trusted_dynamic_mutation_batch(vec![
3081 update_code(1, 11),
3082 DynamicMutation::Update {
3083 entity: OTHER_ENTITY_NAME.to_string(),
3084 key: InputValue::Nat64(1),
3085 patch: other_patch(None, 11),
3086 },
3087 ])
3088 .expect_err("one atomic batch must not cross accepted entities");
3089 assert_eq!(mixed_entity.class(), ErrorClass::Conflict);
3090 assert_eq!(
3091 mixed_entity.diagnostic_facts(),
3092 vec![
3093 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1,),
3094 (
3095 icydb_diagnostic_code::DiagnosticFactTag::ExpectedEntityTag,
3096 ENTITY_TAG.value(),
3097 ),
3098 (
3099 icydb_diagnostic_code::DiagnosticFactTag::ActualEntityTag,
3100 OTHER_ENTITY_TAG.value(),
3101 ),
3102 ],
3103 );
3104
3105 let missing = session
3106 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 12), delete(99)])
3107 .expect_err("a late missing delete must reject the earlier staged update");
3108 assert_eq!(missing.class(), ErrorClass::NotFound);
3109
3110 session
3111 .execute_trusted_dynamic_mutation(&insert(2, None))
3112 .expect("the collision fixture should commit");
3113 let collision = session
3114 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 13), insert(2, None)])
3115 .expect_err("an insert collision must reject the earlier staged update");
3116 assert_eq!(collision.class(), ErrorClass::Conflict);
3117 let failures_unchanged = session
3118 .execute_trusted_dynamic_mutation(&update_code(1, 1))
3119 .expect("failed batches must preserve the original unique value");
3120 assert_eq!(failures_unchanged.affected_rows, 0);
3121
3122 let replaced = session
3123 .execute_trusted_dynamic_mutation_batch(vec![
3124 update_code(1, 14),
3125 DynamicMutation::Replace {
3126 entity: ENTITY_NAME.to_string(),
3127 key: InputValue::Nat64(99),
3128 patch: patch(None, None, Some(99)),
3129 },
3130 ])
3131 .expect("ordinary caller-key replace should insert its absent final row");
3132 assert_eq!(
3133 replaced.rows,
3134 vec![
3135 expected_row_with_code(1, None, 14),
3136 expected_row_with_code(99, None, 99),
3137 ],
3138 );
3139
3140 let unchanged = session
3141 .execute_trusted_dynamic_mutation(&update_code(1, 14))
3142 .expect("the successful mixed replace must publish its preceding update");
3143 assert_eq!(unchanged.affected_rows, 0);
3144 let other_unchanged = session
3145 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
3146 entity: OTHER_ENTITY_NAME.to_string(),
3147 key: InputValue::Nat64(1),
3148 patch: other_patch(None, 10),
3149 })
3150 .expect("cross-entity rejection must preserve the secondary row");
3151 assert_eq!(other_unchanged.affected_rows, 0);
3152 }
3153
3154 #[test]
3155 fn mixed_batch_unique_swap_and_delete_release_use_the_final_overlay() {
3156 let session = initialize();
3157 session
3158 .execute_trusted_dynamic_mutation_batch(vec![
3159 insert_with_code(1, None, 10),
3160 insert_with_code(2, None, 20),
3161 ])
3162 .expect("the unique-overlay fixture should commit");
3163
3164 let swapped = session
3165 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 20), update_code(2, 10)])
3166 .expect("two final rows should atomically swap unique memberships");
3167 assert_eq!(
3168 swapped.rows,
3169 vec![
3170 expected_row_with_code(1, None, 20),
3171 expected_row_with_code(2, None, 10),
3172 ],
3173 );
3174
3175 let released = session
3176 .execute_trusted_dynamic_mutation_batch(vec![delete(1), insert_with_code(3, None, 20)])
3177 .expect("a delete should release unique membership to a final inserted row");
3178 assert_eq!(
3179 released.rows,
3180 vec![
3181 expected_row_with_code(1, None, 20),
3182 expected_row_with_code(3, None, 20),
3183 ],
3184 );
3185 }
3186}
3187
3188#[cfg(test)]
3189mod identity_pre_key_tests {
3190 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3191 use super::DynamicTypedEntityBinding;
3192 use super::{
3193 AcceptedMutationIntentPatch, AcceptedRowLayoutRuntimeContract, AcceptedStructuralMutation,
3194 AcceptedStructuralMutationPacking, AcceptedStructuralMutationStagedAdmission,
3195 AcceptedStructuralMutationTarget, DbSession, DynamicMutation, DynamicStructuralPatch,
3196 DynamicTypedFieldBindingRequest, DynamicTypedFieldType, DynamicTypedMutation,
3197 DynamicWriteCell, FieldSlot, MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
3198 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
3199 MutationProgressRecordOp, add_structural_mutation_staged_bytes,
3200 admit_structural_mutation_staged_charge, checked_pre_key_candidate_count,
3201 insert_key_exists_after_generation, structural_mutation_staged_charge,
3202 validate_structural_mutation_result_bytes,
3203 };
3204 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3205 use crate::db::data::DecodedDataStoreKey;
3206 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3207 use crate::db::executor::budget::{
3208 HardExecutionBudget, HardExecutionContext, HardExecutionFailureHeadroom,
3209 with_query_execution_budget_for_tests,
3210 };
3211 use crate::db::mutation_job::{MutationJobRecord, MutationJobTransition};
3212 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3213 use crate::db::{
3214 CompareProofAndAdvanceError, DynamicQuery, ExhaustiveReadError, RawDataStoreKey,
3215 ReadSetRevisionError, ResumableJobAdvance, ResumableJobAdvanceRequest,
3216 ResumableJobAdvanceStatus, ResumableJobError, ResumableJobId, ResumableJobIdempotencyKey,
3217 ResumableJobStatus, asc,
3218 };
3219 use crate::{
3220 db::{
3221 GeneratedStartupDriverStep, MutationJobAdvanceRequest, MutationJobId,
3222 MutationJobIdempotencyKey, MutationJobPhase, MutationJobStatus,
3223 commit::{
3224 database_incarnation_id, forget_recovered_domain_for_tests,
3225 install_startup_recovery_wakeup,
3226 },
3227 data::DataStore,
3228 drive_generated_startup_recovery_page,
3229 executor::{MutationCommitInterruption, interrupt_next_mutation_commit_for_tests},
3230 index::{IndexId, IndexKey, IndexKeyKind, IndexStore, IndexStoreVisit},
3231 integrity::{
3232 InsertMutationJobResult, PhysicalUnitCheckpoint, QuickIntegrityStatus,
3233 RowInspectionLimits, execute_quick_integrity, execute_row_integrity_page,
3234 with_mutation_progress_store,
3235 },
3236 journal::{
3237 JournalBatch, JournalRecord, JournalSequence, JournalTailControl, JournalTailStore,
3238 encode_journal_batch,
3239 },
3240 registry::{
3241 StoreAllocationIdentities, StoreAllocationIdentity, StoreHandle, StoreRegistry,
3242 StoreRuntimeStorageCapabilities,
3243 },
3244 schema::{
3245 AcceptedFieldKind, AcceptedSchemaRevision, FieldId, FieldInsertGeneration,
3246 FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
3247 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
3248 PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy,
3249 SchemaIndexId, SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
3250 accepted_schema_candidate_with_field_bindings_for_tests,
3251 cardinality_build::{
3252 CardinalityBuildAuthority, CardinalityGenerationPageOutcome,
3253 drive_cardinality_generation_page,
3254 },
3255 cardinality_generation::{CardinalityGenerationHeader, CardinalityGenerationState},
3256 },
3257 write_context::MutationMode,
3258 },
3259 error::{ErrorClass, ErrorOrigin, InternalError},
3260 testing::test_memory,
3261 traits::{CanisterKind, Path},
3262 types::{EntityTag, Timestamp},
3263 value::{InputValue, OutputValue, Value},
3264 };
3265 use icydb_schema::{FieldSourceKey, ScalarType};
3266 use std::{
3267 cell::{Cell, RefCell},
3268 collections::BTreeMap,
3269 time::Instant,
3270 };
3271
3272 const STORE_PATH: &str = "session::write::identity_pre_key_tests::Store";
3273 const ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::Entity";
3274 const ID_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::id";
3275 const PAYLOAD_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::payload";
3276 const ENTITY_NAME: &str = "IdentityRow";
3277 const ENTITY_TAG: EntityTag = EntityTag::new(93);
3278 const JOURNALED_STORE_PATH: &str = "session::write::identity_pre_key_tests::JournaledStore";
3279 const UNRELATED_STORE_PATH: &str = "session::write::identity_pre_key_tests::UnrelatedStore";
3280
3281 struct TestCanister;
3282
3283 impl Path for TestCanister {
3284 const PATH: &'static str = "session::write::identity_pre_key_tests::Canister";
3285 }
3286
3287 impl CanisterKind for TestCanister {
3288 const COMMIT_MEMORY_ID: u8 = 45;
3289 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.commit.v1";
3290 const STARTUP_MEMORY_ID: u8 = 49;
3291 const STARTUP_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.startup.control.v1";
3292 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 46;
3293 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3294 "icydb.identity_pre_key_tests.integrity.progress.v1";
3295 }
3296
3297 thread_local! {
3298 static STARTUP_WAKEUPS: Cell<u32> = const { Cell::new(0) };
3299 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
3300 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
3301 static SCHEMA_STORE: RefCell<SchemaStore> =
3302 const { RefCell::new(SchemaStore::init_heap()) };
3303 static UNRELATED_DATA_STORE: RefCell<DataStore> =
3304 const { RefCell::new(DataStore::init_heap()) };
3305 static UNRELATED_INDEX_STORE: RefCell<IndexStore> =
3306 const { RefCell::new(IndexStore::init_heap()) };
3307 static UNRELATED_SCHEMA_STORE: RefCell<SchemaStore> =
3308 const { RefCell::new(SchemaStore::init_heap()) };
3309 static STORE_REGISTRY: StoreRegistry = {
3310 let mut registry = StoreRegistry::new();
3311 registry.register_store(
3312 STORE_PATH,
3313 &DATA_STORE,
3314 &INDEX_STORE,
3315 &SCHEMA_STORE,
3316 StoreAllocationIdentities::absent(),
3317 StoreRuntimeStorageCapabilities::heap(),
3318 ).expect("identity pre-key test store should register");
3319 registry.register_store(
3320 UNRELATED_STORE_PATH,
3321 &UNRELATED_DATA_STORE,
3322 &UNRELATED_INDEX_STORE,
3323 &UNRELATED_SCHEMA_STORE,
3324 StoreAllocationIdentities::absent(),
3325 StoreRuntimeStorageCapabilities::heap(),
3326 ).expect("unrelated identity test store should register");
3327 registry
3328 };
3329 static JOURNALED_DATA_STORE: RefCell<DataStore> =
3330 RefCell::new(DataStore::init_journaled(test_memory(186)));
3331 static JOURNALED_INDEX_STORE: RefCell<IndexStore> =
3332 RefCell::new(IndexStore::init_journaled(test_memory(187)));
3333 static JOURNALED_SCHEMA_STORE: RefCell<SchemaStore> =
3334 RefCell::new(SchemaStore::init_journaled(test_memory(188)));
3335 static JOURNALED_TAIL_STORE: RefCell<JournalTailStore> =
3336 RefCell::new(JournalTailStore::init(test_memory(189)));
3337 static JOURNALED_STORE_REGISTRY: StoreRegistry = {
3338 let mut registry = StoreRegistry::new();
3339 registry.register_journaled_store(
3340 JOURNALED_STORE_PATH,
3341 &JOURNALED_DATA_STORE,
3342 &JOURNALED_INDEX_STORE,
3343 &JOURNALED_SCHEMA_STORE,
3344 &JOURNALED_TAIL_STORE,
3345 StoreAllocationIdentities::new_journaled(
3346 StoreAllocationIdentity::new(186, "icydb.test.identity_range.data.v1"),
3347 StoreAllocationIdentity::new(187, "icydb.test.identity_range.index.v1"),
3348 StoreAllocationIdentity::new(188, "icydb.test.identity_range.schema.v1"),
3349 StoreAllocationIdentity::new(189, "icydb.test.identity_range.journal.v1"),
3350 ),
3351 StoreRuntimeStorageCapabilities::journaled(),
3352 ).expect("identity range journaled store should register");
3353 registry
3354 };
3355 }
3356
3357 fn record_startup_wakeup() {
3358 STARTUP_WAKEUPS.with(|wakeups| wakeups.set(wakeups.get().saturating_add(1)));
3359 }
3360
3361 struct JournaledTestCanister;
3362
3363 impl Path for JournaledTestCanister {
3364 const PATH: &'static str = "session::write::identity_pre_key_tests::JournaledCanister";
3365 }
3366
3367 impl CanisterKind for JournaledTestCanister {
3368 const COMMIT_MEMORY_ID: u8 = 190;
3369 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_range_tests.commit.v1";
3370 const STARTUP_MEMORY_ID: u8 = 192;
3371 const STARTUP_STABLE_KEY: &'static str = "icydb.identity_range_tests.startup.control.v1";
3372 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 191;
3373 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3374 "icydb.identity_range_tests.integrity.progress.v1";
3375 }
3376
3377 fn source_key(source: &str) -> FieldSourceKey {
3378 FieldSourceKey::try_new(source).expect("identity test field source should admit")
3379 }
3380
3381 fn identity_snapshot(store_path: &str, payload_unique: bool) -> PersistedSchemaSnapshot {
3382 let fields = vec![
3383 PersistedFieldSnapshot::new_initial_with_write_policy(
3384 FieldId::new(1),
3385 "id".to_string(),
3386 SchemaFieldSlot::new(0),
3387 AcceptedFieldKind::Nat64,
3388 Vec::new(),
3389 false,
3390 SchemaInsertDefault::None,
3391 SchemaFieldWritePolicy::from_model_policies(
3392 Some(FieldInsertGeneration::Identity),
3393 None,
3394 ),
3395 FieldStorageDecode::ByKind,
3396 LeafCodec::Scalar(ScalarCodec::Nat64),
3397 ),
3398 PersistedFieldSnapshot::new_initial(
3399 FieldId::new(2),
3400 "payload".to_string(),
3401 SchemaFieldSlot::new(1),
3402 AcceptedFieldKind::Nat64,
3403 Vec::new(),
3404 false,
3405 SchemaInsertDefault::None,
3406 FieldStorageDecode::ByKind,
3407 LeafCodec::Scalar(ScalarCodec::Nat64),
3408 ),
3409 ];
3410 PersistedSchemaSnapshot::new_with_indexes(
3411 SchemaVersion::initial(),
3412 ENTITY_SOURCE.to_string(),
3413 ENTITY_NAME.to_string(),
3414 FieldId::new(1),
3415 SchemaRowLayout::initial(
3416 fields
3417 .iter()
3418 .map(|field| (field.id(), field.slot()))
3419 .collect(),
3420 ),
3421 fields,
3422 vec![PersistedIndexSnapshot::new(
3423 SchemaIndexId::new(1).expect("identity test index ID should admit"),
3424 1,
3425 "by_payload".to_string(),
3426 store_path.to_string(),
3427 payload_unique,
3428 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
3429 FieldId::new(2),
3430 SchemaFieldSlot::new(1),
3431 vec!["payload".to_string()],
3432 AcceptedFieldKind::Nat64,
3433 false,
3434 )]),
3435 None,
3436 )],
3437 )
3438 }
3439
3440 fn initialize() -> DbSession<TestCanister> {
3441 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3442 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3443 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3444 UNRELATED_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3445 UNRELATED_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3446 UNRELATED_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3447 let session = DbSession::<TestCanister>::new(
3448 &STORE_REGISTRY,
3449 &crate::db::RequestExecutionRoot::__new_runtime_root(),
3450 );
3451 session
3452 .db
3453 .drive_startup_recovery_page()
3454 .expect("identity pre-key test database should initialize");
3455 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3456 STORE_PATH,
3457 AcceptedSchemaRevision::INITIAL,
3458 BTreeMap::from([(ENTITY_TAG, identity_snapshot(STORE_PATH, false))]),
3459 BTreeMap::from([
3460 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
3461 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
3462 ]),
3463 );
3464 let store = session
3465 .db
3466 .store_handle(STORE_PATH)
3467 .expect("identity pre-key test store should resolve");
3468 crate::db::commit::publish_accepted_schema_candidate(
3469 STORE_PATH,
3470 store,
3471 AcceptedSchemaRevision::NONE,
3472 &candidate,
3473 )
3474 .expect("identity candidate should publish with explicit zero state");
3475 session
3476 }
3477
3478 fn initialize_journaled_with_root_and_payload_uniqueness(
3479 payload_unique: bool,
3480 ) -> (
3481 DbSession<JournaledTestCanister>,
3482 crate::db::RequestExecutionRoot,
3483 ) {
3484 let root = crate::db::RequestExecutionRoot::__new_runtime_root();
3485 let session = DbSession::<JournaledTestCanister>::new(&JOURNALED_STORE_REGISTRY, &root);
3486 session
3487 .db
3488 .drive_startup_recovery_page()
3489 .expect("journaled identity database should initialize");
3490 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3491 JOURNALED_STORE_PATH,
3492 AcceptedSchemaRevision::INITIAL,
3493 BTreeMap::from([(
3494 ENTITY_TAG,
3495 identity_snapshot(JOURNALED_STORE_PATH, payload_unique),
3496 )]),
3497 BTreeMap::from([
3498 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
3499 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
3500 ]),
3501 );
3502 let store = session
3503 .db
3504 .store_handle(JOURNALED_STORE_PATH)
3505 .expect("journaled identity store should resolve");
3506 crate::db::commit::publish_accepted_schema_candidate(
3507 JOURNALED_STORE_PATH,
3508 store,
3509 AcceptedSchemaRevision::NONE,
3510 &candidate,
3511 )
3512 .expect("journaled identity candidate should publish");
3513 (session, root)
3514 }
3515
3516 fn initialize_journaled_with_root() -> (
3517 DbSession<JournaledTestCanister>,
3518 crate::db::RequestExecutionRoot,
3519 ) {
3520 initialize_journaled_with_root_and_payload_uniqueness(false)
3521 }
3522
3523 fn initialize_journaled() -> DbSession<JournaledTestCanister> {
3524 initialize_journaled_with_root().0
3525 }
3526
3527 fn initialize_journaled_with_unique_payload() -> DbSession<JournaledTestCanister> {
3528 initialize_journaled_with_root_and_payload_uniqueness(true).0
3529 }
3530
3531 fn drive_journaled_recovery_to_completion(session: &DbSession<JournaledTestCanister>) {
3532 for _ in 0..8 {
3533 if session
3534 .db
3535 .drive_startup_recovery_page()
3536 .expect("dedicated driver recovery should remain valid")
3537 {
3538 return;
3539 }
3540 }
3541 panic!("dedicated driver recovery should quiesce within eight complete batches");
3542 }
3543
3544 fn drive_journaled_cardinality_to_ready(session: &DbSession<JournaledTestCanister>) {
3545 let handle = session
3546 .db
3547 .store_handle(JOURNALED_STORE_PATH)
3548 .expect("journaled cardinality store should resolve");
3549 for _ in 0..8 {
3550 let outcome = handle
3551 .with_data(|data| {
3552 handle.with_index(|index| {
3553 handle.with_schema_mut(|schema| {
3554 drive_cardinality_generation_page(data, index, schema, |schema| {
3555 let watermark = JOURNALED_TAIL_STORE
3556 .with(|tail| tail.borrow().fold_watermark())?;
3557 CardinalityBuildAuthority::derive(
3558 schema,
3559 database_incarnation_id()?,
3560 handle.allocation_identities(),
3561 watermark,
3562 )
3563 })
3564 })
3565 })
3566 })
3567 .expect("bounded cardinality generation should advance");
3568 if outcome == CardinalityGenerationPageOutcome::Quiescent {
3569 return;
3570 }
3571 }
3572 panic!("cardinality generation should become Ready within eight bounded pages");
3573 }
3574
3575 fn journaled_user_index_prefix() -> (IndexId, Vec<Vec<u8>>) {
3576 JOURNALED_INDEX_STORE.with(|store| {
3577 let mut selected = None;
3578 store
3579 .borrow()
3580 .visit_entries(|raw_key, _value| {
3581 let key = IndexKey::try_from_raw(raw_key)
3582 .expect("accepted user index key should decode");
3583 if key.key_kind() != IndexKeyKind::User {
3584 return Ok::<_, InternalError>(IndexStoreVisit::Continue);
3585 }
3586 let components = (0..key.component_count())
3587 .map(|index| {
3588 key.component(index)
3589 .expect("accepted index component should exist")
3590 .to_vec()
3591 })
3592 .collect::<Vec<_>>();
3593 selected = Some((*key.index_id(), components));
3594 Ok(IndexStoreVisit::Stop)
3595 })
3596 .expect("accepted user index should be inspectable");
3597 selected.expect("the cardinality fixture should contain one user index entry")
3598 })
3599 }
3600
3601 fn reset_journaled_cardinality_projections() -> u64 {
3602 JOURNALED_DATA_STORE.with(|store| {
3603 store
3604 .borrow_mut()
3605 .reset_journaled_live_projection()
3606 .expect("row projection should reset without a count scan");
3607 });
3608 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
3609 let fold_watermark = JOURNALED_TAIL_STORE
3610 .with(|store| store.borrow().fold_watermark())
3611 .expect("journal watermark should remain current-form");
3612 JOURNALED_INDEX_STORE.with(|store| {
3613 store
3614 .borrow_mut()
3615 .reset_journaled_live_projection(data_generation, fold_watermark)
3616 .expect("index projection should reset without a count scan");
3617 });
3618 data_generation
3619 }
3620
3621 fn assert_journaled_cardinality(
3622 handle: StoreHandle,
3623 index_id: IndexId,
3624 prefix_components: &[Vec<u8>],
3625 expected: u64,
3626 ) {
3627 assert_eq!(handle.exact_entity_count(ENTITY_TAG), Some(expected));
3628 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
3629 assert_eq!(
3630 handle.exact_user_index_prefix_count(
3631 data_generation,
3632 IndexKeyKind::User,
3633 index_id,
3634 prefix_components,
3635 ),
3636 Some(expected),
3637 );
3638 }
3639
3640 fn mark_journaled_cardinality_building() {
3641 let current = JOURNALED_SCHEMA_STORE.with(|store| {
3642 store
3643 .borrow()
3644 .cardinality_generation_header()
3645 .expect("Ready header should decode")
3646 .expect("Ready header should exist")
3647 });
3648 JOURNALED_SCHEMA_STORE.with(|store| {
3649 store
3650 .borrow_mut()
3651 .write_cardinality_generation_header(CardinalityGenerationHeader::new(
3652 current.generation(),
3653 CardinalityGenerationState::Building,
3654 current.slot(),
3655 current.source(),
3656 ))
3657 .expect("Building fallback fixture should persist");
3658 });
3659 }
3660
3661 fn payload_patch(value: u64) -> AcceptedMutationIntentPatch {
3662 AcceptedMutationIntentPatch::new()
3663 .set_authored(FieldSlot::from_validated_index(1), InputValue::Nat64(value))
3664 }
3665
3666 fn dynamic_payload_patch(value: u64) -> DynamicStructuralPatch {
3667 DynamicStructuralPatch::new(vec![(
3668 "payload".to_string(),
3669 DynamicWriteCell::Value(InputValue::Nat64(value)),
3670 )])
3671 }
3672
3673 fn expected_dynamic_row(id: u64, payload: u64) -> Vec<OutputValue> {
3674 vec![OutputValue::Nat64(id), OutputValue::Nat64(payload)]
3675 }
3676
3677 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3678 fn exact_key_binding<C: CanisterKind>(session: &DbSession<C>) -> DynamicTypedEntityBinding {
3679 session
3680 .issue_typed_entity_binding(
3681 ENTITY_SOURCE,
3682 &[
3683 DynamicTypedFieldBindingRequest::new(
3684 ID_SOURCE.to_string(),
3685 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3686 false,
3687 ),
3688 DynamicTypedFieldBindingRequest::new(
3689 PAYLOAD_SOURCE.to_string(),
3690 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3691 false,
3692 ),
3693 ],
3694 )
3695 .expect("exact-key test binding should issue")
3696 }
3697
3698 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3699 fn insert_exact_key_fixture<C: CanisterKind>(session: &DbSession<C>, payload: u64) -> u64 {
3700 let output = session
3701 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
3702 entity: ENTITY_NAME.to_string(),
3703 patch: dynamic_payload_patch(payload),
3704 })
3705 .expect("exact-key fixture insert should commit");
3706 match output.rows.as_slice() {
3707 [row] => match row.as_slice() {
3708 [OutputValue::Nat64(id), OutputValue::Nat64(actual_payload)]
3709 if *actual_payload == payload =>
3710 {
3711 *id
3712 }
3713 _ => panic!("exact-key fixture should return its identity and payload"),
3714 },
3715 _ => panic!("exact-key fixture insert should return one row"),
3716 }
3717 }
3718
3719 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3720 fn identity_row_stored_bytes<C: CanisterKind>(
3721 session: &DbSession<C>,
3722 store_path: &'static str,
3723 key: u64,
3724 ) -> u64 {
3725 let data_key = DecodedDataStoreKey::try_from_structural_key(ENTITY_TAG, &Value::Nat64(key))
3726 .expect("identity row key should encode");
3727 let raw_key = data_key.to_raw().expect("identity raw key should encode");
3728 let store = session
3729 .db
3730 .recovered_store(store_path)
3731 .expect("identity store should resolve");
3732 store.with_data(|data_store| {
3733 u64::try_from(
3734 data_store
3735 .get(&raw_key)
3736 .expect("inserted identity row should exist")
3737 .len(),
3738 )
3739 .expect("bounded row length should fit u64")
3740 })
3741 }
3742
3743 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3744 fn with_stored_bytes_limit<T>(
3745 limit: u64,
3746 shape_fingerprint_prefix: u64,
3747 operation: impl FnOnce() -> Result<T, crate::db::query::intent::QueryError>,
3748 ) -> Result<T, crate::db::query::intent::QueryError> {
3749 let budget = HardExecutionBudget::uniform_for_tests(
3750 u64::MAX,
3751 HardExecutionFailureHeadroom::new(500, 256),
3752 )
3753 .with_limit_for_tests(
3754 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::StoredBytesRead,
3755 limit,
3756 );
3757 let context = HardExecutionContext::new(
3758 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
3759 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
3760 shape_fingerprint_prefix,
3761 );
3762
3763 with_query_execution_budget_for_tests(budget, context, operation)
3764 }
3765
3766 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3767 fn assert_exact_key_batch<C: CanisterKind>(session: &DbSession<C>) {
3768 let first = insert_exact_key_fixture(session, 41);
3769 let second = insert_exact_key_fixture(session, 42);
3770 let missing = u64::MAX;
3771 let binding = exact_key_binding(session);
3772 let gets_before = DataStore::current_get_call_count();
3773 let result = session
3774 .execute_public_exact_key_batch_for_typed_binding(
3775 &binding,
3776 &[second, missing, first, second],
3777 )
3778 .expect("exact-key batch should execute")
3779 .expect("exact-key binding should remain current");
3780
3781 assert_eq!(result.positions, vec![0, 1, 2, 0]);
3782 assert_eq!(
3783 result.distinct_rows,
3784 vec![
3785 Some(expected_dynamic_row(second, 42)),
3786 None,
3787 Some(expected_dynamic_row(first, 41)),
3788 ],
3789 );
3790 assert_eq!(
3791 DataStore::current_get_call_count().saturating_sub(gets_before),
3792 3,
3793 "four input positions with one duplicate must perform three physical reads",
3794 );
3795 }
3796
3797 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3798 #[test]
3799 fn exact_key_batches_preserve_semantics_across_heap_and_journaled_stores() {
3800 assert_exact_key_batch(&initialize());
3801 assert_exact_key_batch(&initialize_journaled());
3802 }
3803
3804 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3805 fn assert_primary_range_materialization_fetches_once<C: CanisterKind>(
3806 session: &DbSession<C>,
3807 store_path: &'static str,
3808 ) {
3809 let key = insert_exact_key_fixture(session, 41);
3810 let stored_bytes = identity_row_stored_bytes(session, store_path, key);
3811
3812 let scalar = DynamicQuery::new(ENTITY_NAME)
3813 .select(["id", "payload"])
3814 .order_by(asc("id"))
3815 .limit(1);
3816 let gets_before = DataStore::current_get_call_count();
3817 let scalar_page = with_stored_bytes_limit(stored_bytes, 0x7072_696d_6172_792d, || {
3818 session.execute_trusted_live_page(&scalar, None)
3819 })
3820 .expect("one scalar primary-range row should fit one payload-read allowance");
3821 assert_eq!(scalar_page.row_count, 1);
3822 assert_eq!(
3823 DataStore::current_get_call_count().saturating_sub(gets_before),
3824 1,
3825 "scalar primary traversal should fetch its emitted row exactly once",
3826 );
3827
3828 let grouped = DynamicQuery::new(ENTITY_NAME)
3829 .group_by("payload")
3830 .aggregate(crate::db::count())
3831 .grouped_limits(10, 16 * 1_024)
3832 .limit(1);
3833 let gets_before = DataStore::current_get_call_count();
3834 let grouped_page = with_stored_bytes_limit(stored_bytes, 0x6772_6f75_7065_642d, || {
3835 session.execute_trusted_dynamic_grouped_query(&grouped)
3836 })
3837 .expect("one grouped primary-range row should fit one payload-read allowance");
3838 assert_eq!(grouped_page.row_count, 1);
3839 assert_eq!(
3840 DataStore::current_get_call_count().saturating_sub(gets_before),
3841 1,
3842 "grouped primary traversal should fetch its source row exactly once",
3843 );
3844 }
3845
3846 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3847 #[test]
3848 fn row_materialization_fetches_each_required_payload_at_most_once() {
3849 assert_primary_range_materialization_fetches_once(&initialize(), STORE_PATH);
3850 assert_primary_range_materialization_fetches_once(
3851 &initialize_journaled(),
3852 JOURNALED_STORE_PATH,
3853 );
3854 }
3855
3856 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3857 #[test]
3858 fn ordered_grouped_pages_close_a_group_spanning_physical_refills_before_resume() {
3859 let session = initialize();
3860 let mut patches = Vec::new();
3861 for _ in 0..70 {
3862 patches.push(dynamic_payload_patch(10));
3863 }
3864 for _ in 0..3 {
3865 patches.push(dynamic_payload_patch(20));
3866 }
3867 patches.push(dynamic_payload_patch(30));
3868 let inserted = session
3869 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, patches)
3870 .expect("ordered grouped continuation rows should insert");
3871 assert_eq!(inserted.rows.len(), 74);
3872
3873 let query = DynamicQuery::new(ENTITY_NAME)
3874 .group_by("payload")
3875 .aggregate(crate::db::count())
3876 .aggregate(crate::db::sum("id"))
3877 .order_by(asc("payload"))
3878 .grouped_limits(4, 16 * 1_024)
3879 .limit(1);
3880 let expected = [
3881 (10_u64, 70_u64, crate::types::Decimal::new(2_485, 0)),
3882 (20, 3, crate::types::Decimal::new(216, 0)),
3883 (30, 1, crate::types::Decimal::new(74, 0)),
3884 ];
3885 let mut continuation: Option<String> = None;
3886 let mut seen_cursors = std::collections::BTreeSet::new();
3887
3888 for (page_index, (group_key, row_count, id_sum)) in expected.into_iter().enumerate() {
3889 let request = continuation.as_ref().map_or_else(
3890 || query.clone(),
3891 |cursor| query.clone().cursor(cursor.clone()),
3892 );
3893 let entries_before = IndexStore::current_entry_read_count();
3894 let rows_before = DataStore::current_get_call_count();
3895 let page = session
3896 .execute_trusted_dynamic_grouped_query(&request)
3897 .unwrap_or_else(|error| {
3898 panic!("ordered grouped page {page_index} should execute: {error:?}")
3899 });
3900 let entries_read =
3901 IndexStore::current_entry_read_count().saturating_sub(entries_before);
3902 let rows_read = DataStore::current_get_call_count().saturating_sub(rows_before);
3903
3904 assert_eq!(page.row_count, 1);
3905 let [row] = page.rows.as_slice() else {
3906 panic!("ordered grouped page must contain exactly one closed group")
3907 };
3908 assert_eq!(row.group_key(), &[OutputValue::Nat64(group_key)]);
3909 assert_eq!(
3910 row.aggregate_values(),
3911 &[OutputValue::Nat64(row_count), OutputValue::Decimal(id_sum),],
3912 );
3913 if page_index == 0 {
3914 assert!(
3915 entries_read.saturating_add(rows_read) >= 70,
3916 "the first closed group must span the maintained 64-entry physical refill",
3917 );
3918 }
3919
3920 continuation = page.next_cursor;
3921 if page_index + 1 < expected.len() {
3922 let cursor = continuation
3923 .as_ref()
3924 .expect("another closed group should retain continuation");
3925 assert!(
3926 seen_cursors.insert(cursor.clone()),
3927 "ordered grouped continuation must advance monotonically",
3928 );
3929 } else {
3930 assert_eq!(continuation, None);
3931 }
3932 }
3933 }
3934
3935 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3936 #[test]
3937 fn exhaustive_pages_require_and_recompare_the_complete_source_proof() {
3938 let session = initialize();
3939 let first = insert_exact_key_fixture(&session, 41);
3940 let second = insert_exact_key_fixture(&session, 42);
3941 let third = insert_exact_key_fixture(&session, 43);
3942 let query = DynamicQuery::new(ENTITY_NAME)
3943 .select(["id", "payload"])
3944 .order_by(asc("id"));
3945
3946 let page = session
3947 .execute_trusted_exhaustive_page(&query, None, None)
3948 .expect("initial exhaustive page should capture its source proof");
3949 assert_eq!(
3950 page.rows,
3951 vec![
3952 expected_dynamic_row(first, 41),
3953 expected_dynamic_row(second, 42),
3954 ],
3955 );
3956 let continuation = page
3957 .continuation
3958 .as_deref()
3959 .expect("unreturned row should retain exhaustive continuation");
3960 assert!(matches!(
3961 session.execute_trusted_exhaustive_page(&query, Some(continuation), None),
3962 Err(ExhaustiveReadError::Revision(
3963 ReadSetRevisionError::ResumeProofRequired
3964 )),
3965 ));
3966 let resumed = session
3967 .execute_trusted_exhaustive_page(&query, Some(continuation), Some(&page.proof))
3968 .expect("unchanged proof should resume exhaustive traversal");
3969 assert_eq!(resumed.rows, vec![expected_dynamic_row(third, 43)]);
3970 assert_eq!(resumed.continuation, None);
3971
3972 let stale_page = session
3973 .execute_trusted_exhaustive_page(&query, None, None)
3974 .expect("fresh exhaustive page should capture current revision");
3975 let stale_continuation = stale_page
3976 .continuation
3977 .as_deref()
3978 .expect("fresh three-row traversal should retain continuation");
3979 let _ = insert_exact_key_fixture(&session, 44);
3980 assert!(matches!(
3981 session.execute_trusted_exhaustive_page(
3982 &query,
3983 Some(stale_continuation),
3984 Some(&stale_page.proof),
3985 ),
3986 Err(ExhaustiveReadError::Revision(
3987 ReadSetRevisionError::StoreDataChanged { .. }
3988 )),
3989 ));
3990 }
3991
3992 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3993 #[test]
3994 fn heap_sources_cannot_back_durable_resumable_jobs() {
3995 let session = initialize();
3996 let proof = session
3997 .capture_read_set_revision_proof(&[ENTITY_NAME])
3998 .expect("heap source proof should capture for one-call exhaustive reads");
3999 let job_id = ResumableJobId::try_from_bytes([70; 32])
4000 .expect("nonzero heap test job identity should admit");
4001
4002 assert!(matches!(
4003 session.start_resumable_job(job_id, proof, Vec::new()),
4004 Err(ResumableJobError::SourceProof(
4005 ReadSetRevisionError::DurableStoreRequired { .. }
4006 )),
4007 ));
4008 }
4009
4010 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4011 #[test]
4012 fn proof_and_progress_controls_charge_one_shared_request_scope() {
4013 let (session, root) = initialize_journaled_with_root();
4014 let resource = icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions;
4015 let before = root.observed(resource);
4016 let proof = session
4017 .capture_read_set_revision_proof(&[ENTITY_NAME])
4018 .expect("proof capture should use the retained request scope");
4019 let job_id = ResumableJobId::try_from_bytes([75; 32])
4020 .expect("nonzero accounting job identity should admit");
4021 session
4022 .start_resumable_job(job_id, proof, Vec::new())
4023 .expect("job start should use the same retained request scope");
4024 let _ = session
4025 .resumable_job_state(job_id)
4026 .expect("job load should use the same retained request scope");
4027
4028 assert_eq!(root.observed(resource).saturating_sub(before), 3);
4029 }
4030
4031 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4032 #[test]
4033 fn source_proofs_ignore_unrelated_stores_but_bind_access_state_changes() {
4034 let session = initialize();
4035 let proof = session
4036 .capture_read_set_revision_proof(&[ENTITY_NAME])
4037 .expect("source proof should cover only the entity's physical store");
4038 let shared_store_proof = session
4039 .capture_read_set_revision_proof(&[ENTITY_NAME, ENTITY_NAME])
4040 .expect("entities sharing one physical source should deduplicate");
4041 assert_eq!(shared_store_proof, proof);
4042 assert_eq!(shared_store_proof.stores().len(), 1);
4043 let unrelated = session
4044 .db
4045 .store_handle(UNRELATED_STORE_PATH)
4046 .expect("unrelated registered store should resolve");
4047 unrelated.with_data_mut(|store| {
4048 let _ = store.remove(&RawDataStoreKey::from_persisted_bytes(vec![1]));
4049 });
4050 session
4051 .verify_read_set_revision_proof(&proof)
4052 .expect("a nonparticipating store mutation must not invalidate the proof");
4053
4054 let source = session
4055 .db
4056 .store_handle(STORE_PATH)
4057 .expect("participating source store should resolve");
4058 source
4059 .mark_index_building()
4060 .expect("source access-state transition should advance its revision");
4061 assert!(matches!(
4062 session.verify_read_set_revision_proof(&proof),
4063 Err(ExhaustiveReadError::Revision(
4064 ReadSetRevisionError::StoreAccessChanged { .. }
4065 )),
4066 ));
4067 }
4068
4069 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4070 #[expect(
4071 clippy::too_many_lines,
4072 reason = "one lifecycle test proves successful replay plus pre-page and post-page source invalidation without sharing progress state across tests"
4073 )]
4074 #[test]
4075 fn journaled_job_advance_is_idempotent_and_revision_checked_on_both_sides() {
4076 let session = initialize_journaled();
4077 let proof = session
4078 .capture_read_set_revision_proof(&[ENTITY_NAME])
4079 .expect("journaled source proof should capture");
4080 let job_id =
4081 ResumableJobId::try_from_bytes([71; 32]).expect("nonzero job identity should admit");
4082 session
4083 .start_resumable_job(job_id, proof, vec![0])
4084 .expect("journaled job should start outside its protected source revision");
4085 let request = ResumableJobAdvanceRequest::new(
4086 job_id,
4087 0,
4088 ResumableJobIdempotencyKey::new("page-0")
4089 .expect("bounded idempotency key should admit"),
4090 );
4091 let calls = Cell::new(0_u8);
4092 let receipt = session
4093 .compare_proof_and_advance(&request, |state| {
4094 calls.set(calls.get() + 1);
4095 assert_eq!(state.application_state, vec![0]);
4096 Ok::<_, ()>(
4097 ResumableJobAdvance::new(Some("cursor-1".to_string()), vec![1], vec![9])
4098 .expect("bounded application advance should admit"),
4099 )
4100 })
4101 .expect("unchanged source should advance exactly once");
4102 assert_eq!(calls.get(), 1);
4103 assert_eq!(receipt.status, ResumableJobAdvanceStatus::Advanced);
4104 assert_eq!(receipt.committed_sequence, 1);
4105
4106 let replay = session
4107 .compare_proof_and_advance::<()>(&request, |_| {
4108 panic!("lost-response replay must not execute application work")
4109 })
4110 .expect("same request identity should return its persisted receipt");
4111 assert_eq!(replay, receipt);
4112 let retained = session
4113 .resumable_job_state(job_id)
4114 .expect("advanced state should remain durable");
4115 assert_eq!(retained.sequence, 1);
4116 assert_eq!(retained.application_state, vec![1]);
4117
4118 let _ = insert_exact_key_fixture(&session, 51);
4119 let pre_change_request = ResumableJobAdvanceRequest::new(
4120 job_id,
4121 1,
4122 ResumableJobIdempotencyKey::new("page-1")
4123 .expect("bounded idempotency key should admit"),
4124 );
4125 let pre_change_calls = Cell::new(0_u8);
4126 let invalidated = session
4127 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
4128 pre_change_calls.set(pre_change_calls.get() + 1);
4129 unreachable!("pre-page proof failure must reject before application work")
4130 })
4131 .expect("source drift should persist one replayable invalidation receipt");
4132 assert_eq!(pre_change_calls.get(), 0);
4133 assert_eq!(invalidated.status, ResumableJobAdvanceStatus::Invalidated);
4134 let invalidated_state = session
4135 .resumable_job_state(job_id)
4136 .expect("invalidated job should remain inspectable");
4137 assert_eq!(invalidated_state.status, ResumableJobStatus::Invalidated);
4138 assert_eq!(invalidated_state.continuation, None);
4139 assert_eq!(invalidated_state.application_state, vec![1]);
4140 assert_eq!(
4141 session
4142 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
4143 panic!("invalidation replay must not execute application work")
4144 })
4145 .expect("lost invalidation reply should replay exactly"),
4146 invalidated,
4147 );
4148
4149 let post_proof = session
4150 .capture_read_set_revision_proof(&[ENTITY_NAME])
4151 .expect("post-change journaled proof should capture");
4152 let post_job_id = ResumableJobId::try_from_bytes([72; 32])
4153 .expect("nonzero post-change job identity should admit");
4154 session
4155 .start_resumable_job(post_job_id, post_proof, vec![7])
4156 .expect("post-change journaled job should start");
4157 let post_request = ResumableJobAdvanceRequest::new(
4158 post_job_id,
4159 0,
4160 ResumableJobIdempotencyKey::new("post-page-0")
4161 .expect("bounded idempotency key should admit"),
4162 );
4163 let post_receipt = session
4164 .compare_proof_and_advance::<()>(&post_request, |_| {
4165 let _ = insert_exact_key_fixture(&session, 52);
4166 Ok(ResumableJobAdvance::new(None, vec![8], vec![10])
4167 .expect("bounded post-change candidate should admit"))
4168 })
4169 .expect("post-page drift should discard the candidate and persist invalidation");
4170 assert_eq!(post_receipt.status, ResumableJobAdvanceStatus::Invalidated);
4171 let post_state = session
4172 .resumable_job_state(post_job_id)
4173 .expect("post-page invalidation should remain inspectable");
4174 assert_eq!(post_state.status, ResumableJobStatus::Invalidated);
4175 assert_eq!(post_state.application_state, vec![7]);
4176 session
4177 .acknowledge_resumable_job(post_job_id, post_state.sequence)
4178 .expect("terminal job acknowledgement should remove retained progress");
4179 session
4180 .acknowledge_resumable_job(post_job_id, post_state.sequence)
4181 .expect("lost acknowledgement reply should be safely replayable");
4182 assert_eq!(
4183 session.resumable_job_state(post_job_id),
4184 Err(ResumableJobError::NotFound),
4185 );
4186
4187 let completed_job_id = ResumableJobId::try_from_bytes([74; 32])
4188 .expect("nonzero completed job identity should admit");
4189 let completed_proof = session
4190 .capture_read_set_revision_proof(&[ENTITY_NAME])
4191 .expect("completed-job source proof should capture");
4192 session
4193 .start_resumable_job(completed_job_id, completed_proof, Vec::new())
4194 .expect("completed-job fixture should start");
4195 let completed_request = ResumableJobAdvanceRequest::new(
4196 completed_job_id,
4197 0,
4198 ResumableJobIdempotencyKey::new("complete")
4199 .expect("bounded completion key should admit"),
4200 );
4201 let completed_receipt = session
4202 .compare_proof_and_advance::<()>(&completed_request, |_| {
4203 Ok(ResumableJobAdvance::new(None, vec![99], vec![100])
4204 .expect("bounded terminal advance should admit"))
4205 })
4206 .expect("null continuation should commit terminal completion");
4207 let completed_state = session
4208 .resumable_job_state(completed_job_id)
4209 .expect("completed state should remain replayable before acknowledgement");
4210 assert_eq!(completed_state.status, ResumableJobStatus::Completed);
4211 assert_eq!(
4212 session
4213 .compare_proof_and_advance::<()>(&completed_request, |_| {
4214 panic!("completed request replay must not execute application work")
4215 })
4216 .expect("completed request should replay until acknowledgement"),
4217 completed_receipt,
4218 );
4219 let after_completion = ResumableJobAdvanceRequest::new(
4220 completed_job_id,
4221 1,
4222 ResumableJobIdempotencyKey::new("after-complete")
4223 .expect("bounded post-completion key should admit"),
4224 );
4225 assert!(matches!(
4226 session.compare_proof_and_advance::<()>(&after_completion, |_| {
4227 panic!("completed jobs cannot execute another page")
4228 }),
4229 Err(CompareProofAndAdvanceError::Protocol(
4230 ResumableJobError::Completed
4231 )),
4232 ));
4233 session
4234 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
4235 .expect("completed job should acknowledge and free capacity");
4236 session
4237 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
4238 .expect("completion acknowledgement should be idempotent");
4239
4240 let stale_job_id = ResumableJobId::try_from_bytes([73; 32])
4241 .expect("nonzero stale-sequence job identity should admit");
4242 let stale_proof = session
4243 .capture_read_set_revision_proof(&[ENTITY_NAME])
4244 .expect("stale-sequence source proof should capture");
4245 session
4246 .start_resumable_job(stale_job_id, stale_proof, Vec::new())
4247 .expect("stale-sequence job should start");
4248 let stale_request = ResumableJobAdvanceRequest::new(
4249 stale_job_id,
4250 4,
4251 ResumableJobIdempotencyKey::new("stale").expect("bounded idempotency key should admit"),
4252 );
4253 assert!(matches!(
4254 session.compare_proof_and_advance::<()>(&stale_request, |_| {
4255 panic!("stale sequence must reject before application work")
4256 }),
4257 Err(CompareProofAndAdvanceError::Protocol(
4258 ResumableJobError::StaleSequence {
4259 expected: 4,
4260 actual: 0,
4261 }
4262 )),
4263 ));
4264 assert_eq!(
4265 session.acknowledge_resumable_job(stale_job_id, 0),
4266 Err(ResumableJobError::NotTerminal),
4267 );
4268 }
4269
4270 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4271 #[test]
4272 fn exact_key_batch_uses_typed_hard_execution_budget() {
4273 let session = initialize();
4274 let binding = exact_key_binding(&session);
4275 let budget =
4276 HardExecutionBudget::uniform_for_tests(0, HardExecutionFailureHeadroom::new(500, 256));
4277 let error = session
4278 .execute_exact_key_batch_with_hard_budget_for_tests(&binding, &[u64::MAX], &budget)
4279 .expect_err("zero query budget should reject the exact-key route");
4280
4281 assert!(matches!(
4282 error.diagnostic().detail(),
4283 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4284 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
4285 })
4286 ));
4287 let facts = error.diagnostic_facts();
4288 assert_eq!(
4289 &facts[..5],
4290 &[
4291 (
4292 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
4293 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions.raw(),
4294 ),
4295 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 0),
4296 (icydb_diagnostic_code::DiagnosticFactTag::Actual, 1),
4297 (
4298 icydb_diagnostic_code::DiagnosticFactTag::ExecutionBudgetScope,
4299 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution.raw(),
4300 ),
4301 (
4302 icydb_diagnostic_code::DiagnosticFactTag::ExecutionLane,
4303 icydb_diagnostic_code::DiagnosticExecutionLane::PublicRead.raw(),
4304 ),
4305 ],
4306 );
4307 assert_eq!(
4308 facts[5].0,
4309 icydb_diagnostic_code::DiagnosticFactTag::QueryShapeFingerprintPrefix,
4310 );
4311 assert_ne!(facts[5].1, 0);
4312 }
4313
4314 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4315 fn assert_planned_query_exhausts(
4316 session: &DbSession<TestCanister>,
4317 query: &crate::db::DynamicQuery,
4318 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
4319 ) {
4320 let budget = HardExecutionBudget::uniform_for_tests(
4321 u64::MAX,
4322 HardExecutionFailureHeadroom::new(500, 256),
4323 )
4324 .with_limit_for_tests(resource, 0);
4325 let context = HardExecutionContext::new(
4326 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
4327 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
4328 0x7068_7973_6963_616c,
4329 );
4330 let error = with_query_execution_budget_for_tests(budget, context, || {
4331 session.execute_trusted_live_page(query, None)
4332 })
4333 .expect_err("the injected zero resource allowance should reject planned execution");
4334
4335 assert!(matches!(
4336 error.diagnostic().detail(),
4337 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4338 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
4339 })
4340 ));
4341 assert_eq!(
4342 error.diagnostic_facts()[0],
4343 (
4344 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
4345 resource.raw(),
4346 ),
4347 );
4348 }
4349
4350 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4351 fn assert_grouped_query_exhausts(
4352 session: &DbSession<TestCanister>,
4353 query: &crate::db::DynamicQuery,
4354 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
4355 ) {
4356 let budget = HardExecutionBudget::uniform_for_tests(
4357 u64::MAX,
4358 HardExecutionFailureHeadroom::new(500, 256),
4359 )
4360 .with_limit_for_tests(resource, 0);
4361 let context = HardExecutionContext::new(
4362 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
4363 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
4364 0x6772_6f75_7065_642d,
4365 );
4366 let error = with_query_execution_budget_for_tests(budget, context, || {
4367 session.execute_trusted_dynamic_grouped_query(query)
4368 })
4369 .expect_err("the injected zero resource allowance should reject grouped execution");
4370
4371 assert!(matches!(
4372 error.diagnostic().detail(),
4373 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4374 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
4375 })
4376 ));
4377 assert_eq!(
4378 error.diagnostic_facts()[0],
4379 (
4380 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
4381 resource.raw(),
4382 ),
4383 );
4384 }
4385
4386 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4387 fn assert_sql_query_exhausts(
4388 session: &DbSession<TestCanister>,
4389 sql: &str,
4390 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
4391 ) {
4392 let budget = HardExecutionBudget::uniform_for_tests(
4393 u64::MAX,
4394 HardExecutionFailureHeadroom::new(500, 256),
4395 )
4396 .with_limit_for_tests(resource, 0);
4397 let context = HardExecutionContext::new(
4398 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
4399 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
4400 0x7371_6c2d_736f_7274,
4401 );
4402 let error = with_query_execution_budget_for_tests(budget, context, || {
4403 session.execute_trusted_sql_query(sql)
4404 })
4405 .expect_err("the injected zero resource allowance should reject SQL execution");
4406
4407 assert!(matches!(
4408 error.diagnostic().detail(),
4409 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4410 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
4411 })
4412 ));
4413 assert_eq!(
4414 error.diagnostic_facts()[0],
4415 (
4416 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
4417 resource.raw(),
4418 ),
4419 );
4420 }
4421
4422 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4423 #[test]
4424 fn planned_read_routes_share_physical_resource_accounting() {
4425 let session = initialize();
4426 let first = insert_exact_key_fixture(&session, 41);
4427 insert_exact_key_fixture(&session, 42);
4428
4429 let fallback = crate::db::DynamicQuery::new(ENTITY_NAME)
4430 .filter(crate::db::FieldRef::new("id").eq(first))
4431 .select(["id", "payload"])
4432 .order_by(crate::db::asc("id"))
4433 .limit(1);
4434 assert_eq!(
4435 session
4436 .execute_trusted_live_page(&fallback, None)
4437 .expect("bounded fallback execution should preserve its result")
4438 .row_count,
4439 1,
4440 );
4441 assert_planned_query_exhausts(
4442 &session,
4443 &fallback,
4444 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::RowsVisited,
4445 );
4446
4447 let covering = crate::db::DynamicQuery::new(ENTITY_NAME)
4448 .filter(crate::db::FieldRef::new("payload").eq(41_u64))
4449 .select(["payload"])
4450 .order_by(crate::db::asc("payload"))
4451 .limit(1);
4452 assert_eq!(
4453 session
4454 .execute_trusted_live_page(&covering, None)
4455 .expect("bounded covering execution should preserve its result")
4456 .row_count,
4457 1,
4458 );
4459 assert_planned_query_exhausts(
4460 &session,
4461 &covering,
4462 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
4463 );
4464
4465 let residual = crate::db::DynamicQuery::new(ENTITY_NAME)
4466 .filter(crate::db::FieldRef::new("payload").eq_field("id"))
4467 .select(["id"])
4468 .order_by(crate::db::asc("id"))
4469 .limit(1);
4470 assert_eq!(
4471 session
4472 .execute_trusted_live_page(&residual, None)
4473 .expect("bounded residual execution should preserve its result")
4474 .row_count,
4475 0,
4476 );
4477 assert_planned_query_exhausts(
4478 &session,
4479 &residual,
4480 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::PredicateExpressionSteps,
4481 );
4482
4483 assert_planned_query_exhausts(
4484 &session,
4485 &fallback,
4486 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::ResultBytes,
4487 );
4488
4489 let grouped = crate::db::DynamicQuery::new(ENTITY_NAME)
4490 .group_by("payload")
4491 .aggregate(crate::db::count())
4492 .order_by(crate::db::asc("payload"))
4493 .grouped_limits(10, 16 * 1_024)
4494 .limit(1);
4495 let grouped_result = session
4496 .execute_trusted_dynamic_grouped_query(&grouped)
4497 .expect("bounded grouped execution should preserve its result");
4498 assert_eq!(grouped_result.row_count, 1);
4499 assert!(grouped_result.next_cursor.is_some());
4500 assert_grouped_query_exhausts(
4501 &session,
4502 &grouped,
4503 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::GroupDistinctEntries,
4504 );
4505 assert_grouped_query_exhausts(
4506 &session,
4507 &grouped,
4508 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::CursorSteps,
4509 );
4510
4511 assert_sql_query_exhausts(
4512 &session,
4513 "SELECT payload, COUNT(*) AS row_count FROM IdentityRow \
4514 GROUP BY payload ORDER BY row_count DESC, payload ASC LIMIT 1",
4515 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::SortEntries,
4516 );
4517 }
4518
4519 fn assert_dynamic_payload<C: CanisterKind>(
4520 session: &DbSession<C>,
4521 key: u64,
4522 expected_payload: u64,
4523 ) {
4524 let unchanged = session
4525 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
4526 entity: ENTITY_NAME.to_string(),
4527 key: InputValue::Nat64(key),
4528 patch: dynamic_payload_patch(expected_payload),
4529 })
4530 .expect("the expected row should remain readable through a no-op update");
4531 assert_eq!(unchanged.affected_rows, 0);
4532 assert_eq!(
4533 unchanged.rows,
4534 vec![expected_dynamic_row(key, expected_payload)],
4535 );
4536 }
4537
4538 fn assert_exact_batch_backlog_pressure(
4539 pressure: &InternalError,
4540 before: JournalTailControl,
4541 next_sequence: u64,
4542 ) {
4543 assert_eq!(
4544 pressure.diagnostic().error_code(),
4545 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_CONVERGENCE_BACKLOG_PRESSURE,
4546 );
4547 assert_eq!(
4548 pressure.diagnostic_facts(),
4549 vec![
4550 (
4551 icydb_diagnostic_code::DiagnosticFactTag::BacklogResource,
4552 icydb_diagnostic_code::DiagnosticBacklogResource::Batches.raw(),
4553 ),
4554 (icydb_diagnostic_code::DiagnosticFactTag::CurrentCount, 38),
4555 (icydb_diagnostic_code::DiagnosticFactTag::ProposedCount, 1),
4556 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 38),
4557 ],
4558 );
4559 assert_eq!(
4560 crate::db::commit::next_database_commit_sequence()
4561 .expect("pressure must leave the database sequence readable"),
4562 next_sequence,
4563 );
4564 assert!(matches!(
4565 crate::db::commit::observe_commit_control()
4566 .expect("pressure must leave commit control observable"),
4567 crate::db::commit::CommitControlObservation::Present {
4568 marker_present: false,
4569 ..
4570 },
4571 ));
4572 assert_eq!(
4573 JOURNALED_TAIL_STORE.with(|tail| {
4574 tail.borrow()
4575 .current_tail_control()
4576 .expect("pressure must preserve the exact tail control")
4577 }),
4578 before,
4579 );
4580 }
4581
4582 fn batch(values: &[u64]) -> Vec<AcceptedStructuralMutation> {
4583 values
4584 .iter()
4585 .map(|value| {
4586 AcceptedStructuralMutation::save(
4587 MutationMode::Insert,
4588 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
4589 payload_patch(*value),
4590 )
4591 })
4592 .collect()
4593 }
4594
4595 fn atomic_progress_fixture(
4596 identity_byte: u8,
4597 ) -> (
4598 MutationJobRecord,
4599 MutationJobRecord,
4600 MutationProgressRecordOp,
4601 ) {
4602 let job_id = MutationJobId::try_from_bytes([identity_byte; 32])
4603 .expect("nonzero atomic progress job id should admit");
4604 let before = MutationJobRecord::new(job_id, vec![1, identity_byte], vec![2])
4605 .expect("atomic progress predecessor should admit");
4606 let request = MutationJobAdvanceRequest::new(
4607 job_id,
4608 0,
4609 MutationJobIdempotencyKey::new(format!("atomic-{identity_byte}"))
4610 .expect("atomic progress replay key should admit"),
4611 );
4612 let (after, _) = before
4613 .apply_transition(
4614 &request,
4615 MutationJobTransition::new(
4616 MutationJobStatus::Active,
4617 MutationJobPhase::Forward,
4618 vec![3],
4619 1,
4620 1,
4621 0,
4622 ),
4623 )
4624 .expect("atomic progress successor should admit");
4625 let operation = MutationProgressRecordOp::replace(&before, &after)
4626 .expect("atomic progress replacement should admit");
4627 (before, after, operation)
4628 }
4629
4630 fn assert_identity_boundary(error: &InternalError) {
4631 assert_eq!(error.class(), ErrorClass::Unsupported);
4632 assert_eq!(error.origin(), ErrorOrigin::Identity);
4633 }
4634
4635 #[test]
4636 fn generated_candidate_collision_is_identity_corruption_before_generic_uniqueness() {
4637 let generated = insert_key_exists_after_generation(true);
4638 assert_eq!(generated.class(), ErrorClass::Corruption);
4639 assert_eq!(generated.origin(), ErrorOrigin::Identity);
4640
4641 let ordinary = insert_key_exists_after_generation(false);
4642 assert_ne!(ordinary.origin(), ErrorOrigin::Identity);
4643 }
4644
4645 #[cfg(target_pointer_width = "64")]
4646 #[test]
4647 fn pre_key_candidate_count_rejects_values_beyond_the_persisted_u32_bound() {
4648 let error = checked_pre_key_candidate_count(
4649 usize::try_from(u64::from(u32::MAX) + 1).expect("64-bit usize should hold u32 + 1"),
4650 )
4651 .expect_err("candidate counts beyond u32 must reject");
4652 assert_identity_boundary(&error);
4653 }
4654
4655 #[test]
4656 #[expect(
4657 clippy::too_many_lines,
4658 reason = "one holding lifecycle proves split, merge, transfer, late-failure neutrality, result order, and Identity state"
4659 )]
4660 fn mixed_structural_batch_preserves_holding_conservation_and_failure_atomicity() {
4661 let session = initialize();
4662 let seeded = session
4663 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
4664 .expect("seed rows should commit");
4665 assert_eq!(seeded.affected_rows, 1);
4666
4667 let split = session
4668 .execute_trusted_dynamic_mutation_batch(vec![
4669 DynamicMutation::Update {
4670 entity: ENTITY_NAME.to_string(),
4671 key: InputValue::Nat64(1),
4672 patch: dynamic_payload_patch(60),
4673 },
4674 DynamicMutation::Insert {
4675 entity: ENTITY_NAME.to_string(),
4676 patch: dynamic_payload_patch(40),
4677 },
4678 ])
4679 .expect("one holding should split atomically");
4680 assert_eq!(split.affected_rows, 2);
4681 assert_eq!(
4682 split.rows,
4683 vec![expected_dynamic_row(1, 60), expected_dynamic_row(2, 40),],
4684 "split after-images must retain input order and exact quantity",
4685 );
4686
4687 let rejected_split = session
4688 .execute_trusted_dynamic_mutation_batch(vec![
4689 DynamicMutation::Update {
4690 entity: ENTITY_NAME.to_string(),
4691 key: InputValue::Nat64(1),
4692 patch: dynamic_payload_patch(50),
4693 },
4694 DynamicMutation::Insert {
4695 entity: ENTITY_NAME.to_string(),
4696 patch: DynamicStructuralPatch::new(Vec::new()),
4697 },
4698 ])
4699 .expect_err("an invalid split output must reject the staged source update");
4700 assert_eq!(rejected_split.class(), ErrorClass::Unsupported);
4701 assert_eq!(rejected_split.origin(), ErrorOrigin::Executor);
4702 assert_eq!(
4703 rejected_split.diagnostic_facts(),
4704 vec![
4705 (
4706 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4707 ENTITY_TAG.value(),
4708 ),
4709 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 2),
4710 (
4711 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4712 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
4713 ),
4714 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1,),
4715 ],
4716 );
4717 assert_dynamic_payload(&session, 1, 60);
4718 assert_dynamic_payload(&session, 2, 40);
4719
4720 let transfer = session
4721 .execute_trusted_dynamic_mutation_batch(vec![
4722 DynamicMutation::Update {
4723 entity: ENTITY_NAME.to_string(),
4724 key: InputValue::Nat64(1),
4725 patch: dynamic_payload_patch(70),
4726 },
4727 DynamicMutation::Update {
4728 entity: ENTITY_NAME.to_string(),
4729 key: InputValue::Nat64(2),
4730 patch: dynamic_payload_patch(30),
4731 },
4732 ])
4733 .expect("distinct transfer patches should share one atomic batch");
4734 assert_eq!(
4735 transfer.rows,
4736 vec![expected_dynamic_row(1, 70), expected_dynamic_row(2, 30),],
4737 "the transfer must preserve the exact total quantity",
4738 );
4739
4740 let merge = session
4741 .execute_trusted_dynamic_mutation_batch(vec![
4742 DynamicMutation::Delete {
4743 entity: ENTITY_NAME.to_string(),
4744 key: InputValue::Nat64(2),
4745 },
4746 DynamicMutation::Update {
4747 entity: ENTITY_NAME.to_string(),
4748 key: InputValue::Nat64(1),
4749 patch: dynamic_payload_patch(100),
4750 },
4751 ])
4752 .expect("two holdings should merge atomically");
4753 assert_eq!(
4754 merge.rows,
4755 vec![expected_dynamic_row(2, 30), expected_dynamic_row(1, 100),],
4756 "delete before-images and update after-images must retain input order",
4757 );
4758
4759 let resplit = session
4760 .execute_trusted_dynamic_mutation_batch(vec![
4761 DynamicMutation::Update {
4762 entity: ENTITY_NAME.to_string(),
4763 key: InputValue::Nat64(1),
4764 patch: dynamic_payload_patch(60),
4765 },
4766 DynamicMutation::Insert {
4767 entity: ENTITY_NAME.to_string(),
4768 patch: dynamic_payload_patch(40),
4769 },
4770 ])
4771 .expect("the merged holding should split again");
4772 assert_eq!(
4773 resplit.rows,
4774 vec![expected_dynamic_row(1, 60), expected_dynamic_row(3, 40),],
4775 );
4776
4777 let rejected_merge = session
4778 .execute_trusted_dynamic_mutation_batch(vec![
4779 DynamicMutation::Delete {
4780 entity: ENTITY_NAME.to_string(),
4781 key: InputValue::Nat64(3),
4782 },
4783 DynamicMutation::Update {
4784 entity: ENTITY_NAME.to_string(),
4785 key: InputValue::Nat64(99),
4786 patch: dynamic_payload_patch(100),
4787 },
4788 ])
4789 .expect_err("a late missing merge target must preserve the earlier staged delete");
4790 assert_eq!(rejected_merge.class(), ErrorClass::NotFound);
4791 assert_dynamic_payload(&session, 1, 60);
4792 assert_dynamic_payload(&session, 3, 40);
4793
4794 SCHEMA_STORE.with(|store| {
4795 let cursor = store
4796 .borrow()
4797 .identity_statement_cursor(
4798 database_incarnation_id().expect("database incarnation should remain readable"),
4799 ENTITY_TAG,
4800 FieldId::new(1),
4801 &AcceptedFieldKind::Nat64,
4802 )
4803 .expect("mixed Identity state should remain readable");
4804 assert_eq!(cursor.expected_high_water(), 3);
4805 assert!(!cursor.has_allocations());
4806 });
4807 }
4808
4809 #[test]
4810 fn mixed_structural_batch_rejects_duplicate_holding_targets_without_mutation() {
4811 let session = initialize();
4812 session
4813 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
4814 .expect("the holding fixture should initialize");
4815
4816 let duplicate = session
4817 .execute_trusted_dynamic_mutation_batch(vec![
4818 DynamicMutation::Update {
4819 entity: ENTITY_NAME.to_string(),
4820 key: InputValue::Nat64(1),
4821 patch: dynamic_payload_patch(60),
4822 },
4823 DynamicMutation::Delete {
4824 entity: ENTITY_NAME.to_string(),
4825 key: InputValue::Nat64(1),
4826 },
4827 ])
4828 .expect_err("duplicate targets across operation kinds must reject");
4829 assert!(matches!(
4830 duplicate.diagnostic().detail(),
4831 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4832 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchDuplicateKey,
4833 }),
4834 ));
4835 assert_eq!(
4836 duplicate.diagnostic_facts(),
4837 vec![
4838 (
4839 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4840 ENTITY_TAG.value(),
4841 ),
4842 (
4843 icydb_diagnostic_code::DiagnosticFactTag::FirstBatchPosition,
4844 0,
4845 ),
4846 (
4847 icydb_diagnostic_code::DiagnosticFactTag::DuplicateBatchPosition,
4848 1,
4849 ),
4850 ],
4851 );
4852 assert_dynamic_payload(&session, 1, 100);
4853 }
4854
4855 #[test]
4856 fn mixed_structural_batch_rejects_empty_and_over_bound_before_resolution() {
4857 let session = initialize();
4858 let empty = session
4859 .execute_trusted_dynamic_mutation_batch(Vec::new())
4860 .expect_err("an empty public batch must reject");
4861 assert!(matches!(
4862 empty.diagnostic().detail(),
4863 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4864 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchEmpty,
4865 }),
4866 ));
4867 assert_eq!(
4868 empty.diagnostic_facts(),
4869 vec![(icydb_diagnostic_code::DiagnosticFactTag::ActualCount, 0,)],
4870 );
4871
4872 let requests = (0..=MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS)
4873 .map(|_| DynamicMutation::Delete {
4874 entity: ENTITY_NAME.to_string(),
4875 key: InputValue::Nat64(1),
4876 })
4877 .collect();
4878 let over_bound = session
4879 .execute_trusted_dynamic_mutation_batch(requests)
4880 .expect_err("operation cap plus one must reject before row resolution");
4881 assert!(matches!(
4882 over_bound.diagnostic().detail(),
4883 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4884 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchTooManyItems,
4885 }),
4886 ));
4887 assert_eq!(
4888 over_bound.diagnostic_facts(),
4889 vec![
4890 (
4891 icydb_diagnostic_code::DiagnosticFactTag::ActualCount,
4892 (MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS + 1) as u64,
4893 ),
4894 (
4895 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4896 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS as u64,
4897 ),
4898 ],
4899 );
4900 }
4901
4902 #[test]
4903 fn mixed_structural_batch_staged_byte_bound_uses_checked_exact_boundary() {
4904 assert_eq!(
4905 structural_mutation_staged_charge([11, 13, 17])
4906 .expect("the writer-owned formula should sum all three row-image components"),
4907 41,
4908 );
4909 let mut exact = 0;
4910 add_structural_mutation_staged_bytes(
4911 &mut exact,
4912 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES],
4913 )
4914 .expect("the exact staged-byte boundary should admit");
4915 assert_eq!(exact, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
4916
4917 let error = add_structural_mutation_staged_bytes(&mut exact, [1])
4918 .expect_err("one byte above the staged-byte boundary must reject");
4919 assert!(matches!(
4920 error.diagnostic().detail(),
4921 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4922 boundary:
4923 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchStagedBytesExceeded,
4924 }),
4925 ));
4926 assert_eq!(
4927 error.diagnostic_facts(),
4928 vec![
4929 (
4930 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
4931 (MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES + 1) as u64,
4932 ),
4933 (
4934 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4935 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES as u64,
4936 ),
4937 ],
4938 );
4939
4940 let mut prefix = 0;
4941 assert_eq!(
4942 admit_structural_mutation_staged_charge(
4943 &mut prefix,
4944 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES],
4945 AcceptedStructuralMutationPacking::BoundedPrefix,
4946 )
4947 .expect("the exact prefix boundary should calculate"),
4948 AcceptedStructuralMutationStagedAdmission::Admitted,
4949 );
4950 assert_eq!(prefix, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
4951 assert_eq!(
4952 admit_structural_mutation_staged_charge(
4953 &mut prefix,
4954 [1],
4955 AcceptedStructuralMutationPacking::BoundedPrefix,
4956 )
4957 .expect("the next prefix candidate should calculate"),
4958 AcceptedStructuralMutationStagedAdmission::PageFull,
4959 );
4960 assert_eq!(prefix, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
4961
4962 let mut empty_prefix = 0;
4963 assert_eq!(
4964 admit_structural_mutation_staged_charge(
4965 &mut empty_prefix,
4966 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES + 1],
4967 AcceptedStructuralMutationPacking::BoundedPrefix,
4968 )
4969 .expect("one oversized candidate should classify without mutating the prefix"),
4970 AcceptedStructuralMutationStagedAdmission::CandidateExceedsPolicy,
4971 );
4972 assert_eq!(empty_prefix, 0);
4973
4974 validate_structural_mutation_result_bytes(MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES)
4975 .expect("the exact result-byte boundary should admit");
4976 let error = validate_structural_mutation_result_bytes(
4977 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1,
4978 )
4979 .expect_err("one byte above the result-byte boundary must reject");
4980 assert!(matches!(
4981 error.diagnostic().detail(),
4982 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4983 boundary:
4984 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchResultBytesExceeded,
4985 }),
4986 ));
4987 assert_eq!(
4988 error.diagnostic_facts(),
4989 vec![
4990 (
4991 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
4992 (MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1) as u64,
4993 ),
4994 (
4995 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4996 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES as u64,
4997 ),
4998 ],
4999 );
5000 }
5001
5002 #[expect(
5003 clippy::too_many_lines,
5004 reason = "one lifecycle proves shared materialization and every maintained frontend against the same zero-state owner"
5005 )]
5006 #[test]
5007 fn identity_insert_frontends_share_one_committed_range_without_rejected_consumption() {
5008 let session = initialize();
5009 let catalog = session
5010 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5011 .expect("identity catalog should resolve");
5012 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5013 .expect("identity row layout should build");
5014 let initial_description = session
5015 .try_describe_entity_by_name(ENTITY_NAME)
5016 .expect("accepted Identity description should resolve");
5017 assert_eq!(
5018 initial_description.entity_tag(),
5019 catalog.identity().entity_tag().value()
5020 );
5021 assert_eq!(
5022 initial_description.accepted_schema_fingerprint_method(),
5023 catalog.fingerprint_method_version()
5024 );
5025 assert_eq!(
5026 initial_description.accepted_schema_fingerprint(),
5027 catalog.fingerprint()
5028 );
5029 let initial_identity = initial_description
5030 .identity()
5031 .expect("accepted Identity policy should be described");
5032 assert_eq!(initial_identity.field(), "id");
5033 assert_eq!(initial_identity.generator(), "Identity::next");
5034 assert_eq!(initial_identity.accepted_kind(), "nat64");
5035 assert_eq!(initial_identity.minimum(), 1);
5036 assert_eq!(initial_identity.maximum(), u128::from(u64::MAX));
5037 assert_eq!(initial_identity.high_water(), 0);
5038 assert_eq!(initial_identity.remaining(), u128::from(u64::MAX));
5039 assert!(!initial_identity.exhausted());
5040
5041 let rejected = session
5042 .execute_accepted_structural_save_batch(
5043 &catalog,
5044 &descriptor,
5045 batch(&[1_000, 2_000]),
5046 Timestamp::from_millis(6),
5047 |_| Err::<(), _>(InternalError::executor_unsupported()),
5048 )
5049 .expect_err("a rejected precommit result must not publish its tentative range");
5050 assert_eq!(rejected.class(), ErrorClass::Unsupported);
5051 assert_eq!(DATA_STORE.with(|store| store.borrow().len()), 0);
5052
5053 let rows = session
5054 .execute_accepted_structural_save_batch(
5055 &catalog,
5056 &descriptor,
5057 batch(&[10, 20, 30]),
5058 Timestamp::from_millis(7),
5059 Ok,
5060 )
5061 .expect("one accepted batch should commit rows and one identity range");
5062 assert_eq!(
5063 rows.into_iter().map(|row| row.values).collect::<Vec<_>>(),
5064 vec![
5065 vec![Value::Nat64(1), Value::Nat64(10)],
5066 vec![Value::Nat64(2), Value::Nat64(20)],
5067 vec![Value::Nat64(3), Value::Nat64(30)],
5068 ],
5069 );
5070
5071 let dynamic = session
5072 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5073 entity: ENTITY_NAME.to_string(),
5074 patch: DynamicStructuralPatch::new(vec![(
5075 "payload".to_string(),
5076 DynamicWriteCell::Value(InputValue::Nat64(40)),
5077 )]),
5078 })
5079 .expect("dynamic omission should commit through shared Identity generation");
5080 assert_eq!(dynamic.affected_rows, 1);
5081
5082 for (request, operation) in [
5083 (
5084 DynamicMutation::Insert {
5085 entity: ENTITY_NAME.to_string(),
5086 patch: DynamicStructuralPatch::new(vec![
5087 (
5088 "id".to_string(),
5089 DynamicWriteCell::Value(InputValue::Nat64(41)),
5090 ),
5091 (
5092 "payload".to_string(),
5093 DynamicWriteCell::Value(InputValue::Nat64(42)),
5094 ),
5095 ]),
5096 },
5097 icydb_diagnostic_code::DiagnosticMutationOperation::Insert,
5098 ),
5099 (
5100 DynamicMutation::Update {
5101 entity: ENTITY_NAME.to_string(),
5102 key: InputValue::Nat64(1),
5103 patch: DynamicStructuralPatch::new(vec![(
5104 "id".to_string(),
5105 DynamicWriteCell::Default,
5106 )]),
5107 },
5108 icydb_diagnostic_code::DiagnosticMutationOperation::Update,
5109 ),
5110 ] {
5111 let error = session
5112 .execute_trusted_dynamic_mutation(&request)
5113 .expect_err("structural Identity authorship and regeneration must reject");
5114 assert_eq!(error.class(), ErrorClass::Unsupported);
5115 assert_eq!(error.origin(), ErrorOrigin::Executor);
5116 assert_eq!(
5117 error.diagnostic_facts(),
5118 vec![
5119 (
5120 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
5121 ENTITY_TAG.value(),
5122 ),
5123 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
5124 (
5125 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
5126 operation.raw(),
5127 ),
5128 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,),
5129 ],
5130 );
5131 }
5132
5133 let binding = session
5134 .issue_typed_entity_binding(
5135 ENTITY_SOURCE,
5136 &[
5137 DynamicTypedFieldBindingRequest::new(
5138 ID_SOURCE.to_string(),
5139 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
5140 false,
5141 ),
5142 DynamicTypedFieldBindingRequest::new(
5143 PAYLOAD_SOURCE.to_string(),
5144 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
5145 false,
5146 ),
5147 ],
5148 )
5149 .expect("typed output should bind the Identity field");
5150 let typed_patch = binding
5151 .bind_write_fields(vec![(
5152 PAYLOAD_SOURCE.to_string(),
5153 DynamicWriteCell::Value(InputValue::Nat64(50)),
5154 )])
5155 .expect("typed payload should lower");
5156 let typed = session
5157 .execute_trusted_typed_mutation(
5158 &binding,
5159 &DynamicTypedMutation::Insert { patch: typed_patch },
5160 )
5161 .expect("typed omission should commit through shared Identity generation");
5162 assert_eq!(
5163 typed
5164 .expect("typed insert should return one mutation result")
5165 .affected_rows,
5166 1,
5167 );
5168 let explicit_typed_patch = binding
5169 .bind_write_fields(vec![
5170 (
5171 ID_SOURCE.to_string(),
5172 DynamicWriteCell::Value(InputValue::Nat64(51)),
5173 ),
5174 (
5175 PAYLOAD_SOURCE.to_string(),
5176 DynamicWriteCell::Value(InputValue::Nat64(52)),
5177 ),
5178 ])
5179 .expect("the low-level binding should retain exact authored intent");
5180 let explicit_typed_error = session
5181 .execute_trusted_typed_mutation(
5182 &binding,
5183 &DynamicTypedMutation::Insert {
5184 patch: explicit_typed_patch,
5185 },
5186 )
5187 .expect_err("typed Identity authorship must reject before allocation");
5188 assert_eq!(explicit_typed_error.class(), ErrorClass::Unsupported);
5189 assert_eq!(explicit_typed_error.origin(), ErrorOrigin::Executor);
5190 assert_eq!(
5191 explicit_typed_error.diagnostic_facts(),
5192 vec![
5193 (
5194 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
5195 ENTITY_TAG.value(),
5196 ),
5197 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
5198 (
5199 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
5200 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
5201 ),
5202 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,),
5203 ],
5204 );
5205
5206 let replace_error = session
5207 .execute_trusted_dynamic_mutation(&DynamicMutation::Replace {
5208 entity: ENTITY_NAME.to_string(),
5209 key: InputValue::Nat64(99),
5210 patch: DynamicStructuralPatch::new(vec![(
5211 "payload".to_string(),
5212 DynamicWriteCell::Value(InputValue::Nat64(60)),
5213 )]),
5214 })
5215 .expect_err("save-as-insert with a chosen Identity must reject");
5216 assert_eq!(replace_error.class(), ErrorClass::Unsupported);
5217 assert_eq!(replace_error.origin(), ErrorOrigin::Executor);
5218
5219 #[cfg(feature = "sql")]
5220 {
5221 for sql in [
5222 "INSERT INTO IdentityRow (payload) VALUES (70) RETURNING id, payload",
5223 "INSERT INTO IdentityRow (id, payload) VALUES (DEFAULT, 80) RETURNING id",
5224 ] {
5225 let _result = session
5226 .execute_trusted_sql_mutation(sql)
5227 .expect("SQL omission and DEFAULT should commit Identity generation");
5228 }
5229
5230 let error = session
5231 .execute_trusted_sql_mutation(
5232 "INSERT INTO IdentityRow (id, payload) VALUES (42, 90)",
5233 )
5234 .expect_err("an explicit SQL Identity value must reject before allocation");
5235 let diagnostic = error.diagnostic();
5236 assert_eq!(
5237 diagnostic.code(),
5238 icydb_diagnostic_code::DiagnosticCode::QuerySqlWriteBoundary,
5239 );
5240 assert!(matches!(
5241 diagnostic.detail(),
5242 Some(icydb_diagnostic_code::DiagnosticDetail::SqlWriteBoundary {
5243 boundary: icydb_diagnostic_code::SqlWriteBoundaryCode::ExplicitGeneratedField,
5244 }),
5245 ));
5246 }
5247
5248 let expected_committed = if cfg!(feature = "sql") { 7 } else { 5 };
5249 assert_eq!(
5250 DATA_STORE.with(|store| store.borrow().len()),
5251 expected_committed
5252 );
5253 SCHEMA_STORE.with(|store| {
5254 let cursor = store
5255 .borrow()
5256 .identity_statement_cursor(
5257 database_incarnation_id().expect("database incarnation should remain readable"),
5258 ENTITY_TAG,
5259 FieldId::new(1),
5260 &AcceptedFieldKind::Nat64,
5261 )
5262 .expect("committed writes must leave active state readable");
5263 assert_eq!(cursor.expected_high_water(), u128::from(expected_committed),);
5264 assert!(!cursor.has_allocations());
5265 });
5266 let committed_description = session
5267 .try_describe_entity_by_name(ENTITY_NAME)
5268 .expect("committed Identity description should resolve");
5269 let committed_identity = committed_description
5270 .identity()
5271 .expect("accepted Identity policy should remain described");
5272 assert_eq!(
5273 committed_identity.high_water(),
5274 u128::from(expected_committed),
5275 );
5276 assert_eq!(
5277 committed_identity.remaining(),
5278 u128::from(u64::MAX - expected_committed),
5279 );
5280 assert!(!committed_identity.exhausted());
5281 }
5282
5283 #[test]
5284 #[expect(
5285 clippy::too_many_lines,
5286 reason = "one ordered scenario proves target/progress atomicity, every interruption wake-up, state-only admission, and successful no-op wake-up behavior"
5287 )]
5288 fn mutation_progress_and_target_rows_recover_as_one_marker_transition() {
5289 let session = initialize_journaled();
5290 let initial_entity_revision = JOURNALED_TAIL_STORE
5291 .with(|tail| tail.borrow().entity_mutation_revision(ENTITY_TAG))
5292 .expect("direct initial schema publication must install entity revision authority");
5293 assert_eq!(initial_entity_revision, 1);
5294 install_startup_recovery_wakeup(record_startup_wakeup);
5295 let catalog = session
5296 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5297 .expect("journaled atomic-progress catalog should resolve");
5298 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5299 .expect("journaled atomic-progress row layout should build");
5300
5301 for (ordinal, interruption) in [
5302 MutationCommitInterruption::MarkerPersisted,
5303 MutationCommitInterruption::JournalPublished,
5304 MutationCommitInterruption::RowsPublished,
5305 MutationCommitInterruption::ProgressReplaced,
5306 ]
5307 .into_iter()
5308 .enumerate()
5309 {
5310 let identity_byte = 31 + u8::try_from(ordinal).expect("small ordinal should fit");
5311 let (before, after, operation) = atomic_progress_fixture(identity_byte);
5312 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5313 match store.insert_mutation(&before)? {
5314 InsertMutationJobResult::Inserted => Ok(()),
5315 InsertMutationJobResult::Occupied(_) => {
5316 Err(crate::db::MutationJobError::IdentityConflict)
5317 }
5318 }
5319 })
5320 .expect("atomic predecessor should insert once");
5321
5322 let wakeups_before = STARTUP_WAKEUPS.with(Cell::get);
5323 interrupt_next_mutation_commit_for_tests(interruption);
5324 let interrupted = session.execute_accepted_structural_update_with_mutation_progress(
5325 &catalog,
5326 &descriptor,
5327 batch(&[700 + u64::try_from(ordinal).expect("small ordinal should fit")]),
5328 Timestamp::from_millis(17),
5329 operation,
5330 );
5331 assert!(
5332 interrupted.is_err(),
5333 "selected atomic boundary should interrupt"
5334 );
5335 assert_eq!(
5336 STARTUP_WAKEUPS.with(Cell::get),
5337 wakeups_before.saturating_add(1),
5338 "a normally returned retained-marker error must register its wake-up",
5339 );
5340
5341 forget_recovered_domain_for_tests(&session.db)
5342 .expect("interruption should reset volatile recovery ownership");
5343 let retained_before =
5344 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5345 store.load_mutation(before.state().job_id)
5346 })
5347 .expect("pre-driver progress should load");
5348 let row_count_before = JOURNALED_DATA_STORE.with(|store| store.borrow().len());
5349 let pending = session
5350 .db
5351 .ensure_recovered_state()
5352 .expect_err("ordinary admission must not drive retained-marker recovery");
5353 assert_eq!(
5354 pending.diagnostic().error_code(),
5355 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
5356 );
5357 assert_eq!(
5358 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5359 store.load_mutation(before.state().job_id)
5360 })
5361 .expect("post-admission progress should load"),
5362 retained_before,
5363 );
5364 assert_eq!(
5365 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
5366 row_count_before,
5367 "state-only admission must not mutate target rows",
5368 );
5369 assert!(
5370 session
5371 .db
5372 .drive_startup_recovery_page()
5373 .expect("dedicated driver should finish target and progress together"),
5374 );
5375 let retained = with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5376 store.load_mutation(before.state().job_id)
5377 })
5378 .expect("recovered successor should load");
5379 assert_eq!(retained, after);
5380 assert_eq!(
5381 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
5382 u64::try_from(ordinal + 1).expect("small row count should fit"),
5383 );
5384 }
5385
5386 let (before, after, operation) = atomic_progress_fixture(39);
5387 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5388 match store.insert_mutation(&before)? {
5389 InsertMutationJobResult::Inserted => Ok(()),
5390 InsertMutationJobResult::Occupied(_) => {
5391 Err(crate::db::MutationJobError::IdentityConflict)
5392 }
5393 }
5394 })
5395 .expect("final predecessor should insert once");
5396 let wakeups_before_success = STARTUP_WAKEUPS.with(Cell::get);
5397 session
5398 .execute_accepted_structural_update_with_mutation_progress(
5399 &catalog,
5400 &descriptor,
5401 batch(&[799]),
5402 Timestamp::from_millis(18),
5403 operation,
5404 )
5405 .expect("uninterrupted atomic transition should clear its marker");
5406 assert_eq!(
5407 STARTUP_WAKEUPS.with(Cell::get),
5408 wakeups_before_success.saturating_add(1),
5409 "a successful retained commit must request online convergence",
5410 );
5411 let retained = with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5412 store.load_mutation(before.state().job_id)
5413 })
5414 .expect("final successor should load");
5415 assert_eq!(retained, after);
5416 forget_recovered_domain_for_tests(&session.db)
5417 .expect("post-clear recovery ownership should reset");
5418 let pending = session
5419 .db
5420 .ensure_recovered_state()
5421 .expect_err("an upgrade epoch must remain gated until its driver runs");
5422 assert_eq!(
5423 pending.diagnostic().error_code(),
5424 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
5425 );
5426 assert!(
5427 session
5428 .db
5429 .drive_startup_recovery_page()
5430 .expect("post-clear driver recovery should fold the retained batch"),
5431 );
5432 }
5433
5434 #[test]
5435 fn startup_recovery_initializes_missing_entity_revisions_from_the_store_revision() {
5436 let session = initialize_journaled();
5437 let catalog = session
5438 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5439 .expect("journaled predecessor catalog should resolve");
5440 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5441 .expect("journaled predecessor row layout should build");
5442 session
5443 .execute_accepted_structural_save_batch(
5444 &catalog,
5445 &descriptor,
5446 batch(&[901]),
5447 Timestamp::from_millis(21),
5448 Ok,
5449 )
5450 .expect("predecessor row should advance the store-wide revision");
5451 let baseline = JOURNALED_TAIL_STORE.with(|tail| {
5452 let mut tail = tail.borrow_mut();
5453 let baseline = tail
5454 .data_mutation_revision()
5455 .expect("predecessor store-wide revision should load");
5456 tail.clear_entity_mutation_revisions_for_tests();
5457 baseline
5458 });
5459
5460 forget_recovered_domain_for_tests(&session.db)
5461 .expect("upgrade should reset volatile recovery ownership");
5462 drive_journaled_recovery_to_completion(&session);
5463
5464 let recovered = JOURNALED_TAIL_STORE
5465 .with(|tail| tail.borrow().entity_mutation_revision(ENTITY_TAG))
5466 .expect("recovery should publish the current entity authority");
5467 assert_eq!(recovered, baseline);
5468 }
5469
5470 #[test]
5471 fn mutation_progress_neither_side_mismatch_blocks_recovery() {
5472 let session = initialize_journaled();
5473 let catalog = session
5474 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5475 .expect("journaled corruption catalog should resolve");
5476 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5477 .expect("journaled corruption row layout should build");
5478 let (before, _after, operation) = atomic_progress_fixture(41);
5479 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5480 match store.insert_mutation(&before)? {
5481 InsertMutationJobResult::Inserted => Ok(()),
5482 InsertMutationJobResult::Occupied(_) => {
5483 Err(crate::db::MutationJobError::IdentityConflict)
5484 }
5485 }
5486 })
5487 .expect("corruption predecessor should insert once");
5488
5489 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::MarkerPersisted);
5490 assert!(
5491 session
5492 .execute_accepted_structural_update_with_mutation_progress(
5493 &catalog,
5494 &descriptor,
5495 batch(&[811]),
5496 Timestamp::from_millis(19),
5497 operation,
5498 )
5499 .is_err(),
5500 "marker interruption should retain recovery authority",
5501 );
5502 let (unexpected, _) = before
5503 .apply_transition(
5504 &MutationJobAdvanceRequest::new(
5505 before.state().job_id,
5506 0,
5507 MutationJobIdempotencyKey::new("unexpected-third-state")
5508 .expect("unexpected replay key should admit"),
5509 ),
5510 MutationJobTransition::new(
5511 MutationJobStatus::Active,
5512 MutationJobPhase::Forward,
5513 vec![99],
5514 2,
5515 0,
5516 0,
5517 ),
5518 )
5519 .expect("unexpected but valid progress state should admit");
5520 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5521 store.replace_mutation(&unexpected)
5522 })
5523 .expect("test should install the neither-side state");
5524
5525 forget_recovered_domain_for_tests(&session.db)
5526 .expect("corrupt recovery ownership should reset");
5527 let error = session
5528 .db
5529 .drive_startup_recovery_page()
5530 .expect_err("neither-side progress must block recovery");
5531 assert_eq!(error.class(), ErrorClass::Corruption);
5532 assert_eq!(error.origin(), ErrorOrigin::Recovery);
5533 assert_eq!(
5534 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5535 store.load_mutation(before.state().job_id)
5536 })
5537 .expect("unexpected state should remain inspectable to the test"),
5538 unexpected,
5539 );
5540 assert!(
5541 session.db.drive_startup_recovery_page().is_err(),
5542 "a retained corrupt marker must continue blocking database access",
5543 );
5544 }
5545
5546 #[test]
5547 #[expect(
5548 clippy::too_many_lines,
5549 reason = "one ordered scenario exercises every durable interruption boundary, guarded recovery, derived rebuild, and both integrity tiers"
5550 )]
5551 fn journaled_identity_recovery_quiesces_every_publication_interruption_before_reallocation() {
5552 let session = initialize_journaled();
5553 let catalog = session
5554 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5555 .expect("journaled identity catalog should resolve");
5556 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5557 .expect("journaled identity row layout should build");
5558
5559 for (ordinal, interruption) in [
5560 MutationCommitInterruption::MarkerPersisted,
5561 MutationCommitInterruption::JournalPublished,
5562 MutationCommitInterruption::RowsPublished,
5563 MutationCommitInterruption::StateMaterialized,
5564 ]
5565 .into_iter()
5566 .enumerate()
5567 {
5568 interrupt_next_mutation_commit_for_tests(interruption);
5569 let interrupted = session.execute_accepted_structural_save_batch(
5570 &catalog,
5571 &descriptor,
5572 batch(&[u64::try_from(ordinal).expect("ordinal should fit")]),
5573 Timestamp::from_millis(8),
5574 Ok,
5575 );
5576 assert!(
5577 interrupted.is_err(),
5578 "the selected durable boundary should interrupt",
5579 );
5580
5581 let Err(pending) = session.execute_accepted_structural_save_batch(
5582 &catalog,
5583 &descriptor,
5584 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
5585 Timestamp::from_millis(9),
5586 Ok,
5587 ) else {
5588 panic!("ordinary mutation must not drive retained-marker recovery");
5589 };
5590 assert_eq!(
5591 pending.diagnostic().error_code(),
5592 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
5593 );
5594 drive_journaled_recovery_to_completion(&session);
5595
5596 let committed = session
5597 .execute_accepted_structural_save_batch(
5598 &catalog,
5599 &descriptor,
5600 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
5601 Timestamp::from_millis(9),
5602 Ok,
5603 )
5604 .expect("the next mutation must recover before allocating");
5605 let expected_high_water =
5606 u64::try_from((ordinal + 1) * 2).expect("small test high-water should fit");
5607 assert_eq!(
5608 committed
5609 .into_iter()
5610 .map(|row| row.values)
5611 .collect::<Vec<_>>(),
5612 vec![vec![
5613 Value::Nat64(expected_high_water),
5614 Value::Nat64(100 + u64::try_from(ordinal).expect("ordinal should fit")),
5615 ]],
5616 );
5617 assert_eq!(
5618 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
5619 expected_high_water,
5620 );
5621 JOURNALED_SCHEMA_STORE.with(|store| {
5622 let cursor = store
5623 .borrow()
5624 .identity_statement_cursor(
5625 database_incarnation_id()
5626 .expect("database incarnation should remain readable"),
5627 ENTITY_TAG,
5628 FieldId::new(1),
5629 &AcceptedFieldKind::Nat64,
5630 )
5631 .expect("guarded recovery must leave quiescent active state");
5632 assert_eq!(
5633 cursor.expected_high_water(),
5634 u128::from(expected_high_water),
5635 );
5636 assert!(!cursor.has_allocations());
5637 });
5638 }
5639
5640 for (ordinal, (interruption, deleted_key)) in [
5641 (MutationCommitInterruption::MarkerPersisted, 2),
5642 (MutationCommitInterruption::JournalPublished, 4),
5643 (MutationCommitInterruption::RowPrefixPublished, 6),
5644 (MutationCommitInterruption::RowsPublished, 8),
5645 (MutationCommitInterruption::StateMaterialized, 7),
5646 ]
5647 .into_iter()
5648 .enumerate()
5649 {
5650 let expected_payload =
5651 501 + u64::try_from(ordinal).expect("small interruption ordinal should fit");
5652 interrupt_next_mutation_commit_for_tests(interruption);
5653 let interrupted = session.execute_trusted_dynamic_mutation_batch(vec![
5654 DynamicMutation::Update {
5655 entity: ENTITY_NAME.to_string(),
5656 key: InputValue::Nat64(1),
5657 patch: dynamic_payload_patch(expected_payload),
5658 },
5659 DynamicMutation::Delete {
5660 entity: ENTITY_NAME.to_string(),
5661 key: InputValue::Nat64(deleted_key),
5662 },
5663 ]);
5664 assert!(
5665 interrupted.is_err(),
5666 "the selected caller-key mixed publication boundary should interrupt",
5667 );
5668 let pending = session
5669 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
5670 entity: ENTITY_NAME.to_string(),
5671 key: InputValue::Nat64(1),
5672 patch: dynamic_payload_patch(expected_payload),
5673 })
5674 .expect_err("ordinary update must not drive retained-marker recovery");
5675 assert_eq!(
5676 pending.diagnostic().error_code(),
5677 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
5678 );
5679 drive_journaled_recovery_to_completion(&session);
5680 let recovered_update = session
5681 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
5682 entity: ENTITY_NAME.to_string(),
5683 key: InputValue::Nat64(1),
5684 patch: dynamic_payload_patch(expected_payload),
5685 })
5686 .expect("guarded reentry should complete the marker-authorized mixed batch");
5687 assert_eq!(
5688 recovered_update.affected_rows, 0,
5689 "the recovered update must already expose its admitted final image",
5690 );
5691 let recovered_delete = session
5692 .execute_trusted_dynamic_mutation(&DynamicMutation::Delete {
5693 entity: ENTITY_NAME.to_string(),
5694 key: InputValue::Nat64(deleted_key),
5695 })
5696 .expect_err("the recovered delete must already be materialized");
5697 assert_eq!(recovered_delete.class(), ErrorClass::NotFound);
5698 JOURNALED_SCHEMA_STORE.with(|store| {
5699 let cursor = store
5700 .borrow()
5701 .identity_statement_cursor(
5702 database_incarnation_id()
5703 .expect("database incarnation should remain readable"),
5704 ENTITY_TAG,
5705 FieldId::new(1),
5706 &AcceptedFieldKind::Nat64,
5707 )
5708 .expect("caller-key recovery must preserve active Identity state");
5709 assert_eq!(cursor.expected_high_water(), 8);
5710 assert!(!cursor.has_allocations());
5711 });
5712 }
5713
5714 forget_recovered_domain_for_tests(&session.db)
5715 .expect("the final journal tail should remain recoverable");
5716 session
5717 .db
5718 .drive_startup_recovery_page()
5719 .expect("derived rebuild must not allocate another identity");
5720
5721 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
5722 let index_generation = JOURNALED_INDEX_STORE.with(|store| store.borrow().generation());
5723 let data_len = JOURNALED_DATA_STORE.with(|store| store.borrow().len());
5724 let index_len = JOURNALED_INDEX_STORE.with(|store| store.borrow().len());
5725 forget_recovered_domain_for_tests(&session.db)
5726 .expect("an empty-tail upgrade should reset recovery ownership");
5727 session
5728 .db
5729 .drive_startup_recovery_page()
5730 .expect("an empty-tail upgrade should admit without rebuilding stored rows or indexes");
5731 assert_eq!(
5732 JOURNALED_DATA_STORE.with(|store| store.borrow().generation()),
5733 data_generation
5734 .checked_add(1)
5735 .expect("test generation should advance once"),
5736 "empty-tail recovery must reset the disposable row projection exactly once",
5737 );
5738 assert_eq!(
5739 JOURNALED_INDEX_STORE.with(|store| store.borrow().generation()),
5740 index_generation
5741 .checked_add(1)
5742 .expect("test generation should advance once"),
5743 "empty-tail recovery must reset the disposable index projection exactly once",
5744 );
5745 assert_eq!(
5746 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
5747 data_len,
5748 "empty-tail recovery must not rebuild or remove authoritative rows",
5749 );
5750 assert_eq!(
5751 JOURNALED_INDEX_STORE.with(|store| store.borrow().len()),
5752 index_len,
5753 "empty-tail recovery must not clear or rebuild canonical secondary indexes",
5754 );
5755
5756 let quick = execute_quick_integrity(
5757 &session.db,
5758 catalog.inspection_plan(),
5759 catalog.runtime_root_identity().database_incarnation(),
5760 )
5761 .expect("quiescent Identity control inventory should be inspectable");
5762 assert_eq!(quick.status(), &QuickIntegrityStatus::CompleteClean);
5763 let row_page = execute_row_integrity_page(
5764 &session.db,
5765 catalog.inspection_plan(),
5766 PhysicalUnitCheckpoint::BeforeFirst,
5767 RowInspectionLimits::standard(),
5768 )
5769 .expect("Identity rows should remain within committed high-water");
5770 assert!(row_page.exhausted());
5771 assert!(row_page.findings().is_empty());
5772
5773 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 3);
5774 assert!(
5775 JOURNALED_INDEX_STORE.with(|store| !store.borrow().is_empty()),
5776 "derived index rebuild should restore witnesses without allocating identities",
5777 );
5778 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5779 JOURNALED_SCHEMA_STORE.with(|store| {
5780 let cursor = store
5781 .borrow()
5782 .identity_statement_cursor(
5783 database_incarnation_id().expect("database incarnation should remain readable"),
5784 ENTITY_TAG,
5785 FieldId::new(1),
5786 &AcceptedFieldKind::Nat64,
5787 )
5788 .expect("folded identity state should reopen without allocating");
5789 assert_eq!(cursor.expected_high_water(), 8);
5790 assert!(!cursor.has_allocations());
5791 });
5792 }
5793
5794 #[test]
5795 fn journaled_online_convergence_drains_the_full_backlog_in_complete_batch_callbacks_without_reallocating_ids()
5796 {
5797 const SUBMISSION: &str = "generated/8899aabbccddeeff";
5798 let session = initialize_journaled();
5799 let catalog = session
5800 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5801 .expect("journaled identity catalog should resolve");
5802 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5803 .expect("journaled identity row layout should build");
5804
5805 for payload in 0_u64..38 {
5806 session
5807 .execute_accepted_structural_save_batch(
5808 &catalog,
5809 &descriptor,
5810 batch(&[payload]),
5811 Timestamp::from_millis(8),
5812 Ok,
5813 )
5814 .unwrap_or_else(|error| {
5815 panic!("journaled identity fixture row {payload} should commit: {error:?}")
5816 });
5817 }
5818
5819 let before = JOURNALED_TAIL_STORE.with(|tail| {
5820 tail.borrow()
5821 .current_tail_control()
5822 .expect("online backlog control should remain valid")
5823 });
5824 assert_eq!(before.batch_count(), 38);
5825 let next_sequence = crate::db::commit::next_database_commit_sequence()
5826 .expect("database sequence preview should remain readable");
5827 let Err(pressure) = session.execute_accepted_structural_save_batch(
5828 &catalog,
5829 &descriptor,
5830 batch(&[38]),
5831 Timestamp::from_millis(8),
5832 Ok,
5833 ) else {
5834 panic!("the exact cumulative batch ceiling should reject one more batch")
5835 };
5836 assert_exact_batch_backlog_pressure(&pressure, before, next_sequence);
5837
5838 for folded_batches in 1..=38 {
5839 let complete = session
5840 .db
5841 .drive_startup_recovery_page()
5842 .expect("online complete-batch callback should commit");
5843 assert_eq!(complete, folded_batches == 38);
5844 }
5845
5846 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5847 session
5848 .execute_accepted_structural_save_batch(
5849 &catalog,
5850 &descriptor,
5851 batch(&[38]),
5852 Timestamp::from_millis(8),
5853 Ok,
5854 )
5855 .expect("drain should make the rejected mutation retryable");
5856 assert!(
5857 session
5858 .db
5859 .drive_startup_recovery_page()
5860 .expect("the retry tail should converge"),
5861 );
5862
5863 assert_eq!(
5864 drive_generated_startup_recovery_page(&session, &JOURNALED_STORE_REGISTRY, SUBMISSION,)
5865 .expect("online convergence should commit"),
5866 GeneratedStartupDriverStep::Terminal,
5867 "the quiescent generated driver should stop",
5868 );
5869
5870 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 39);
5871 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5872 assert_dynamic_payload(&session, 1, 0);
5873 assert_dynamic_payload(&session, 39, 38);
5874 JOURNALED_SCHEMA_STORE.with(|store| {
5875 let cursor = store
5876 .borrow()
5877 .identity_statement_cursor(
5878 database_incarnation_id().expect("database incarnation should remain readable"),
5879 ENTITY_TAG,
5880 FieldId::new(1),
5881 &AcceptedFieldKind::Nat64,
5882 )
5883 .expect("online convergence must preserve active Identity state");
5884 assert_eq!(cursor.expected_high_water(), 39);
5885 assert!(!cursor.has_allocations());
5886 });
5887 }
5888
5889 #[test]
5890 fn journaled_online_convergence_reconstructs_same_key_batches_from_canonical_predecessors() {
5891 let session = initialize_journaled();
5892 session
5893 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5894 entity: ENTITY_NAME.to_string(),
5895 patch: dynamic_payload_patch(10),
5896 })
5897 .expect("the initial positioned row should commit");
5898 for payload in [20, 30] {
5899 session
5900 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
5901 entity: ENTITY_NAME.to_string(),
5902 key: InputValue::Nat64(1),
5903 patch: dynamic_payload_patch(payload),
5904 })
5905 .unwrap_or_else(|error| {
5906 panic!("the positioned same-key update should commit: {error:?}")
5907 });
5908 }
5909
5910 assert_dynamic_payload(&session, 1, 30);
5911 assert_eq!(
5912 JOURNALED_INDEX_STORE.with(|store| store.borrow().len()),
5913 1,
5914 "the newest live index effect should hide every predecessor",
5915 );
5916 for folded_batches in 1..=3 {
5917 let complete = session
5918 .db
5919 .drive_startup_recovery_page()
5920 .expect("the positioned same-key batch should converge");
5921 assert_eq!(complete, folded_batches == 3);
5922 }
5923
5924 assert_dynamic_payload(&session, 1, 30);
5925 assert_eq!(
5926 JOURNALED_INDEX_STORE.with(|store| store.borrow().len()),
5927 1,
5928 "canonical derived state must contain only the newest membership",
5929 );
5930 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5931 }
5932
5933 #[test]
5934 fn ready_cardinality_combines_durable_base_with_exact_live_delta_and_fold_maintenance() {
5935 let session = initialize_journaled();
5936 session
5937 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5938 entity: ENTITY_NAME.to_string(),
5939 patch: dynamic_payload_patch(10),
5940 })
5941 .expect("initial cardinality row should commit");
5942 assert!(
5943 session
5944 .db
5945 .drive_startup_recovery_page()
5946 .expect("initial cardinality row should fold"),
5947 );
5948 drive_journaled_cardinality_to_ready(&session);
5949 let handle = session
5950 .db
5951 .store_handle(JOURNALED_STORE_PATH)
5952 .expect("journaled cardinality store should resolve");
5953 let (index_id, prefix_components) = journaled_user_index_prefix();
5954 reset_journaled_cardinality_projections();
5955 assert_eq!(
5956 JOURNALED_DATA_STORE.with(|store| store.borrow().exact_entity_count(ENTITY_TAG)),
5957 None,
5958 "the reopened-style volatile full count must remain unavailable",
5959 );
5960 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 1);
5961
5962 session
5963 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5964 entity: ENTITY_NAME.to_string(),
5965 patch: dynamic_payload_patch(10),
5966 })
5967 .expect("post-Ready row should commit into the live overlay");
5968 for payload in [20, 10] {
5969 session
5970 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
5971 entity: ENTITY_NAME.to_string(),
5972 key: InputValue::Nat64(2),
5973 patch: dynamic_payload_patch(payload),
5974 })
5975 .expect("same-key post-Ready overlay should commit");
5976 }
5977 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 2);
5978 for folded in 1..=3 {
5979 let complete = session
5980 .db
5981 .drive_startup_recovery_page()
5982 .expect("post-Ready row should fold with exact maintenance");
5983 assert_eq!(complete, folded == 3);
5984 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 2);
5985 }
5986 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 2);
5987 session
5988 .execute_trusted_dynamic_mutation(&DynamicMutation::Delete {
5989 entity: ENTITY_NAME.to_string(),
5990 key: InputValue::Nat64(2),
5991 })
5992 .expect("post-Ready delete should commit into the live overlay");
5993 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 1);
5994 assert!(
5995 session
5996 .db
5997 .drive_startup_recovery_page()
5998 .expect("post-Ready delete should fold with exact maintenance"),
5999 );
6000 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 1);
6001 mark_journaled_cardinality_building();
6002 assert_eq!(
6003 handle.exact_entity_count(ENTITY_TAG),
6004 None,
6005 "non-Ready evidence must select the conservative path",
6006 );
6007 }
6008
6009 #[test]
6010 fn journaled_cardinality_rejects_volatile_counts_and_unfolded_accepted_root_drift() {
6011 let session = initialize_journaled();
6012 session
6013 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
6014 entity: ENTITY_NAME.to_string(),
6015 patch: dynamic_payload_patch(10),
6016 })
6017 .expect("cardinality fixture row should commit");
6018 assert!(
6019 session
6020 .db
6021 .drive_startup_recovery_page()
6022 .expect("cardinality fixture row should fold"),
6023 );
6024 drive_journaled_cardinality_to_ready(&session);
6025 let handle = session
6026 .db
6027 .store_handle(JOURNALED_STORE_PATH)
6028 .expect("journaled cardinality store should resolve");
6029 let (index_id, prefix_components) = journaled_user_index_prefix();
6030 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
6031
6032 assert_eq!(
6033 JOURNALED_DATA_STORE.with(|store| store.borrow().exact_entity_count(ENTITY_TAG)),
6034 Some(1),
6035 "the live full-count cache should be populated before accepted-root drift",
6036 );
6037 assert_eq!(
6038 JOURNALED_INDEX_STORE.with(|store| {
6039 store.borrow().exact_prefix_cardinality(
6040 data_generation,
6041 IndexKeyKind::User,
6042 index_id,
6043 prefix_components.as_slice(),
6044 )
6045 }),
6046 Some(1),
6047 "the live prefix-count cache should be populated before accepted-root drift",
6048 );
6049 assert_eq!(
6050 JOURNALED_INDEX_STORE.with(|store| {
6051 store.borrow().exact_child_prefixes_for_parent_set(
6052 data_generation,
6053 IndexKeyKind::User,
6054 index_id,
6055 [prefix_components.as_slice()],
6056 8,
6057 )
6058 }),
6059 Some(Vec::new()),
6060 "the volatile child-prefix cache should demonstrate the bypass fixture",
6061 );
6062 assert_eq!(
6063 handle.exact_user_index_child_prefixes_for_parent_set(
6064 data_generation,
6065 index_id,
6066 [prefix_components.as_slice()],
6067 8,
6068 ),
6069 None,
6070 "journaled child enumeration must use its conservative route instead of volatile authority",
6071 );
6072 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 1);
6073
6074 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
6075 JOURNALED_STORE_PATH,
6076 AcceptedSchemaRevision::new(2),
6077 BTreeMap::from([(ENTITY_TAG, identity_snapshot(JOURNALED_STORE_PATH, false))]),
6078 BTreeMap::from([
6079 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
6080 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
6081 ]),
6082 );
6083 crate::db::commit::publish_accepted_schema_candidate(
6084 JOURNALED_STORE_PATH,
6085 handle,
6086 AcceptedSchemaRevision::INITIAL,
6087 &candidate,
6088 )
6089 .expect("a successor accepted root should publish into the live overlay");
6090
6091 assert_eq!(
6092 handle.exact_entity_count(ENTITY_TAG),
6093 None,
6094 "an unfolded accepted root must invalidate durable evidence immediately",
6095 );
6096 assert_eq!(
6097 handle.exact_user_index_prefix_count(
6098 data_generation,
6099 IndexKeyKind::User,
6100 index_id,
6101 prefix_components.as_slice(),
6102 ),
6103 None,
6104 "journaled consumers must not fall back to a populated volatile prefix cache",
6105 );
6106 }
6107
6108 #[test]
6109 fn journaled_convergence_uses_final_batch_rows_for_unique_release() {
6110 let session = initialize_journaled_with_unique_payload();
6111 let inserted = session
6112 .execute_trusted_dynamic_insert_batch(
6113 ENTITY_NAME,
6114 vec![dynamic_payload_patch(10), dynamic_payload_patch(20)],
6115 )
6116 .expect("the unique journal fixture should commit");
6117 assert_eq!(
6118 inserted.rows,
6119 vec![expected_dynamic_row(1, 10), expected_dynamic_row(2, 20)],
6120 );
6121 assert!(
6122 session
6123 .db
6124 .drive_startup_recovery_page()
6125 .expect("the unique fixture should become canonical"),
6126 );
6127
6128 let swapped = session
6129 .execute_trusted_dynamic_mutation_batch(vec![
6130 DynamicMutation::Update {
6131 entity: ENTITY_NAME.to_string(),
6132 key: InputValue::Nat64(1),
6133 patch: dynamic_payload_patch(20),
6134 },
6135 DynamicMutation::Update {
6136 entity: ENTITY_NAME.to_string(),
6137 key: InputValue::Nat64(2),
6138 patch: dynamic_payload_patch(10),
6139 },
6140 ])
6141 .expect("one journal batch should admit a final-row unique swap");
6142 assert_eq!(
6143 swapped.rows,
6144 vec![expected_dynamic_row(1, 20), expected_dynamic_row(2, 10)],
6145 );
6146 assert!(
6147 session
6148 .db
6149 .drive_startup_recovery_page()
6150 .expect("the unique swap should converge in one complete batch"),
6151 );
6152
6153 let released = session
6154 .execute_trusted_dynamic_mutation_batch(vec![
6155 DynamicMutation::Delete {
6156 entity: ENTITY_NAME.to_string(),
6157 key: InputValue::Nat64(1),
6158 },
6159 DynamicMutation::Insert {
6160 entity: ENTITY_NAME.to_string(),
6161 patch: dynamic_payload_patch(20),
6162 },
6163 ])
6164 .expect("a journaled delete should release its unique value to the final insert");
6165 assert_eq!(
6166 released.rows,
6167 vec![expected_dynamic_row(1, 20), expected_dynamic_row(3, 20)],
6168 );
6169 assert!(
6170 session
6171 .db
6172 .drive_startup_recovery_page()
6173 .expect("the delete and unique reuse should converge together"),
6174 );
6175
6176 assert_dynamic_payload(&session, 2, 10);
6177 assert_dynamic_payload(&session, 3, 20);
6178 assert_eq!(JOURNALED_INDEX_STORE.with(|store| store.borrow().len()), 2);
6179 assert!(
6180 session
6181 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(20)],)
6182 .is_err(),
6183 "the converged unique index must remain authoritative",
6184 );
6185 }
6186
6187 #[test]
6188 fn journaled_startup_recovery_completes_one_large_batch_atomically() {
6189 let session = initialize_journaled();
6190 let catalog = session
6191 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
6192 .expect("journaled identity catalog should resolve");
6193 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
6194 .expect("journaled identity row layout should build");
6195 let payloads = (0_u64..129).collect::<Vec<_>>();
6196 session
6197 .execute_accepted_structural_save_batch(
6198 &catalog,
6199 &descriptor,
6200 batch(&payloads),
6201 Timestamp::from_millis(9),
6202 Ok,
6203 )
6204 .expect("one large journal batch should commit");
6205
6206 forget_recovered_domain_for_tests(&session.db)
6207 .expect("upgrade should reset recovery ownership");
6208 assert!(
6209 session
6210 .db
6211 .drive_startup_recovery_page()
6212 .expect("the complete batch recovery page should commit"),
6213 );
6214
6215 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 129);
6216 JOURNALED_TAIL_STORE.with(|tail| {
6217 let tail = tail.borrow();
6218 assert!(!tail.has_stored_batch());
6219 });
6220 assert_dynamic_payload(&session, 1, 0);
6221 assert_dynamic_payload(&session, 129, 128);
6222 }
6223
6224 #[test]
6225 fn complete_batch_validation_rejects_a_late_record_before_canonical_writes() {
6226 let session = initialize_journaled();
6227 let catalog = session
6228 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
6229 .expect("journaled identity catalog should resolve");
6230 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
6231 .expect("journaled identity row layout should build");
6232 session
6233 .execute_accepted_structural_save_batch(
6234 &catalog,
6235 &descriptor,
6236 batch(&[7]),
6237 Timestamp::from_millis(9),
6238 Ok,
6239 )
6240 .expect("journal batch predecessor should commit");
6241
6242 JOURNALED_TAIL_STORE.with(|tail| {
6243 let mut tail = tail.borrow_mut();
6244 let original = tail
6245 .next_batch_after(JournalSequence::new(0))
6246 .expect("journal batch should decode")
6247 .expect("journal batch should exist");
6248 let mut records = original.records().to_vec();
6249 records.push(
6250 JournalRecord::schema_put(JOURNALED_STORE_PATH, vec![0xff; 8])
6251 .expect("bounded semantic corruption should build"),
6252 );
6253 let corrupted = JournalBatch::new_with_database_commit_sequence(
6254 original.batch_id(),
6255 original.commit_marker_id(),
6256 original.journal_sequence(),
6257 original.database_commit_sequence(),
6258 records,
6259 )
6260 .expect("current corrupt batch shape should build");
6261 let encoded = encode_journal_batch(&corrupted)
6262 .expect("current corrupt batch envelope should encode");
6263 tail.clear_batches_through(original.journal_sequence());
6264 tail.insert_raw_batch_for_tests(original.journal_sequence(), encoded)
6265 .expect("corrupt persisted batch should replace the predecessor");
6266 });
6267
6268 forget_recovered_domain_for_tests(&session.db)
6269 .expect("upgrade should reset recovery ownership");
6270 let error = session
6271 .db
6272 .drive_startup_recovery_page()
6273 .expect_err("late semantic corruption must fail before fold apply");
6274 assert_eq!(error.class(), ErrorClass::Corruption);
6275 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 0);
6276 JOURNALED_TAIL_STORE.with(|tail| {
6277 let tail = tail.borrow();
6278 assert_eq!(
6279 tail.fold_watermark()
6280 .expect("watermark should remain readable")
6281 .highest_folded_journal_sequence(),
6282 JournalSequence::new(0),
6283 );
6284 assert!(tail.has_stored_batch());
6285 });
6286 }
6287
6288 #[test]
6289 fn prepared_batch_row_evidence_rejects_a_late_malformed_row_before_canonical_writes() {
6290 let session = initialize_journaled();
6291 let catalog = session
6292 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
6293 .expect("journaled identity catalog should resolve");
6294 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
6295 .expect("journaled identity row layout should build");
6296 session
6297 .execute_accepted_structural_save_batch(
6298 &catalog,
6299 &descriptor,
6300 batch(&[7, 8]),
6301 Timestamp::from_millis(9),
6302 Ok,
6303 )
6304 .expect("two-row journal batch should commit");
6305
6306 JOURNALED_TAIL_STORE.with(|tail| {
6307 let mut tail = tail.borrow_mut();
6308 let original = tail
6309 .next_batch_after(JournalSequence::new(0))
6310 .expect("journal batch should decode")
6311 .expect("journal batch should exist");
6312 let mut records = original.records().to_vec();
6313 let mut row_ordinal = 0_u8;
6314 for record in &mut records {
6315 if let JournalRecord::RowPut { row_bytes, .. } = record {
6316 row_ordinal = row_ordinal.saturating_add(1);
6317 if row_ordinal == 2 {
6318 *row_bytes = vec![0xff; 8];
6319 break;
6320 }
6321 }
6322 }
6323 assert_eq!(row_ordinal, 2, "the late row record should be present");
6324 let corrupted = JournalBatch::new_with_database_commit_sequence(
6325 original.batch_id(),
6326 original.commit_marker_id(),
6327 original.journal_sequence(),
6328 original.database_commit_sequence(),
6329 records,
6330 )
6331 .expect("current corrupt batch shape should build");
6332 let encoded = encode_journal_batch(&corrupted)
6333 .expect("current corrupt batch envelope should encode");
6334 tail.clear_batches_through(original.journal_sequence());
6335 tail.insert_raw_batch_for_tests(original.journal_sequence(), encoded)
6336 .expect("corrupt persisted batch should replace the predecessor");
6337 });
6338
6339 forget_recovered_domain_for_tests(&session.db)
6340 .expect("upgrade should reset recovery ownership");
6341 let error = session
6342 .db
6343 .drive_startup_recovery_page()
6344 .expect_err("late malformed row must fail during complete batch preparation");
6345 assert_eq!(error.class(), ErrorClass::Corruption);
6346 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 0);
6347 JOURNALED_TAIL_STORE.with(|tail| {
6348 let tail = tail.borrow();
6349 assert_eq!(
6350 tail.fold_watermark()
6351 .expect("watermark should remain readable")
6352 .highest_folded_journal_sequence(),
6353 JournalSequence::new(0),
6354 );
6355 assert!(tail.has_stored_batch());
6356 });
6357 }
6358
6359 #[test]
6360 #[ignore = "release-closeout native timing probe for one marker-authorized driver recovery"]
6361 fn identity_recovery_closeout_reports_driver_time() {
6362 let session = initialize_journaled();
6363 let catalog = session
6364 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
6365 .expect("journaled identity catalog should resolve");
6366 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
6367 .expect("journaled identity row layout should build");
6368
6369 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::RowsPublished);
6370 let interrupted = session.execute_accepted_structural_save_batch(
6371 &catalog,
6372 &descriptor,
6373 batch(&[1]),
6374 Timestamp::from_millis(10),
6375 Ok,
6376 );
6377 assert!(
6378 interrupted.is_err(),
6379 "the selected publication boundary should interrupt",
6380 );
6381
6382 let start = Instant::now();
6383 assert!(
6384 session
6385 .db
6386 .drive_startup_recovery_page()
6387 .expect("dedicated driver should recover before allocation"),
6388 );
6389 let committed = session
6390 .execute_accepted_structural_save_batch(
6391 &catalog,
6392 &descriptor,
6393 batch(&[2]),
6394 Timestamp::from_millis(11),
6395 Ok,
6396 )
6397 .expect("post-recovery allocation should commit");
6398 let elapsed = start.elapsed();
6399 assert_eq!(
6400 committed
6401 .into_iter()
6402 .map(|row| row.values)
6403 .collect::<Vec<_>>(),
6404 vec![vec![Value::Nat64(2), Value::Nat64(2)]],
6405 );
6406
6407 println!(
6408 "identity recovery closeout: driver_nanos={}",
6409 elapsed.as_nanos(),
6410 );
6411 }
6412}
6413
6414#[cfg(test)]
6415mod targeted_rule_mutation_tests {
6416 use super::{
6417 DbSession, DynamicMutation, DynamicStructuralPatch, DynamicTypedFieldBindingRequest,
6418 DynamicTypedFieldType, DynamicTypedMutation, DynamicWriteCell,
6419 };
6420 use crate::{
6421 db::{
6422 data::{DataStore, encode_input_value_for_candidate_field_contract},
6423 index::IndexStore,
6424 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
6425 schema::{
6426 AcceptedCheckLiteralV1, AcceptedCompositeCatalog, AcceptedFieldDecodeContract,
6427 AcceptedFieldKind, AcceptedNamedTypeIdentity, AcceptedRuleOperation,
6428 AcceptedRuleTarget, AcceptedSchemaRevision, AcceptedSourceBindingCatalog,
6429 ConstraintOrigin, FieldId, FieldStorageDecode, FieldWriteManagement, LeafCodec,
6430 PersistedFieldSnapshot, PersistedNestedLeafSnapshot, PersistedSchemaSnapshot,
6431 ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy, SchemaInsertDefault,
6432 SchemaRowLayout, SchemaStore, SchemaVersion,
6433 accepted_schema_candidate_with_catalogs_for_tests,
6434 build_record_newtype_composite_catalog_for_tests,
6435 empty_accepted_enum_catalog_for_tests, enum_catalog::ValueAdmissionBudget,
6436 },
6437 },
6438 error::InternalError,
6439 traits::{CanisterKind, Path},
6440 types::EntityTag,
6441 value::InputValue,
6442 };
6443 use icydb_schema::{
6444 ConstraintSourceKey, EntitySourceKey, FieldSourceKey, ScalarType, TypeSourceKey,
6445 };
6446 use std::{cell::RefCell, collections::BTreeMap};
6447
6448 const STORE_PATH: &str = "session::write::targeted_rule_mutation_tests::Store";
6449 const ENTITY_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity";
6450 const ID_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::id";
6451 const PROFILE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::profile";
6452 const UPDATED_AT_SOURCE: &str =
6453 "session::write::targeted_rule_mutation_tests::Entity::updated_at";
6454 const PROFILE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Profile";
6455 const DEGREE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Degree";
6456 const DEGREE_MEMBER_SOURCE: &str =
6457 "session::write::targeted_rule_mutation_tests::Profile::degree";
6458 const DEGREE_RULE_SOURCE: &str =
6459 "session::write::targeted_rule_mutation_tests::Profile::degree_multiple";
6460
6461 struct TestCanister;
6462
6463 impl Path for TestCanister {
6464 const PATH: &'static str = "session::write::targeted_rule_mutation_tests::Canister";
6465 }
6466
6467 impl CanisterKind for TestCanister {
6468 const COMMIT_MEMORY_ID: u8 = 43;
6469 const COMMIT_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.commit.v1";
6470 const STARTUP_MEMORY_ID: u8 = 49;
6471 const STARTUP_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.startup.control.v1";
6472 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 44;
6473 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
6474 "icydb.targeted_mutation_tests.integrity.progress.v1";
6475 }
6476
6477 thread_local! {
6478 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
6479 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
6480 static SCHEMA_STORE: RefCell<SchemaStore> =
6481 const { RefCell::new(SchemaStore::init_heap()) };
6482 static STORE_REGISTRY: StoreRegistry = {
6483 let mut registry = StoreRegistry::new();
6484 registry.register_store(
6485 STORE_PATH,
6486 &DATA_STORE,
6487 &INDEX_STORE,
6488 &SCHEMA_STORE,
6489 StoreAllocationIdentities::absent(),
6490 StoreRuntimeStorageCapabilities::heap(),
6491 ).expect("targeted mutation test store should register");
6492 registry
6493 };
6494 }
6495
6496 fn source<T, E: std::fmt::Debug>(raw: &str, parse: impl FnOnce(String) -> Result<T, E>) -> T {
6497 parse(raw.to_string()).expect("test source identity should admit")
6498 }
6499
6500 fn profile_input(degree: u64) -> InputValue {
6501 InputValue::Map(vec![(
6502 InputValue::Text("degree".to_string()),
6503 InputValue::Nat64(degree),
6504 )])
6505 }
6506
6507 fn structural_patch(id: u64, degree: u64) -> DynamicStructuralPatch {
6508 DynamicStructuralPatch::new(vec![
6509 (
6510 "id".to_string(),
6511 DynamicWriteCell::Value(InputValue::Nat64(id)),
6512 ),
6513 (
6514 "profile".to_string(),
6515 DynamicWriteCell::Value(profile_input(degree)),
6516 ),
6517 ])
6518 }
6519
6520 fn encoded_value(
6521 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
6522 composite_catalog: &AcceptedCompositeCatalog,
6523 name: &str,
6524 kind: &AcceptedFieldKind,
6525 storage_decode: FieldStorageDecode,
6526 leaf_codec: LeafCodec,
6527 value: InputValue,
6528 ) -> Vec<u8> {
6529 let field = AcceptedFieldDecodeContract::new(name, kind, false, storage_decode, leaf_codec);
6530 encode_input_value_for_candidate_field_contract(
6531 enum_catalog,
6532 composite_catalog,
6533 field,
6534 value,
6535 &mut ValueAdmissionBudget::standard(),
6536 )
6537 .expect("test accepted value should encode")
6538 }
6539
6540 fn nat64_literal(
6541 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
6542 composite_catalog: &AcceptedCompositeCatalog,
6543 value: u64,
6544 ) -> AcceptedCheckLiteralV1 {
6545 let kind = AcceptedFieldKind::Nat64;
6546 AcceptedCheckLiteralV1::from_accepted_parts(
6547 kind.clone(),
6548 FieldStorageDecode::ByKind,
6549 LeafCodec::Scalar(ScalarCodec::Nat64),
6550 encoded_value(
6551 enum_catalog,
6552 composite_catalog,
6553 "degree_bound",
6554 &kind,
6555 FieldStorageDecode::ByKind,
6556 LeafCodec::Scalar(ScalarCodec::Nat64),
6557 InputValue::Nat64(value),
6558 ),
6559 )
6560 }
6561
6562 fn targeted_constraint_id(error: &InternalError) -> u32 {
6563 let facts = error.diagnostic_facts();
6564 assert!(facts.contains(&(
6565 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
6566 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
6567 )));
6568 assert!(facts.contains(&(icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,)));
6569 assert!(facts.contains(&(
6570 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
6571 icydb_diagnostic_code::DiagnosticConstraintKind::TargetedRule.raw(),
6572 )));
6573 assert_eq!(
6574 facts
6575 .iter()
6576 .filter(|(tag, _)| matches!(
6577 tag,
6578 icydb_diagnostic_code::DiagnosticFactTag::RootField
6579 | icydb_diagnostic_code::DiagnosticFactTag::RecordMember
6580 ))
6581 .copied()
6582 .collect::<Vec<_>>(),
6583 vec![
6584 (icydb_diagnostic_code::DiagnosticFactTag::RootField, 2),
6585 (
6586 icydb_diagnostic_code::DiagnosticFactTag::RecordMember,
6587 icydb_diagnostic_code::pack_u32_pair(1, 1),
6588 ),
6589 ]
6590 );
6591 let value = facts
6592 .iter()
6593 .find_map(|(tag, value)| {
6594 (*tag == icydb_diagnostic_code::DiagnosticFactTag::ConstraintId).then_some(*value)
6595 })
6596 .expect("targeted mutation should retain its accepted constraint ID");
6597 u32::try_from(value).expect("accepted constraint ID fits u32")
6598 }
6599
6600 #[expect(
6601 clippy::too_many_lines,
6602 reason = "one end-to-end fixture proves every maintained write frontend converges on the same accepted targeted-rule schedule"
6603 )]
6604 #[test]
6605 fn targeted_rules_converge_across_dynamic_typed_sql_default_timestamp_and_batch_writes() {
6606 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
6607 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
6608 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
6609
6610 let entity_tag = EntityTag::new(93);
6611 let enum_catalog = empty_accepted_enum_catalog_for_tests();
6612 let (composite_catalog, profile_type, degree_type, degree_member) =
6613 build_record_newtype_composite_catalog_for_tests(
6614 "tests::TargetedProfile".to_string(),
6615 "degree".to_string(),
6616 "tests::TargetedDegree".to_string(),
6617 AcceptedFieldKind::Nat64,
6618 &enum_catalog,
6619 )
6620 .expect("targeted mutation composites should close");
6621 let profile_kind = AcceptedFieldKind::Composite {
6622 type_id: profile_type,
6623 };
6624 let profile_default = encoded_value(
6625 &enum_catalog,
6626 &composite_catalog,
6627 "profile",
6628 &profile_kind,
6629 FieldStorageDecode::CatalogValue,
6630 LeafCodec::Structural,
6631 profile_input(12),
6632 );
6633 let fields = vec![
6634 PersistedFieldSnapshot::new_initial(
6635 FieldId::new(1),
6636 "id".to_string(),
6637 SchemaFieldSlot::new(0),
6638 AcceptedFieldKind::Nat64,
6639 Vec::new(),
6640 false,
6641 SchemaInsertDefault::None,
6642 FieldStorageDecode::ByKind,
6643 LeafCodec::Scalar(ScalarCodec::Nat64),
6644 ),
6645 PersistedFieldSnapshot::new_initial(
6646 FieldId::new(2),
6647 "profile".to_string(),
6648 SchemaFieldSlot::new(1),
6649 profile_kind,
6650 vec![PersistedNestedLeafSnapshot::new(
6651 vec!["degree".to_string()],
6652 AcceptedFieldKind::Composite {
6653 type_id: degree_type,
6654 },
6655 false,
6656 )],
6657 false,
6658 SchemaInsertDefault::SlotPayload(profile_default),
6659 FieldStorageDecode::CatalogValue,
6660 LeafCodec::Structural,
6661 ),
6662 PersistedFieldSnapshot::new_initial_with_write_policy(
6663 FieldId::new(3),
6664 "updated_at".to_string(),
6665 SchemaFieldSlot::new(2),
6666 AcceptedFieldKind::Timestamp,
6667 Vec::new(),
6668 false,
6669 SchemaInsertDefault::None,
6670 SchemaFieldWritePolicy::from_model_policies(
6671 None,
6672 Some(FieldWriteManagement::UpdatedAt),
6673 ),
6674 FieldStorageDecode::ByKind,
6675 LeafCodec::Scalar(ScalarCodec::Timestamp),
6676 ),
6677 ];
6678 let mut snapshot = PersistedSchemaSnapshot::new(
6679 SchemaVersion::initial(),
6680 ENTITY_SOURCE.to_string(),
6681 "TargetedMutation".to_string(),
6682 FieldId::new(1),
6683 SchemaRowLayout::initial(
6684 fields
6685 .iter()
6686 .map(|field| (field.id(), field.slot()))
6687 .collect(),
6688 ),
6689 fields,
6690 );
6691 let constraint_catalog = snapshot
6692 .constraint_catalog()
6693 .clone()
6694 .with_added_targeted_rule(
6695 "profile_degree_multiple".to_string(),
6696 ConstraintOrigin::Generated,
6697 AcceptedRuleTarget::new(
6698 FieldId::new(2),
6699 AcceptedNamedTypeIdentity::Composite(degree_type),
6700 ),
6701 AcceptedRuleOperation::MultipleOf {
6702 divisor: nat64_literal(&enum_catalog, &composite_catalog, 5),
6703 },
6704 )
6705 .expect("targeted mutation rule should allocate");
6706 let targeted_rule_id = constraint_catalog
6707 .constraints()
6708 .last()
6709 .expect("targeted mutation rule should persist")
6710 .id();
6711 snapshot = snapshot.with_constraint_catalog(constraint_catalog);
6712
6713 let entity_source = source(ENTITY_SOURCE, EntitySourceKey::try_new);
6714 let id_source = source(ID_SOURCE, FieldSourceKey::try_new);
6715 let profile_source = source(PROFILE_SOURCE, FieldSourceKey::try_new);
6716 let updated_at_source = source(UPDATED_AT_SOURCE, FieldSourceKey::try_new);
6717 let profile_type_source = source(PROFILE_TYPE_SOURCE, TypeSourceKey::try_new);
6718 let degree_type_source = source(DEGREE_TYPE_SOURCE, TypeSourceKey::try_new);
6719 let degree_member_source = source(DEGREE_MEMBER_SOURCE, FieldSourceKey::try_new);
6720 let degree_rule_source = source(DEGREE_RULE_SOURCE, ConstraintSourceKey::try_new);
6721 let source_bindings = AcceptedSourceBindingCatalog::initial_for_tests(
6722 BTreeMap::from([(entity_source, entity_tag)]),
6723 BTreeMap::from([
6724 ((entity_tag, id_source), FieldId::new(1)),
6725 ((entity_tag, profile_source), FieldId::new(2)),
6726 ((entity_tag, updated_at_source), FieldId::new(3)),
6727 ]),
6728 BTreeMap::from([((entity_tag, degree_rule_source), targeted_rule_id)]),
6729 BTreeMap::new(),
6730 BTreeMap::new(),
6731 )
6732 .with_initial_named_types_for_tests(
6733 BTreeMap::from([
6734 (
6735 profile_type_source,
6736 AcceptedNamedTypeIdentity::Composite(profile_type),
6737 ),
6738 (
6739 degree_type_source,
6740 AcceptedNamedTypeIdentity::Composite(degree_type),
6741 ),
6742 ]),
6743 BTreeMap::new(),
6744 BTreeMap::from([((profile_type, degree_member_source), degree_member)]),
6745 );
6746 let candidate = accepted_schema_candidate_with_catalogs_for_tests(
6747 STORE_PATH,
6748 AcceptedSchemaRevision::INITIAL,
6749 enum_catalog,
6750 composite_catalog,
6751 source_bindings,
6752 BTreeMap::from([(entity_tag, snapshot)]),
6753 );
6754
6755 let session = DbSession::<TestCanister>::new(
6756 &STORE_REGISTRY,
6757 &crate::db::RequestExecutionRoot::__new_runtime_root(),
6758 );
6759 session
6760 .db
6761 .drive_startup_recovery_page()
6762 .expect("targeted mutation test database should initialize");
6763 let store = session
6764 .db
6765 .store_handle(STORE_PATH)
6766 .expect("targeted mutation test store should resolve");
6767 crate::db::commit::publish_accepted_schema_candidate(
6768 STORE_PATH,
6769 store,
6770 AcceptedSchemaRevision::NONE,
6771 &candidate,
6772 )
6773 .expect("targeted mutation candidate should publish");
6774
6775 let dynamic_error = session
6776 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
6777 entity: "TargetedMutation".to_string(),
6778 patch: structural_patch(1, 12),
6779 })
6780 .expect_err("dynamic write must enforce the targeted rule");
6781 assert_eq!(
6782 targeted_constraint_id(&dynamic_error),
6783 targeted_rule_id.get()
6784 );
6785
6786 let binding = session
6787 .issue_typed_entity_binding(
6788 ENTITY_SOURCE,
6789 &[
6790 DynamicTypedFieldBindingRequest::new(
6791 ID_SOURCE.to_string(),
6792 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
6793 false,
6794 ),
6795 DynamicTypedFieldBindingRequest::new(
6796 PROFILE_SOURCE.to_string(),
6797 DynamicTypedFieldType::Named(PROFILE_TYPE_SOURCE.to_string()),
6798 false,
6799 ),
6800 DynamicTypedFieldBindingRequest::new(
6801 UPDATED_AT_SOURCE.to_string(),
6802 DynamicTypedFieldType::Scalar(ScalarType::Timestamp),
6803 false,
6804 ),
6805 ],
6806 )
6807 .expect("targeted typed binding should issue");
6808 let typed_patch = binding
6809 .bind_write_fields(vec![
6810 (
6811 ID_SOURCE.to_string(),
6812 DynamicWriteCell::Value(InputValue::Nat64(2)),
6813 ),
6814 (
6815 PROFILE_SOURCE.to_string(),
6816 DynamicWriteCell::Value(profile_input(12)),
6817 ),
6818 ])
6819 .expect("targeted typed patch should bind");
6820 let typed_error = session
6821 .execute_trusted_typed_mutation(
6822 &binding,
6823 &DynamicTypedMutation::Insert { patch: typed_patch },
6824 )
6825 .expect_err("typed write must enforce the targeted rule");
6826 assert_eq!(targeted_constraint_id(&typed_error), targeted_rule_id.get());
6827
6828 #[cfg(feature = "sql")]
6829 {
6830 let sql_error = session
6831 .execute_trusted_sql_mutation("INSERT INTO TargetedMutation (id) VALUES (3)")
6832 .expect_err("SQL default resolution must enforce the targeted rule");
6833 let crate::db::QueryError::Execute(execute) = sql_error else {
6834 panic!("targeted SQL write should fail at shared execution admission");
6835 };
6836 assert_eq!(
6837 targeted_constraint_id(execute.as_internal()),
6838 targeted_rule_id.get()
6839 );
6840 }
6841
6842 session
6843 .execute_trusted_dynamic_mutation_batch(vec![
6844 DynamicMutation::Insert {
6845 entity: "TargetedMutation".to_string(),
6846 patch: structural_patch(4, 5),
6847 },
6848 DynamicMutation::Insert {
6849 entity: "TargetedMutation".to_string(),
6850 patch: structural_patch(5, 12),
6851 },
6852 ])
6853 .expect_err("one invalid targeted value must reject the whole batch");
6854 assert_eq!(
6855 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
6856 Some(0),
6857 "no frontend or earlier valid batch row may escape targeted admission",
6858 );
6859
6860 let admitted = session
6861 .execute_trusted_dynamic_mutation_batch(vec![
6862 DynamicMutation::Insert {
6863 entity: "TargetedMutation".to_string(),
6864 patch: structural_patch(6, 5),
6865 },
6866 DynamicMutation::Insert {
6867 entity: "TargetedMutation".to_string(),
6868 patch: structural_patch(7, 10),
6869 },
6870 ])
6871 .expect("compliant targeted values should share one accepted batch");
6872 let [first, second] = admitted.rows.as_slice() else {
6873 panic!("the mixed targeted batch should return two rows");
6874 };
6875 let first_timestamp = first
6876 .get(2)
6877 .expect("the first mixed row should contain its managed timestamp");
6878 assert!(matches!(
6879 first_timestamp,
6880 crate::value::OutputValue::Timestamp(_)
6881 ));
6882 assert_eq!(
6883 second.get(2),
6884 Some(first_timestamp),
6885 "one accepted mixed batch must materialize one managed timestamp",
6886 );
6887 assert_eq!(
6888 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
6889 Some(2),
6890 );
6891 }
6892}