1#[cfg(test)]
8mod key_handoff_tests;
9#[cfg(test)]
10mod output_handoff_tests;
11
12use super::AcceptedSchemaCatalogContext;
13use crate::{
14 db::{
15 DbSession, DynamicMutation, DynamicMutationResult, DynamicStructuralPatch,
16 DynamicTypedBindingError, DynamicTypedEntityBinding, DynamicTypedMutation,
17 DynamicTypedStructuralPatch, DynamicWriteCell, TypedEntityDescriptor, TypedFieldType,
18 commit::{CommitRowOp, database_incarnation_id},
19 data::{
20 AcceptedMutationIntentPatch, AcceptedPreKeyInsert, DecodedDataStoreKey, FieldSlot,
21 RawRow, StructuralRowContract, StructuralSlotReader,
22 canonical_row_from_raw_row_with_accepted_decode_contract,
23 resolve_existing_replace_structural_patch_with_accepted_contract,
24 resolve_insert_structural_patch_with_accepted_contract,
25 resolve_update_structural_patch_with_accepted_contract,
26 },
27 executor::{
28 AcceptedMutationConstraintContext, AcceptedMutationConstraintScheduler,
29 budget::finish_current_execution_instruction_watermark,
30 commit_structural_row_ops_with_mutation_progress,
31 commit_structural_row_ops_with_window, mutation_key_exists_error,
32 },
33 integrity::MutationProgressRecordOp,
34 schema::{
35 AcceptedFieldKind, AcceptedIdentityAllocation, AcceptedRowLayoutRuntimeContract,
36 AcceptedRowLayoutRuntimeField, FieldId, FieldInsertGeneration, IdentityStatementCursor,
37 lower_field_type, output_value_from_runtime,
38 },
39 write_context::{AcceptedWriteContext, MutationMode},
40 },
41 error::{InternalError, MutationDiagnosticContext},
42 metrics::EntityMetricsSpan,
43 traits::CanisterKind,
44 types::{CurrentTimestamp, Timestamp},
45 value::{InputValue, Value},
46};
47use icydb_schema::{EntitySourceKey, FieldSourceKey, FieldType, TypeSourceKey};
48
49#[derive(Clone, Debug, Eq, PartialEq)]
50struct AcceptedIdentityInsertField {
51 field_id: FieldId,
52 field_slot: usize,
53 accepted_kind: AcceptedFieldKind,
54}
55
56struct AcceptedStructuralMutationCommitOptions {
57 capture_output_values: bool,
58 packing: AcceptedStructuralMutationPacking,
59}
60
61impl AcceptedStructuralMutationCommitOptions {
62 const fn standard() -> Self {
63 Self {
64 capture_output_values: true,
65 packing: AcceptedStructuralMutationPacking::Complete,
66 }
67 }
68
69 #[cfg(test)]
70 const fn with_mutation_progress() -> Self {
71 Self {
72 capture_output_values: false,
73 packing: AcceptedStructuralMutationPacking::Complete,
74 }
75 }
76
77 const fn bounded_prefix() -> Self {
78 Self {
79 capture_output_values: false,
80 packing: AcceptedStructuralMutationPacking::BoundedPrefix,
81 }
82 }
83}
84
85#[derive(Clone, Copy)]
86enum AcceptedStructuralMutationPacking {
87 Complete,
88 BoundedPrefix,
89}
90
91pub(in crate::db::session) enum AcceptedStructuralMutationCommitDirective {
92 Standard,
93 WithMutationProgress(MutationProgressRecordOp),
94 Skip,
95}
96
97pub(in crate::db::session) enum AcceptedStructuralMutationTarget {
100 ResolveFromAfterImage,
101 Expected(Box<DecodedDataStoreKey>),
102 ExpectedLoaded(AcceptedLoadedStructuralRow),
103}
104
105pub(in crate::db::session) struct AcceptedLoadedStructuralRow {
108 key: Box<DecodedDataStoreKey>,
109 row: RawRow,
110}
111
112impl AcceptedLoadedStructuralRow {
113 pub(in crate::db::session) fn from_validated_parts(
114 key: DecodedDataStoreKey,
115 row: RawRow,
116 ) -> Self {
117 Self {
118 key: Box::new(key),
119 row,
120 }
121 }
122
123 fn into_parts(self) -> (DecodedDataStoreKey, RawRow) {
124 (*self.key, self.row)
125 }
126}
127
128impl AcceptedStructuralMutationTarget {
129 pub(in crate::db::session) fn expected(key: DecodedDataStoreKey) -> Self {
130 Self::Expected(Box::new(key))
131 }
132
133 pub(in crate::db::session) const fn expected_loaded(row: AcceptedLoadedStructuralRow) -> Self {
136 Self::ExpectedLoaded(row)
137 }
138}
139
140pub(in crate::db::session) enum AcceptedStructuralMutation {
143 Save {
144 mode: MutationMode,
145 target: AcceptedStructuralMutationTarget,
146 patch: AcceptedMutationIntentPatch,
147 },
148 Delete {
149 key: Box<DecodedDataStoreKey>,
150 },
151}
152
153impl AcceptedStructuralMutation {
154 pub(in crate::db::session) const fn save(
155 mode: MutationMode,
156 target: AcceptedStructuralMutationTarget,
157 patch: AcceptedMutationIntentPatch,
158 ) -> Self {
159 Self::Save {
160 mode,
161 target,
162 patch,
163 }
164 }
165
166 pub(in crate::db::session) fn delete(key: DecodedDataStoreKey) -> Self {
167 Self::Delete { key: Box::new(key) }
168 }
169}
170
171const MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS: usize = 4_096;
172const MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES: usize = 64;
173pub(in crate::db::session) const STRUCTURAL_MUTATION_BATCH_STAGED_BYTES_POLICY: u32 =
174 16 * 1024 * 1024;
175pub(in crate::db::session) const MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES: usize =
176 STRUCTURAL_MUTATION_BATCH_STAGED_BYTES_POLICY as usize;
177const MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES: usize = 1024 * 1024;
178
179struct AcceptedStructuralMutationBatchItem {
180 catalog: AcceptedSchemaCatalogContext,
181 mutation: AcceptedStructuralMutation,
182}
183
184struct AcceptedStructuralMutationEntityState {
185 entity_tag: crate::types::EntityTag,
186 identity_field: Option<AcceptedIdentityInsertField>,
187 identity_incarnation: Option<crate::db::integrity::DatabaseIncarnationId>,
188 identity_cursor: Option<IdentityStatementCursor>,
189 identity_insert_ordinal: u32,
190}
191
192#[derive(Clone, Copy, Debug, Eq, PartialEq)]
193pub(in crate::db::session) struct AcceptedStructuralMutationPackingReport {
194 admitted_mutations: usize,
195 staged_bytes: usize,
196 stopped_before_candidate: bool,
197 candidate_exceeds_batch_policy: bool,
198}
199
200impl AcceptedStructuralMutationPackingReport {
201 #[must_use]
202 pub(in crate::db::session) const fn admitted_mutations(self) -> usize {
203 self.admitted_mutations
204 }
205
206 #[must_use]
207 pub(in crate::db::session) const fn stopped_before_candidate(self) -> bool {
208 self.stopped_before_candidate
209 }
210
211 #[must_use]
212 pub(in crate::db::session) const fn candidate_exceeds_batch_policy(self) -> bool {
213 self.candidate_exceeds_batch_policy
214 }
215}
216
217fn structural_mutation_staged_charge(
218 lengths: impl IntoIterator<Item = usize>,
219) -> Result<usize, InternalError> {
220 lengths.into_iter().try_fold(0_usize, |total, length| {
221 total.checked_add(length).ok_or_else(|| {
222 InternalError::mutation_batch_staged_bytes_exceeded(
223 None,
224 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
225 )
226 })
227 })
228}
229
230fn add_structural_mutation_staged_bytes(
231 total: &mut usize,
232 lengths: impl IntoIterator<Item = usize>,
233) -> Result<(), InternalError> {
234 let charge = structural_mutation_staged_charge(lengths)?;
235 *total = total.checked_add(charge).ok_or_else(|| {
236 InternalError::mutation_batch_staged_bytes_exceeded(
237 None,
238 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
239 )
240 })?;
241 if *total > MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES {
242 return Err(InternalError::mutation_batch_staged_bytes_exceeded(
243 Some(*total),
244 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
245 ));
246 }
247 Ok(())
248}
249
250fn admit_structural_mutation_staged_charge(
251 total: &mut usize,
252 lengths: impl IntoIterator<Item = usize>,
253 packing: AcceptedStructuralMutationPacking,
254) -> Result<AcceptedStructuralMutationStagedAdmission, InternalError> {
255 if matches!(packing, AcceptedStructuralMutationPacking::Complete) {
256 add_structural_mutation_staged_bytes(total, lengths)?;
257 return Ok(AcceptedStructuralMutationStagedAdmission::Admitted);
258 }
259
260 let charge = structural_mutation_staged_charge(lengths)?;
261 if charge > MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES {
262 return Ok(AcceptedStructuralMutationStagedAdmission::CandidateExceedsPolicy);
263 }
264 let Some(next_total) = total.checked_add(charge) else {
265 return Ok(AcceptedStructuralMutationStagedAdmission::PageFull);
266 };
267 if next_total > MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES {
268 return Ok(AcceptedStructuralMutationStagedAdmission::PageFull);
269 }
270 *total = next_total;
271 Ok(AcceptedStructuralMutationStagedAdmission::Admitted)
272}
273
274#[derive(Clone, Copy, Debug, Eq, PartialEq)]
275enum AcceptedStructuralMutationStagedAdmission {
276 Admitted,
277 PageFull,
278 CandidateExceedsPolicy,
279}
280
281fn validate_structural_mutation_result_bytes(encoded_bytes: usize) -> Result<(), InternalError> {
282 if encoded_bytes > MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES {
283 return Err(InternalError::mutation_batch_result_bytes_exceeded(
284 encoded_bytes,
285 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES,
286 ));
287 }
288 Ok(())
289}
290
291pub(in crate::db::session) struct AcceptedStructuralMutationRow {
293 values: Vec<Value>,
294 logical_changed: bool,
295}
296
297impl AcceptedStructuralMutationRow {
298 #[cfg(any(feature = "sql", test))]
299 pub(in crate::db::session) fn into_values(self) -> Vec<Value> {
300 self.values
301 }
302
303 pub(in crate::db::session) const fn logical_changed(&self) -> bool {
304 self.logical_changed
305 }
306}
307
308fn mutation_diagnostic_context(
309 catalog: &AcceptedSchemaCatalogContext,
310 mode: MutationMode,
311 batch_position: u32,
312) -> MutationDiagnosticContext {
313 MutationDiagnosticContext::new(
314 catalog.fingerprint_method_version(),
315 catalog.fingerprint(),
316 catalog.identity().entity_tag().value(),
317 mode.diagnostic_operation(),
318 batch_position,
319 )
320}
321
322const fn dynamic_write_context(operation_timestamp: Timestamp) -> AcceptedWriteContext {
323 AcceptedWriteContext::new(operation_timestamp)
324}
325
326fn insert_key_exists_after_generation(identity_generated: bool) -> InternalError {
327 if identity_generated {
328 InternalError::identity_state_corruption()
329 } else {
330 mutation_key_exists_error()
331 }
332}
333
334fn dynamic_key(
335 entity_tag: crate::types::EntityTag,
336 key: InputValue,
337) -> Result<DecodedDataStoreKey, InternalError> {
338 let value = key
339 .try_into_runtime_non_enum()
340 .ok_or_else(InternalError::executor_unsupported)?;
341 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
342}
343
344fn lower_resolved_write_cell(
345 lowered: AcceptedMutationIntentPatch,
346 field: &AcceptedRowLayoutRuntimeField<'_>,
347 cell: DynamicWriteCell,
348 mode: MutationMode,
349 mutation_context: MutationDiagnosticContext,
350) -> Result<AcceptedMutationIntentPatch, InternalError> {
351 if !matches!(cell, DynamicWriteCell::Omitted)
352 && (field.write_policy().insert_generation().is_some()
353 || field.write_policy().write_management().is_some())
354 {
355 return Err(InternalError::mutation_database_owned_field_explicit(
356 mutation_context,
357 field.field_id().get(),
358 ));
359 }
360
361 let slot = FieldSlot::from_validated_index(usize::from(field.slot().get()));
362 Ok(match cell {
363 DynamicWriteCell::Omitted => lowered,
364 DynamicWriteCell::Default => match mode {
365 MutationMode::Insert | MutationMode::Replace => {
366 lowered.set_explicit_insert_default(slot)
367 }
368 MutationMode::Update => lowered.set_explicit_update_default(slot),
369 },
370 DynamicWriteCell::Null => lowered.set_authored(slot, InputValue::null()),
371 DynamicWriteCell::Value(value) => lowered.set_authored(slot, value),
372 })
373}
374
375fn lower_dynamic_patch(
376 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
377 patch: DynamicStructuralPatch,
378 mode: MutationMode,
379 mutation_context: MutationDiagnosticContext,
380) -> Result<AcceptedMutationIntentPatch, InternalError> {
381 let mut lowered = AcceptedMutationIntentPatch::new();
382 for (field_name, cell) in patch.into_fields() {
383 let slot = descriptor
384 .field_slot_index_by_name(&field_name)
385 .ok_or_else(InternalError::executor_unsupported)?;
386 let field = descriptor
387 .field_for_slot_index(slot)
388 .ok_or_else(InternalError::executor_invariant)?;
389 lowered = lower_resolved_write_cell(lowered, field, cell, mode, mutation_context)?;
390 }
391 Ok(lowered)
392}
393
394fn lower_dynamic_save_intent(
395 catalog: &AcceptedSchemaCatalogContext,
396 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
397 patch: DynamicStructuralPatch,
398 mode: MutationMode,
399 target: AcceptedStructuralMutationTarget,
400 batch_position: u32,
401) -> Result<AcceptedStructuralMutation, InternalError> {
402 Ok(AcceptedStructuralMutation::save(
403 mode,
404 target,
405 lower_dynamic_patch(
406 descriptor,
407 patch,
408 mode,
409 mutation_diagnostic_context(catalog, mode, batch_position),
410 )?,
411 ))
412}
413
414fn lower_dynamic_mutation_intent(
415 catalog: &AcceptedSchemaCatalogContext,
416 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
417 request: DynamicMutation,
418 batch_position: u32,
419) -> Result<AcceptedStructuralMutation, InternalError> {
420 let entity_tag = catalog.identity().entity_tag();
421 match request {
422 DynamicMutation::Insert { patch, .. } => lower_dynamic_save_intent(
423 catalog,
424 descriptor,
425 patch,
426 MutationMode::Insert,
427 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
428 batch_position,
429 ),
430 DynamicMutation::Update { key, patch, .. } => lower_dynamic_save_intent(
431 catalog,
432 descriptor,
433 patch,
434 MutationMode::Update,
435 AcceptedStructuralMutationTarget::expected(dynamic_key(entity_tag, key)?),
436 batch_position,
437 ),
438 DynamicMutation::Replace { key, patch, .. } => lower_dynamic_save_intent(
439 catalog,
440 descriptor,
441 patch,
442 MutationMode::Replace,
443 AcceptedStructuralMutationTarget::expected(dynamic_key(entity_tag, key)?),
444 batch_position,
445 ),
446 DynamicMutation::Delete { key, .. } => Ok(AcceptedStructuralMutation::delete(dynamic_key(
447 entity_tag, key,
448 )?)),
449 }
450}
451
452fn lower_typed_patch(
453 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
454 binding: &DynamicTypedEntityBinding,
455 patch: DynamicTypedStructuralPatch,
456 mode: MutationMode,
457 mutation_context: MutationDiagnosticContext,
458) -> Result<AcceptedMutationIntentPatch, InternalError> {
459 let mut lowered = AcceptedMutationIntentPatch::new();
460 for (descriptor_ordinal, cell) in patch.into_fields() {
461 let (field_id, slot) = binding
462 .field_identity_binding(descriptor_ordinal)
463 .ok_or_else(InternalError::store_invariant)?;
464 let slot_index = usize::from(slot);
465 let field = descriptor
466 .field_for_slot_index(slot_index)
467 .ok_or_else(InternalError::store_invariant)?;
468 if field.field_id().get() != field_id {
469 return Err(InternalError::store_invariant());
470 }
471 lowered = lower_resolved_write_cell(lowered, field, cell, mode, mutation_context)?;
472 }
473 Ok(lowered)
474}
475
476fn lower_typed_mutation_intent(
477 catalog: &AcceptedSchemaCatalogContext,
478 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
479 binding: &DynamicTypedEntityBinding,
480 request: DynamicTypedMutation,
481 batch_position: u32,
482) -> Result<Option<AcceptedStructuralMutation>, InternalError> {
483 let entity_tag = catalog.identity().entity_tag();
484 let (mode, target, patch) = match request {
485 DynamicTypedMutation::Insert { patch } => (
486 MutationMode::Insert,
487 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
488 patch,
489 ),
490 DynamicTypedMutation::Update { key, patch } => (
491 MutationMode::Update,
492 AcceptedStructuralMutationTarget::expected(dynamic_key(entity_tag, key)?),
493 patch,
494 ),
495 DynamicTypedMutation::Replace { key, patch } => (
496 MutationMode::Replace,
497 AcceptedStructuralMutationTarget::expected(dynamic_key(entity_tag, key)?),
498 patch,
499 ),
500 DynamicTypedMutation::Delete { key } => {
501 return Ok(Some(AcceptedStructuralMutation::delete(dynamic_key(
502 entity_tag, key,
503 )?)));
504 }
505 };
506 if !patch.is_bound_to(binding) {
507 return Ok(None);
508 }
509 let patch = lower_typed_patch(
510 descriptor,
511 binding,
512 patch,
513 mode,
514 mutation_diagnostic_context(catalog, mode, batch_position),
515 )?;
516 Ok(Some(AcceptedStructuralMutation::save(mode, target, patch)))
517}
518
519fn preserve_dynamic_replacement_identity(
520 key: &DecodedDataStoreKey,
521 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
522 mut patch: AcceptedMutationIntentPatch,
523) -> Result<AcceptedMutationIntentPatch, InternalError> {
524 let primary_key_slots = descriptor.primary_key_slot_indices();
525 let runtime_key = key.primary_key_runtime_value();
526 let components = match runtime_key {
527 Value::List(values) if primary_key_slots.len() > 1 => values,
528 value if primary_key_slots.len() == 1 => vec![value],
529 _ => return Err(InternalError::executor_invariant()),
530 };
531 if components.len() != primary_key_slots.len() {
532 return Err(InternalError::executor_invariant());
533 }
534
535 for (slot, value) in primary_key_slots.iter().copied().zip(components) {
536 let _ = descriptor
537 .field_for_slot_index(slot)
538 .ok_or_else(InternalError::executor_invariant)?;
539 let has_explicit_intent = patch
540 .entries()
541 .iter()
542 .any(|entry| entry.slot().index() == slot);
543 if has_explicit_intent {
544 continue;
545 }
546 let value = InputValue::try_from_runtime_non_enum(&value)
547 .ok_or_else(InternalError::executor_invariant)?;
548 patch =
549 patch.set_preserved_replacement_identity(FieldSlot::from_validated_index(slot), value);
550 }
551
552 Ok(patch)
553}
554
555fn accepted_identity_insert_field(
559 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
560) -> Result<Option<AcceptedIdentityInsertField>, InternalError> {
561 let mut identity = None;
562 for field in descriptor.fields() {
563 if field.write_policy().insert_generation() != Some(FieldInsertGeneration::Identity) {
564 continue;
565 }
566 let field_slot = usize::from(field.slot().get());
567 if identity
568 .replace(AcceptedIdentityInsertField {
569 field_id: field.field_id(),
570 field_slot,
571 accepted_kind: field.kind().clone(),
572 })
573 .is_some()
574 || descriptor.primary_key_slot_indices() != [field_slot]
575 {
576 return Err(InternalError::identity_corruption());
577 }
578 }
579 Ok(identity)
580}
581
582fn checked_pre_key_candidate_count(count: usize) -> Result<u32, InternalError> {
583 u32::try_from(count).map_err(|_| InternalError::identity_candidate_count_exhausted())
584}
585
586fn validate_identity_materialization(
587 entity_tag: crate::types::EntityTag,
588 identity_field: &AcceptedIdentityInsertField,
589 candidate: &AcceptedPreKeyInsert,
590 allocation: &AcceptedIdentityAllocation,
591 data_key: &DecodedDataStoreKey,
592 reader: &StructuralSlotReader<'_>,
593) -> Result<(), InternalError> {
594 let owner = allocation.owner();
595 let slot_value = reader.required_cached_value(identity_field.field_slot)?;
596 if candidate.entity_tag() != entity_tag
597 || candidate.input_ordinal() != allocation.input_ordinal()
598 || owner.entity_tag() != entity_tag
599 || owner.field_id() != identity_field.field_id
600 || allocation.field_slot() != identity_field.field_slot
601 || slot_value != allocation.value()
602 || data_key.primary_key_runtime_value() != *allocation.value()
603 {
604 return Err(InternalError::identity_corruption());
605 }
606 Ok(())
607}
608
609fn data_key_from_validated_reader(
612 entity_tag: crate::types::EntityTag,
613 reader: &StructuralSlotReader<'_>,
614) -> Result<DecodedDataStoreKey, InternalError> {
615 let values = reader
616 .contract()
617 .primary_key_slot_indices()
618 .iter()
619 .map(|slot| reader.required_cached_value(*slot).cloned())
620 .collect::<Result<Vec<_>, _>>()?;
621
622 DecodedDataStoreKey::try_from_structural_key_values(entity_tag, &values)
623}
624
625fn validated_existing_row(
626 store: crate::db::registry::StoreHandle,
627 data_key: &DecodedDataStoreKey,
628 contract: &StructuralRowContract,
629) -> Result<Option<RawRow>, InternalError> {
630 let raw_key = data_key.to_raw()?;
631 let row = store.with_data(|data| data.get(&raw_key));
632 if let Some(row) = row.as_ref() {
633 let reader =
634 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(row, contract)?;
635 reader.validate_primary_key(data_key)?;
636 }
637 Ok(row)
638}
639
640fn into_mutation_output_values(
643 mut reader: StructuralSlotReader<'_>,
644 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
645) -> Result<Vec<Value>, InternalError> {
646 let mut values = Vec::with_capacity(descriptor.fields().len());
647 for field in descriptor.fields() {
648 values.push(reader.take_required_value(usize::from(field.slot().get()))?);
649 }
650 Ok(values)
651}
652
653fn prepare_dynamic_mutation_result(
654 catalog: &AcceptedSchemaCatalogContext,
655 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
656 rows: Vec<AcceptedStructuralMutationRow>,
657 enforce_mixed_batch_result_bound: bool,
658) -> Result<DynamicMutationResult, InternalError> {
659 let affected_rows = rows.iter().try_fold(0_u32, |total, row| {
660 total
661 .checked_add(u32::from(row.logical_changed()))
662 .ok_or_else(InternalError::executor_invariant)
663 })?;
664 let columns = descriptor
665 .fields()
666 .iter()
667 .map(|field| field.name().to_string())
668 .collect();
669 let rows = rows
670 .into_iter()
671 .map(|row| {
672 row.values
673 .into_iter()
674 .map(|value| {
675 output_value_from_runtime(catalog.enum_catalog(), value)
676 .map_err(|_| InternalError::store_invariant())
677 })
678 .collect::<Result<Vec<_>, _>>()
679 })
680 .collect::<Result<Vec<_>, _>>()?;
681 let result = DynamicMutationResult {
682 entity: catalog.snapshot().entity_name().to_string(),
683 columns,
684 rows,
685 affected_rows,
686 };
687 if enforce_mixed_batch_result_bound {
688 let encoded =
689 candid::encode_one(&result).map_err(|_| InternalError::executor_invariant())?;
690 validate_structural_mutation_result_bytes(encoded.len())?;
691 }
692 Ok(result)
693}
694
695fn typed_descriptor_field_type(
696 field_type: TypedFieldType,
697) -> Result<FieldType, DynamicTypedBindingError> {
698 match field_type {
699 TypedFieldType::Scalar(scalar) => Ok(FieldType::Scalar(scalar)),
700 TypedFieldType::List(item) => Ok(FieldType::List(Box::new(typed_descriptor_field_type(
701 *item,
702 )?))),
703 TypedFieldType::Named(source_key) => TypeSourceKey::try_new(source_key.to_string())
704 .map(FieldType::Named)
705 .map_err(|_| DynamicTypedBindingError::FieldUnavailable),
706 }
707}
708
709fn typed_adapter_field_kind_matches(
710 accepted: &AcceptedFieldKind,
711 expected: &AcceptedFieldKind,
712) -> bool {
713 if accepted == expected {
714 return true;
715 }
716 match (accepted, expected) {
717 (AcceptedFieldKind::Relation { key_kind, .. }, expected) => {
718 typed_adapter_field_kind_matches(key_kind, expected)
719 }
720 (AcceptedFieldKind::List(accepted), AcceptedFieldKind::List(expected)) => {
721 typed_adapter_field_kind_matches(accepted, expected)
722 }
723 _ => false,
724 }
725}
726
727impl<C: CanisterKind> DbSession<C> {
728 pub fn issue_typed_entity_binding(
730 &self,
731 descriptor: &TypedEntityDescriptor,
732 ) -> Result<DynamicTypedEntityBinding, DynamicTypedBindingError> {
733 let entity_source = EntitySourceKey::try_new(descriptor.entity_source_key)
734 .map_err(|_| DynamicTypedBindingError::FieldUnavailable)?;
735 let catalog = self
736 .find_accepted_schema_catalog_context_for_entity_source_key(entity_source.as_str())?
737 .ok_or(DynamicTypedBindingError::FieldUnavailable)?;
738 let identity = catalog.identity();
739 if identity.entity_path() != entity_source.as_str() {
740 return Err(InternalError::store_invariant().into());
741 }
742 let store = self.db.recovered_store(identity.store_path())?;
743 store.with_schema(|schema| {
747 let bundle = schema
748 .borrow_current_accepted_schema_bundle()?
749 .ok_or_else(InternalError::store_invariant)?;
750 let entity_tag = identity.entity_tag();
751 if bundle.source_bindings().entity(&entity_source) != Some(entity_tag)
752 || bundle.revision() != catalog.revision()
753 {
754 return Err(InternalError::store_invariant().into());
755 }
756 let snapshot = bundle
757 .entity_snapshots()
758 .get(&entity_tag)
759 .ok_or_else(InternalError::store_invariant)?;
760 if descriptor.primary_key_source_keys.len() != snapshot.primary_key_field_ids().len() {
761 return Err(DynamicTypedBindingError::IncompatibleField);
762 }
763 for (source_key, accepted_field_id) in descriptor
764 .primary_key_source_keys
765 .iter()
766 .zip(snapshot.primary_key_field_ids())
767 {
768 let source = FieldSourceKey::try_new((*source_key).to_string())
769 .map_err(|_| DynamicTypedBindingError::FieldUnavailable)?;
770 let descriptor_field_id = bundle
771 .source_bindings()
772 .field(entity_tag, &source)
773 .ok_or(DynamicTypedBindingError::FieldUnavailable)?;
774 if descriptor_field_id != *accepted_field_id {
775 return Err(DynamicTypedBindingError::IncompatibleField);
776 }
777 }
778 let row_contract = catalog.inspection_plan().row_contract();
779 let mut fields = Vec::with_capacity(descriptor.fields.len());
780 for field_descriptor in descriptor.fields {
781 let source = FieldSourceKey::try_new(field_descriptor.source_key.to_string())
782 .map_err(|_| DynamicTypedBindingError::FieldUnavailable)?;
783 let field_id = bundle
784 .source_bindings()
785 .field(entity_tag, &source)
786 .ok_or(DynamicTypedBindingError::FieldUnavailable)?;
787 let field = snapshot
788 .fields()
789 .iter()
790 .find(|field| field.id() == field_id)
791 .ok_or_else(InternalError::store_invariant)?;
792 let runtime_field = row_contract
793 .required_accepted_field_contract(usize::from(field.slot().get()))?;
794 if runtime_field.field_id() != field_id {
795 return Err(InternalError::store_invariant().into());
796 }
797 let field_type = typed_descriptor_field_type(field_descriptor.field_type)?;
798 let expected_kind = lower_field_type(&field_type, bundle.source_bindings())
799 .map_err(|_| DynamicTypedBindingError::IncompatibleField)?;
800 if field.nullable() != field_descriptor.nullable
801 || !typed_adapter_field_kind_matches(field.kind(), &expected_kind)
802 {
803 return Err(DynamicTypedBindingError::IncompatibleField);
804 }
805 fields.push((
806 source.as_str().to_string(),
807 field_id.get(),
808 field.slot().get(),
809 field.name().to_string(),
810 ));
811 }
812 let adapter_names = bundle.typed_adapter_names()?;
813
814 DynamicTypedEntityBinding::new(
815 database_incarnation_id()?.to_bytes(),
816 entity_source.as_str().to_string(),
817 snapshot.entity_name().to_string(),
818 entity_tag.value(),
819 catalog.revision().get(),
820 catalog.fingerprint(),
821 row_contract.current_layout_version().get(),
822 fields,
823 adapter_names.named_types,
824 adapter_names.enum_variants,
825 adapter_names.composite_fields,
826 )
827 .map_err(Into::into)
828 })
829 }
830
831 pub(in crate::db::session) fn current_typed_entity_binding_catalog(
832 &self,
833 binding: &DynamicTypedEntityBinding,
834 ) -> Result<Option<AcceptedSchemaCatalogContext>, InternalError> {
835 self.db.ensure_recovered_state()?;
839 let incarnation = database_incarnation_id()?.to_bytes();
840 if incarnation != binding.database_incarnation {
841 return Ok(None);
842 }
843 let Some(catalog) = self.find_accepted_schema_catalog_context_for_entity_source_key(
844 binding.entity_source.as_str(),
845 )?
846 else {
847 return Ok(None);
848 };
849 self.typed_entity_binding_matches_catalog(binding, &catalog, incarnation)
850 .map(|current| current.then_some(catalog))
851 }
852
853 fn typed_entity_binding_matches_catalog(
854 &self,
855 binding: &DynamicTypedEntityBinding,
856 catalog: &AcceptedSchemaCatalogContext,
857 incarnation: [u8; 16],
858 ) -> Result<bool, InternalError> {
859 if incarnation != binding.database_incarnation {
860 return Ok(false);
861 }
862 let row_contract = catalog.inspection_plan().row_contract();
863 let identity = catalog.identity();
864 if identity.entity_path() != binding.entity_source.as_str()
865 || identity.entity_tag().value() != binding.entity_tag
866 || catalog.revision().get() != binding.accepted_revision
867 || catalog.fingerprint() != binding.accepted_fingerprint
868 || row_contract.current_layout_version().get() != binding.entity_generation
869 {
870 return Ok(false);
871 }
872 let entity_source = EntitySourceKey::try_new(binding.entity_source.clone())
873 .map_err(|_| InternalError::store_invariant())?;
874 let store = self.db.recovered_store(identity.store_path())?;
875 store.with_schema(|schema| {
878 let bundle = schema
879 .borrow_current_accepted_schema_bundle()?
880 .ok_or_else(InternalError::store_invariant)?;
881 if bundle.revision() != catalog.revision()
882 || bundle.source_bindings().entity(&entity_source) != Some(identity.entity_tag())
883 {
884 return Ok(false);
885 }
886 let snapshot = bundle
887 .entity_snapshots()
888 .get(&identity.entity_tag())
889 .ok_or_else(InternalError::store_invariant)?;
890 for (source_key, expected_field_id, expected_slot) in binding.field_identity_bindings()
891 {
892 let source = FieldSourceKey::try_new(source_key)
893 .map_err(|_| InternalError::store_invariant())?;
894 let Some(field_id) = bundle
895 .source_bindings()
896 .field(identity.entity_tag(), &source)
897 else {
898 return Ok(false);
899 };
900 let Some(field) = snapshot
901 .fields()
902 .iter()
903 .find(|field| field.id() == field_id)
904 else {
905 return Err(InternalError::store_invariant());
906 };
907 if field_id.get() != expected_field_id || field.slot().get() != expected_slot {
908 return Ok(false);
909 }
910 }
911
912 Ok(true)
913 })
914 }
915
916 pub fn typed_entity_binding_is_current(
918 &self,
919 binding: &DynamicTypedEntityBinding,
920 ) -> Result<bool, InternalError> {
921 self.current_typed_entity_binding_catalog(binding)
922 .map(|catalog| catalog.is_some())
923 }
924
925 #[cfg(feature = "sql")]
928 pub(in crate::db::session) fn execute_accepted_structural_delete_batch(
929 &self,
930 catalog: &AcceptedSchemaCatalogContext,
931 _descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
932 keys: Vec<DecodedDataStoreKey>,
933 precommit_validation: impl FnOnce(&[Vec<Value>]) -> Result<(), InternalError>,
934 ) -> Result<Vec<Vec<Value>>, InternalError> {
935 let mutations = keys
936 .into_iter()
937 .map(AcceptedStructuralMutation::delete)
938 .collect::<Vec<_>>();
939 let mutation_capacity = mutations.len();
940 let mut mutations = mutations.into_iter();
941 self.execute_accepted_structural_mutation_batch_inner(
942 catalog,
943 mutation_capacity,
944 0,
945 || {
946 Ok(mutations
947 .next()
948 .map(|mutation| AcceptedStructuralMutationBatchItem {
949 catalog: catalog.clone(),
950 mutation,
951 }))
952 },
953 Timestamp::now(),
954 AcceptedStructuralMutationCommitOptions::standard(),
955 |rows, _report| {
956 let rows = rows
957 .into_iter()
958 .map(AcceptedStructuralMutationRow::into_values)
959 .collect::<Vec<_>>();
960 precommit_validation(rows.as_slice())?;
961 Ok((rows, AcceptedStructuralMutationCommitDirective::Standard))
962 },
963 )
964 }
965
966 pub(in crate::db::session) fn execute_accepted_structural_save_batch<T>(
974 &self,
975 catalog: &AcceptedSchemaCatalogContext,
976 _descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
977 mutations: Vec<AcceptedStructuralMutation>,
978 operation_timestamp: Timestamp,
979 precommit_preparation: impl FnOnce(
980 Vec<AcceptedStructuralMutationRow>,
981 ) -> Result<T, InternalError>,
982 ) -> Result<T, InternalError> {
983 let mutation_capacity = mutations.len();
984 let identity_candidate_count = mutations
985 .iter()
986 .filter(|mutation| {
987 matches!(
988 mutation,
989 AcceptedStructuralMutation::Save {
990 mode: MutationMode::Insert,
991 target: AcceptedStructuralMutationTarget::ResolveFromAfterImage,
992 ..
993 }
994 )
995 })
996 .count();
997 let mut mutations = mutations.into_iter();
998 self.execute_accepted_structural_mutation_batch_inner(
999 catalog,
1000 mutation_capacity,
1001 identity_candidate_count,
1002 || {
1003 Ok(mutations
1004 .next()
1005 .map(|mutation| AcceptedStructuralMutationBatchItem {
1006 catalog: catalog.clone(),
1007 mutation,
1008 }))
1009 },
1010 operation_timestamp,
1011 AcceptedStructuralMutationCommitOptions::standard(),
1012 |rows, _report| {
1013 precommit_preparation(rows).map(|prepared| {
1014 (
1015 prepared,
1016 AcceptedStructuralMutationCommitDirective::Standard,
1017 )
1018 })
1019 },
1020 )
1021 }
1022
1023 #[cfg(test)]
1025 pub(in crate::db::session) fn execute_accepted_structural_update_with_mutation_progress(
1026 &self,
1027 catalog: &AcceptedSchemaCatalogContext,
1028 _descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
1029 mutations: Vec<AcceptedStructuralMutation>,
1030 operation_timestamp: Timestamp,
1031 mutation_progress: MutationProgressRecordOp,
1032 ) -> Result<usize, InternalError> {
1033 let mutation_capacity = mutations.len();
1034 let mut mutations = mutations.into_iter();
1035 self.execute_accepted_structural_mutation_batch_inner(
1036 catalog,
1037 mutation_capacity,
1038 0,
1039 || {
1040 Ok(mutations
1041 .next()
1042 .map(|mutation| AcceptedStructuralMutationBatchItem {
1043 catalog: catalog.clone(),
1044 mutation,
1045 }))
1046 },
1047 operation_timestamp,
1048 AcceptedStructuralMutationCommitOptions::with_mutation_progress(),
1049 |rows, _report| {
1050 Ok((
1051 rows.len(),
1052 AcceptedStructuralMutationCommitDirective::WithMutationProgress(
1053 mutation_progress,
1054 ),
1055 ))
1056 },
1057 )
1058 }
1059
1060 #[cfg(any(feature = "sql", test))]
1063 pub(in crate::db::session) fn execute_accepted_structural_update_bounded_prefix<T>(
1064 &self,
1065 catalog: &AcceptedSchemaCatalogContext,
1066 _descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
1067 mutation_capacity: usize,
1068 mut next_mutation: impl FnMut() -> Result<Option<AcceptedStructuralMutation>, InternalError>,
1069 operation_timestamp: Timestamp,
1070 precommit_preparation: impl FnOnce(
1071 AcceptedStructuralMutationPackingReport,
1072 ) -> Result<
1073 (T, AcceptedStructuralMutationCommitDirective),
1074 InternalError,
1075 >,
1076 ) -> Result<T, InternalError> {
1077 self.execute_accepted_structural_mutation_batch_inner(
1078 catalog,
1079 mutation_capacity,
1080 0,
1081 || {
1082 next_mutation().map(|mutation| {
1083 mutation.map(|mutation| AcceptedStructuralMutationBatchItem {
1084 catalog: catalog.clone(),
1085 mutation,
1086 })
1087 })
1088 },
1089 operation_timestamp,
1090 AcceptedStructuralMutationCommitOptions::bounded_prefix(),
1091 |rows, report| {
1092 if rows.len() != report.admitted_mutations() {
1093 return Err(InternalError::executor_invariant());
1094 }
1095 precommit_preparation(report)
1096 },
1097 )
1098 }
1099
1100 #[expect(
1101 clippy::too_many_arguments,
1102 clippy::too_many_lines,
1103 reason = "one phased owner keeps accepted authority, mutation context, precommit preparation, output capture, and commit staging inseparable"
1104 )]
1105 fn execute_accepted_structural_mutation_batch_inner<T>(
1106 &self,
1107 anchor_catalog: &AcceptedSchemaCatalogContext,
1108 mutation_capacity: usize,
1109 identity_candidate_count: usize,
1110 mut next_mutation: impl FnMut() -> Result<
1111 Option<AcceptedStructuralMutationBatchItem>,
1112 InternalError,
1113 >,
1114 operation_timestamp: Timestamp,
1115 options: AcceptedStructuralMutationCommitOptions,
1116 precommit_preparation: impl FnOnce(
1117 Vec<AcceptedStructuralMutationRow>,
1118 AcceptedStructuralMutationPackingReport,
1119 ) -> Result<
1120 (T, AcceptedStructuralMutationCommitDirective),
1121 InternalError,
1122 >,
1123 ) -> Result<T, InternalError> {
1124 let AcceptedStructuralMutationCommitOptions {
1125 capture_output_values,
1126 packing,
1127 } = options;
1128 let anchor_identity = anchor_catalog.identity();
1129 let accepted_root_identity = anchor_catalog.runtime_root_identity();
1130 let store_path = anchor_identity.store_path();
1131 let store = self.db.recovered_store(store_path)?;
1132 let write_context = dynamic_write_context(operation_timestamp);
1133 if mutation_capacity > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1134 return Err(InternalError::mutation_batch_too_many_items(
1135 mutation_capacity,
1136 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1137 ));
1138 }
1139 let _ = checked_pre_key_candidate_count(identity_candidate_count)?;
1140 let mut entity_states: Vec<AcceptedStructuralMutationEntityState> = Vec::new();
1141 let mut scheduler = AcceptedMutationConstraintScheduler::new(mutation_capacity);
1142 let mut output = Vec::with_capacity(mutation_capacity);
1143 let mut staged_bytes = 0_usize;
1144 let mut stopped_before_candidate = false;
1145 let mut candidate_exceeds_batch_policy = false;
1146 let mut input_index = 0_usize;
1147
1148 while let Some(item) = next_mutation()? {
1149 if input_index >= mutation_capacity {
1150 return Err(InternalError::mutation_batch_too_many_items(
1151 input_index.saturating_add(1),
1152 mutation_capacity,
1153 ));
1154 }
1155 let batch_input_ordinal = u32::try_from(input_index).map_err(|_| {
1156 InternalError::mutation_batch_too_many_items(
1157 mutation_capacity,
1158 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1159 )
1160 })?;
1161 input_index = input_index.saturating_add(1);
1162 let catalog = &item.catalog;
1163 let identity = catalog.identity();
1164 if catalog.runtime_root_identity() != accepted_root_identity
1165 || identity.store_path() != store_path
1166 {
1167 return Err(InternalError::query_executor_invariant());
1168 }
1169 let descriptor =
1170 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1171 let row_decode_contract =
1172 descriptor.row_decode_contract(catalog.value_catalog_handle().clone());
1173 let entity_path = identity.entity_path();
1174 let _metrics_span = EntityMetricsSpan::new(entity_path);
1175 let row_contract = StructuralRowContract::from_accepted_decode_contract(
1176 entity_path,
1177 row_decode_contract.clone(),
1178 );
1179 let entity_state_index = entity_states
1180 .iter()
1181 .position(|state| state.entity_tag == identity.entity_tag());
1182 let entity_state_index = if let Some(index) = entity_state_index {
1183 index
1184 } else {
1185 if entity_states.len() >= MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES {
1186 return Err(InternalError::mutation_batch_too_many_entities(
1187 entity_states.len().saturating_add(1),
1188 MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES,
1189 ));
1190 }
1191 let identity_field = accepted_identity_insert_field(&descriptor)?;
1192 let identity_incarnation = identity_field
1193 .as_ref()
1194 .map(|_| database_incarnation_id())
1195 .transpose()?;
1196 entity_states.push(AcceptedStructuralMutationEntityState {
1197 entity_tag: identity.entity_tag(),
1198 identity_field,
1199 identity_incarnation,
1200 identity_cursor: None,
1201 identity_insert_ordinal: 0,
1202 });
1203 entity_states.len().saturating_sub(1)
1204 };
1205 let identity_field = entity_states[entity_state_index].identity_field.clone();
1206 let identity_insert_ordinal = entity_states[entity_state_index].identity_insert_ordinal;
1207 let mutation = item.mutation;
1208 let AcceptedStructuralMutation::Save {
1209 mode,
1210 target,
1211 patch: authored_patch,
1212 } = mutation
1213 else {
1214 let AcceptedStructuralMutation::Delete { key } = mutation else {
1215 return Err(InternalError::executor_invariant());
1216 };
1217 let before = validated_existing_row(store, &key, &row_contract)?
1218 .ok_or_else(|| InternalError::store_not_found(&key))?;
1219 let raw_key = key.to_raw()?;
1220 let canonical_before = canonical_row_from_raw_row_with_accepted_decode_contract(
1221 entity_path,
1222 row_decode_contract.clone(),
1223 &before,
1224 )?;
1225 let admission = admit_structural_mutation_staged_charge(
1226 &mut staged_bytes,
1227 [
1228 raw_key.as_bytes().len(),
1229 canonical_before.as_raw_row().as_bytes().len(),
1230 ],
1231 packing,
1232 )?;
1233 match admission {
1234 AcceptedStructuralMutationStagedAdmission::Admitted => {}
1235 AcceptedStructuralMutationStagedAdmission::PageFull => {
1236 stopped_before_candidate = true;
1237 break;
1238 }
1239 AcceptedStructuralMutationStagedAdmission::CandidateExceedsPolicy => {
1240 stopped_before_candidate = true;
1241 candidate_exceeds_batch_policy = true;
1242 break;
1243 }
1244 }
1245 scheduler.schedule_delete(
1246 entity_path,
1247 identity.entity_tag(),
1248 catalog.fingerprint(),
1249 CommitRowOp::new(
1250 entity_path,
1251 raw_key,
1252 Some(canonical_before.as_raw_row().as_bytes().to_vec()),
1253 None,
1254 catalog.fingerprint(),
1255 ),
1256 batch_input_ordinal,
1257 )?;
1258 let reader = StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
1259 canonical_before.as_raw_row(),
1260 &row_contract,
1261 )?;
1262 let values = if capture_output_values {
1263 into_mutation_output_values(reader, &descriptor)?
1264 } else {
1265 Vec::new()
1266 };
1267 output.push(AcceptedStructuralMutationRow {
1268 values,
1269 logical_changed: true,
1270 });
1271 continue;
1272 };
1273 let mutation_context = mutation_diagnostic_context(catalog, mode, batch_input_ordinal);
1274 let (expected_key, preloaded_before, pre_key_insert, mut keyed_patch) = match target {
1275 AcceptedStructuralMutationTarget::ResolveFromAfterImage => {
1276 let candidate_ordinal =
1277 if identity_field.is_some() && matches!(mode, MutationMode::Insert) {
1278 identity_insert_ordinal
1279 } else {
1280 batch_input_ordinal
1281 };
1282 (
1283 None,
1284 None,
1285 Some(AcceptedPreKeyInsert::new(
1286 identity.entity_tag(),
1287 authored_patch,
1288 candidate_ordinal,
1289 )),
1290 None,
1291 )
1292 }
1293 AcceptedStructuralMutationTarget::Expected(key) => {
1294 (Some(*key), None, None, Some(authored_patch))
1295 }
1296 AcceptedStructuralMutationTarget::ExpectedLoaded(loaded) => {
1297 let (key, row) = loaded.into_parts();
1298 (Some(key), Some(row), None, Some(authored_patch))
1299 }
1300 };
1301 if matches!(mode, MutationMode::Replace)
1302 && let Some(key) = expected_key.as_ref()
1303 {
1304 let patch = keyed_patch
1305 .take()
1306 .ok_or_else(InternalError::executor_invariant)?;
1307 keyed_patch = Some(preserve_dynamic_replacement_identity(
1308 key,
1309 &descriptor,
1310 patch,
1311 )?);
1312 }
1313 let patch = pre_key_insert
1314 .as_ref()
1315 .map(AcceptedPreKeyInsert::fields)
1316 .or(keyed_patch.as_ref())
1317 .ok_or_else(InternalError::executor_invariant)?;
1318 let before = match (expected_key.as_ref(), preloaded_before) {
1319 (Some(_), Some(row)) => Some(row),
1320 (Some(key), None) => validated_existing_row(store, key, &row_contract)?,
1321 (None, None) => None,
1322 (None, Some(_)) => return Err(InternalError::executor_invariant()),
1323 };
1324 match mode {
1325 MutationMode::Insert if before.is_some() => {
1326 return Err(mutation_key_exists_error());
1327 }
1328 MutationMode::Update if before.is_none() => {
1329 let key = expected_key
1330 .as_ref()
1331 .ok_or_else(InternalError::executor_invariant)?;
1332 return Err(InternalError::store_not_found(key));
1333 }
1334 MutationMode::Insert | MutationMode::Replace | MutationMode::Update => {}
1335 }
1336
1337 let identity_allocation = if let Some(identity_field) = identity_field.as_ref()
1338 && matches!(mode, MutationMode::Insert)
1339 && before.is_none()
1340 {
1341 let candidate = pre_key_insert.as_ref().ok_or_else(|| {
1342 InternalError::mutation_database_owned_field_explicit(
1343 mutation_context,
1344 identity_field.field_id.get(),
1345 )
1346 })?;
1347 if entity_states[entity_state_index].identity_cursor.is_none() {
1348 let incarnation = entity_states[entity_state_index]
1349 .identity_incarnation
1350 .ok_or_else(InternalError::identity_state_corruption)?;
1351 entity_states[entity_state_index].identity_cursor =
1352 Some(store.with_schema(|schema_store| {
1353 schema_store.identity_statement_cursor(
1354 incarnation,
1355 identity.entity_tag(),
1356 identity_field.field_id,
1357 &identity_field.accepted_kind,
1358 )
1359 })?);
1360 }
1361 let allocation = entity_states[entity_state_index]
1362 .identity_cursor
1363 .as_mut()
1364 .ok_or_else(InternalError::identity_state_corruption)?
1365 .allocate(identity_field.field_slot, candidate.input_ordinal())?;
1366 entity_states[entity_state_index].identity_insert_ordinal = identity_insert_ordinal
1367 .checked_add(1)
1368 .ok_or_else(InternalError::identity_candidate_count_exhausted)?;
1369 Some(allocation)
1370 } else if let Some(identity_field) = identity_field.as_ref()
1371 && matches!(mode, MutationMode::Replace)
1372 && before.is_none()
1373 {
1374 return Err(InternalError::mutation_database_owned_field_explicit(
1375 mutation_context,
1376 identity_field.field_id.get(),
1377 ));
1378 } else {
1379 None
1380 };
1381
1382 let resolved = match (mode, before.as_ref()) {
1383 (MutationMode::Insert | MutationMode::Replace, None) => {
1384 resolve_insert_structural_patch_with_accepted_contract(
1385 entity_path,
1386 row_decode_contract.clone(),
1387 catalog.fingerprint(),
1388 catalog.accepted_row_constraints(),
1389 patch,
1390 write_context,
1391 mutation_context,
1392 identity_allocation.as_ref(),
1393 )?
1394 }
1395 (MutationMode::Update, Some(before)) => {
1396 resolve_update_structural_patch_with_accepted_contract(
1397 entity_path,
1398 row_decode_contract.clone(),
1399 catalog.fingerprint(),
1400 catalog.accepted_row_constraints(),
1401 before,
1402 patch,
1403 write_context,
1404 mutation_context,
1405 )?
1406 }
1407 (MutationMode::Replace, Some(before)) => {
1408 resolve_existing_replace_structural_patch_with_accepted_contract(
1409 entity_path,
1410 row_decode_contract.clone(),
1411 catalog.fingerprint(),
1412 catalog.accepted_row_constraints(),
1413 before,
1414 patch,
1415 write_context,
1416 mutation_context,
1417 )?
1418 }
1419 (MutationMode::Insert, Some(_)) | (MutationMode::Update, None) => {
1420 return Err(InternalError::executor_invariant());
1421 }
1422 };
1423 let (after, provenance) = resolved.into_parts();
1424 let reader = StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
1425 after.as_raw_row(),
1426 &row_contract,
1427 )?;
1428 let data_key = match expected_key {
1429 Some(key) => {
1430 reader.validate_primary_key(&key)?;
1431 key
1432 }
1433 None => data_key_from_validated_reader(identity.entity_tag(), &reader)?,
1434 };
1435 if let Some(allocation) = identity_allocation.as_ref() {
1436 validate_identity_materialization(
1437 identity.entity_tag(),
1438 identity_field
1439 .as_ref()
1440 .ok_or_else(InternalError::identity_corruption)?,
1441 pre_key_insert
1442 .as_ref()
1443 .ok_or_else(InternalError::identity_corruption)?,
1444 allocation,
1445 &data_key,
1446 &reader,
1447 )?;
1448 }
1449 if matches!(mode, MutationMode::Insert)
1450 && validated_existing_row(store, &data_key, &row_contract)?.is_some()
1451 {
1452 return Err(insert_key_exists_after_generation(
1453 identity_allocation.is_some(),
1454 ));
1455 }
1456 let raw_key = data_key.to_raw()?;
1457 let canonical_before = before
1458 .as_ref()
1459 .map(|before| {
1460 canonical_row_from_raw_row_with_accepted_decode_contract(
1461 entity_path,
1462 row_decode_contract.clone(),
1463 before,
1464 )
1465 })
1466 .transpose()?;
1467 let logical_changed = canonical_before.as_ref().is_none_or(|before| {
1468 before.as_raw_row().as_bytes() != after.as_raw_row().as_bytes()
1469 });
1470 let physical_changed = before
1471 .as_ref()
1472 .is_none_or(|before| before.as_bytes() != after.as_raw_row().as_bytes());
1473 let admission = admit_structural_mutation_staged_charge(
1474 &mut staged_bytes,
1475 [
1476 raw_key.as_bytes().len(),
1477 canonical_before
1478 .as_ref()
1479 .map_or(0, |before| before.as_raw_row().as_bytes().len()),
1480 after.as_raw_row().as_bytes().len(),
1481 ],
1482 packing,
1483 )?;
1484 match admission {
1485 AcceptedStructuralMutationStagedAdmission::Admitted => {}
1486 AcceptedStructuralMutationStagedAdmission::PageFull => {
1487 stopped_before_candidate = true;
1488 break;
1489 }
1490 AcceptedStructuralMutationStagedAdmission::CandidateExceedsPolicy => {
1491 stopped_before_candidate = true;
1492 candidate_exceeds_batch_policy = true;
1493 break;
1494 }
1495 }
1496 let row_op = physical_changed.then(|| {
1497 CommitRowOp::new(
1498 entity_path,
1499 raw_key.clone(),
1500 canonical_before
1501 .as_ref()
1502 .map(|before| before.as_raw_row().as_bytes().to_vec()),
1503 Some(after.as_raw_row().as_bytes().to_vec()),
1504 catalog.fingerprint(),
1505 )
1506 });
1507 scheduler.schedule_save_after_image(
1508 AcceptedMutationConstraintContext {
1509 entity_path,
1510 entity_tag: identity.entity_tag(),
1511 row_decode_contract: row_decode_contract.clone(),
1512 schema_fingerprint: catalog.fingerprint(),
1513 fingerprint_method: catalog.fingerprint_method_version(),
1514 row_constraints: catalog.accepted_row_constraints(),
1515 },
1516 mode,
1517 &data_key,
1518 after.as_raw_row(),
1519 provenance.as_slice(),
1520 row_op,
1521 batch_input_ordinal,
1522 )?;
1523 let values = if capture_output_values {
1524 into_mutation_output_values(reader, &descriptor)?
1525 } else {
1526 Vec::new()
1527 };
1528 output.push(AcceptedStructuralMutationRow {
1529 values,
1530 logical_changed,
1531 });
1532 }
1533
1534 let report = AcceptedStructuralMutationPackingReport {
1535 admitted_mutations: output.len(),
1536 staged_bytes,
1537 stopped_before_candidate,
1538 candidate_exceeds_batch_policy,
1539 };
1540 let batch = scheduler.finish();
1541 let (prepared, commit_directive) = precommit_preparation(output, report)?;
1542 finish_current_execution_instruction_watermark()?;
1543 let mut identity_ranges = Vec::with_capacity(entity_states.len());
1544 for state in entity_states {
1545 if let Some(range) = state
1546 .identity_cursor
1547 .map(IdentityStatementCursor::into_range_advance)
1548 .transpose()?
1549 .flatten()
1550 {
1551 identity_ranges.push(range);
1552 }
1553 }
1554 if !matches!(
1555 commit_directive,
1556 AcceptedStructuralMutationCommitDirective::Skip
1557 ) && batch.is_empty()
1558 && !identity_ranges.is_empty()
1559 {
1560 return Err(InternalError::identity_corruption());
1561 }
1562 match commit_directive {
1563 AcceptedStructuralMutationCommitDirective::Skip => {}
1564 AcceptedStructuralMutationCommitDirective::Standard if batch.is_empty() => {}
1565 AcceptedStructuralMutationCommitDirective::Standard => {
1566 commit_structural_row_ops_with_window(
1567 &self.db,
1568 batch,
1569 identity_ranges,
1570 "accepted_structural_batch_apply",
1571 )?;
1572 }
1573 AcceptedStructuralMutationCommitDirective::WithMutationProgress(operation)
1574 if batch.is_empty() =>
1575 {
1576 let _ = operation;
1577 return Err(InternalError::executor_invariant());
1578 }
1579 AcceptedStructuralMutationCommitDirective::WithMutationProgress(operation) => {
1580 commit_structural_row_ops_with_mutation_progress(
1581 &self.db,
1582 batch,
1583 identity_ranges,
1584 operation,
1585 "accepted_structural_batch_apply",
1586 )?;
1587 }
1588 }
1589 Ok(prepared)
1590 }
1591
1592 fn execute_lowered_dynamic_mutation_batch(
1593 &self,
1594 catalog: &AcceptedSchemaCatalogContext,
1595 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
1596 mutations: Vec<AcceptedStructuralMutation>,
1597 enforce_mixed_batch_result_bound: bool,
1598 ) -> Result<DynamicMutationResult, InternalError> {
1599 self.execute_accepted_structural_save_batch(
1600 catalog,
1601 descriptor,
1602 mutations,
1603 Timestamp::now(),
1604 |rows| {
1605 prepare_dynamic_mutation_result(
1606 catalog,
1607 descriptor,
1608 rows,
1609 enforce_mixed_batch_result_bound,
1610 )
1611 },
1612 )
1613 }
1614
1615 pub fn execute_trusted_dynamic_mutation(
1622 &self,
1623 request: &DynamicMutation,
1624 ) -> Result<DynamicMutationResult, InternalError> {
1625 self.execute_trusted_dynamic_mutation_batch_with_result_policy(vec![request.clone()], false)
1626 }
1627
1628 pub fn execute_trusted_dynamic_mutation_batch(
1634 &self,
1635 requests: Vec<DynamicMutation>,
1636 ) -> Result<Vec<DynamicMutationResult>, InternalError> {
1637 self.execute_trusted_dynamic_mutation_batch_mixed(requests)
1638 }
1639
1640 fn execute_trusted_dynamic_mutation_batch_mixed(
1641 &self,
1642 requests: Vec<DynamicMutation>,
1643 ) -> Result<Vec<DynamicMutationResult>, InternalError> {
1644 if requests.is_empty() {
1645 return Err(InternalError::mutation_batch_empty());
1646 }
1647 if requests.len() > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1648 return Err(InternalError::mutation_batch_too_many_items(
1649 requests.len(),
1650 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1651 ));
1652 }
1653 let first = requests
1654 .first()
1655 .ok_or_else(InternalError::mutation_batch_empty)?;
1656 if first.entity().is_empty() {
1657 return Err(InternalError::executor_unsupported());
1658 }
1659 let anchor_catalog =
1660 self.accepted_schema_catalog_context_for_entity_name(Some(first.entity()))?;
1661 let anchor_identity = anchor_catalog.identity();
1662 let mut entity_tags = std::collections::BTreeSet::new();
1663 let mut items = Vec::with_capacity(requests.len());
1664 let mut result_catalogs = Vec::with_capacity(requests.len());
1665 let mut identity_candidate_count = 0_usize;
1666
1667 let request_count = requests.len();
1668 for (batch_position, request) in requests.into_iter().enumerate() {
1669 let batch_position = u32::try_from(batch_position).map_err(|_| {
1670 InternalError::mutation_batch_too_many_items(
1671 request_count,
1672 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1673 )
1674 })?;
1675 if request.entity().is_empty() {
1676 return Err(InternalError::executor_unsupported());
1677 }
1678 let item_catalog = anchor_catalog
1679 .for_entity_name(request.entity())
1680 .ok_or_else(|| InternalError::unsupported_entity_path(request.entity()))?;
1681 let item_identity = item_catalog.identity();
1682 if item_identity.store_path() != anchor_identity.store_path() {
1683 return Err(InternalError::mutation_batch_store_mismatch(
1684 batch_position,
1685 anchor_identity.entity_tag().value(),
1686 item_identity.entity_tag().value(),
1687 ));
1688 }
1689 entity_tags.insert(item_identity.entity_tag());
1690 if entity_tags.len() > MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES {
1691 return Err(InternalError::mutation_batch_too_many_entities(
1692 entity_tags.len(),
1693 MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES,
1694 ));
1695 }
1696 let descriptor =
1697 AcceptedRowLayoutRuntimeContract::from_accepted_schema(item_catalog.snapshot())?;
1698 let mutation =
1699 lower_dynamic_mutation_intent(&item_catalog, &descriptor, request, batch_position)?;
1700 if matches!(
1701 mutation,
1702 AcceptedStructuralMutation::Save {
1703 mode: MutationMode::Insert,
1704 target: AcceptedStructuralMutationTarget::ResolveFromAfterImage,
1705 ..
1706 }
1707 ) {
1708 identity_candidate_count = identity_candidate_count.saturating_add(1);
1709 }
1710 result_catalogs.push(item_catalog.clone());
1711 items.push(AcceptedStructuralMutationBatchItem {
1712 catalog: item_catalog,
1713 mutation,
1714 });
1715 }
1716
1717 self.execute_lowered_mixed_mutation_batch(
1718 &anchor_catalog,
1719 items,
1720 result_catalogs,
1721 identity_candidate_count,
1722 )
1723 }
1724
1725 fn execute_lowered_mixed_mutation_batch(
1726 &self,
1727 anchor_catalog: &AcceptedSchemaCatalogContext,
1728 items: Vec<AcceptedStructuralMutationBatchItem>,
1729 result_catalogs: Vec<AcceptedSchemaCatalogContext>,
1730 identity_candidate_count: usize,
1731 ) -> Result<Vec<DynamicMutationResult>, InternalError> {
1732 if items.len() != result_catalogs.len() {
1733 return Err(InternalError::executor_invariant());
1734 }
1735 let mutation_count = items.len();
1736 let mut items = items.into_iter();
1737 self.execute_accepted_structural_mutation_batch_inner(
1738 anchor_catalog,
1739 mutation_count,
1740 identity_candidate_count,
1741 || Ok(items.next()),
1742 Timestamp::now(),
1743 AcceptedStructuralMutationCommitOptions::standard(),
1744 |rows, _report| {
1745 if rows.len() != result_catalogs.len() {
1746 return Err(InternalError::executor_invariant());
1747 }
1748 let mut results = Vec::with_capacity(rows.len());
1749 for (row, catalog) in rows.into_iter().zip(result_catalogs.iter()) {
1750 let descriptor =
1751 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1752 results.push(prepare_dynamic_mutation_result(
1753 catalog,
1754 &descriptor,
1755 vec![row],
1756 false,
1757 )?);
1758 }
1759 let encoded = candid::encode_one(&results)
1760 .map_err(|_| InternalError::executor_invariant())?;
1761 validate_structural_mutation_result_bytes(encoded.len())?;
1762 Ok((results, AcceptedStructuralMutationCommitDirective::Standard))
1763 },
1764 )
1765 }
1766
1767 fn execute_trusted_dynamic_mutation_batch_with_result_policy(
1768 &self,
1769 requests: Vec<DynamicMutation>,
1770 enforce_mixed_batch_result_bound: bool,
1771 ) -> Result<DynamicMutationResult, InternalError> {
1772 if requests.is_empty() {
1773 return Err(InternalError::mutation_batch_empty());
1774 }
1775 if requests.len() > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1776 return Err(InternalError::mutation_batch_too_many_items(
1777 requests.len(),
1778 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1779 ));
1780 }
1781 let first = requests
1782 .first()
1783 .ok_or_else(InternalError::mutation_batch_empty)?;
1784 if first.entity().is_empty() {
1785 return Err(InternalError::executor_unsupported());
1786 }
1787 let catalog = self.accepted_schema_catalog_context_for_entity_name(Some(first.entity()))?;
1788 let accepted_identity = catalog.identity();
1789 let descriptor =
1790 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1791 let mut mutations = Vec::with_capacity(requests.len());
1792
1793 let request_count = requests.len();
1794 for (batch_position, request) in requests.into_iter().enumerate() {
1795 let batch_position = u32::try_from(batch_position).map_err(|_| {
1796 InternalError::mutation_batch_too_many_items(
1797 request_count,
1798 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1799 )
1800 })?;
1801 if request.entity().is_empty() {
1802 return Err(InternalError::executor_unsupported());
1803 }
1804 let item_catalog =
1805 self.accepted_schema_catalog_context_for_entity_name(Some(request.entity()))?;
1806 if item_catalog.identity() != accepted_identity {
1807 return Err(InternalError::query_executor_invariant());
1808 }
1809 let mutation =
1810 lower_dynamic_mutation_intent(&catalog, &descriptor, request, batch_position)?;
1811 mutations.push(mutation);
1812 }
1813
1814 self.execute_lowered_dynamic_mutation_batch(
1815 &catalog,
1816 &descriptor,
1817 mutations,
1818 enforce_mixed_batch_result_bound,
1819 )
1820 }
1821
1822 #[doc(hidden)]
1825 pub fn execute_trusted_typed_mutation(
1826 &self,
1827 binding: &DynamicTypedEntityBinding,
1828 request: DynamicTypedMutation,
1829 ) -> Result<Option<DynamicMutationResult>, InternalError> {
1830 let Some(catalog) = self.current_typed_entity_binding_catalog(binding)? else {
1831 return Ok(None);
1832 };
1833 let descriptor =
1834 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1835 let Some(mutation) =
1836 lower_typed_mutation_intent(&catalog, &descriptor, binding, request, 0)?
1837 else {
1838 return Ok(None);
1839 };
1840 self.execute_lowered_dynamic_mutation_batch(&catalog, &descriptor, vec![mutation], false)
1841 .map(Some)
1842 }
1843
1844 #[doc(hidden)]
1848 pub fn execute_trusted_same_entity_typed_mutation_batch(
1849 &self,
1850 binding: &DynamicTypedEntityBinding,
1851 requests: Vec<DynamicTypedMutation>,
1852 ) -> Result<Option<DynamicMutationResult>, InternalError> {
1853 if requests.is_empty() {
1854 return Err(InternalError::mutation_batch_empty());
1855 }
1856 if requests.len() > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1857 return Err(InternalError::mutation_batch_too_many_items(
1858 requests.len(),
1859 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1860 ));
1861 }
1862 let Some(catalog) = self.current_typed_entity_binding_catalog(binding)? else {
1863 return Ok(None);
1864 };
1865 let descriptor =
1866 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1867 let mut mutations = Vec::with_capacity(requests.len());
1868 let request_count = requests.len();
1869 for (batch_position, request) in requests.into_iter().enumerate() {
1870 let batch_position = u32::try_from(batch_position).map_err(|_| {
1871 InternalError::mutation_batch_too_many_items(
1872 request_count,
1873 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1874 )
1875 })?;
1876 let Some(mutation) = lower_typed_mutation_intent(
1877 &catalog,
1878 &descriptor,
1879 binding,
1880 request,
1881 batch_position,
1882 )?
1883 else {
1884 return Ok(None);
1885 };
1886 mutations.push(mutation);
1887 }
1888
1889 self.execute_lowered_dynamic_mutation_batch(&catalog, &descriptor, mutations, true)
1890 .map(Some)
1891 }
1892
1893 #[doc(hidden)]
1897 pub fn execute_trusted_typed_mutation_batch(
1898 &self,
1899 requests: Vec<(DynamicTypedEntityBinding, DynamicTypedMutation)>,
1900 ) -> Result<Option<Vec<DynamicMutationResult>>, InternalError> {
1901 if requests.is_empty() {
1902 return Err(InternalError::mutation_batch_empty());
1903 }
1904 if requests.len() > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1905 return Err(InternalError::mutation_batch_too_many_items(
1906 requests.len(),
1907 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1908 ));
1909 }
1910 let first_binding = requests
1911 .first()
1912 .map(|(binding, _)| binding)
1913 .ok_or_else(InternalError::mutation_batch_empty)?;
1914 let Some(catalog) = self.current_typed_entity_binding_catalog(first_binding)? else {
1915 return Ok(None);
1916 };
1917 let anchor_identity = catalog.identity();
1918 let mut entity_tags = std::collections::BTreeSet::new();
1919 let mut items = Vec::with_capacity(requests.len());
1920 let mut result_catalogs = Vec::with_capacity(requests.len());
1921 let mut identity_candidate_count = 0_usize;
1922
1923 let request_count = requests.len();
1924 for (batch_position, (binding, request)) in requests.into_iter().enumerate() {
1925 let batch_position = u32::try_from(batch_position).map_err(|_| {
1926 InternalError::mutation_batch_too_many_items(
1927 request_count,
1928 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1929 )
1930 })?;
1931 let Some(item_catalog) = catalog.for_entity_path(binding.entity_source.as_str()) else {
1932 return Ok(None);
1933 };
1934 if !self.typed_entity_binding_matches_catalog(
1935 &binding,
1936 &item_catalog,
1937 database_incarnation_id()?.to_bytes(),
1938 )? {
1939 return Ok(None);
1940 }
1941 let item_identity = item_catalog.identity();
1942 if item_identity.store_path() != anchor_identity.store_path() {
1943 return Err(InternalError::mutation_batch_store_mismatch(
1944 batch_position,
1945 anchor_identity.entity_tag().value(),
1946 item_identity.entity_tag().value(),
1947 ));
1948 }
1949 entity_tags.insert(item_identity.entity_tag());
1950 if entity_tags.len() > MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES {
1951 return Err(InternalError::mutation_batch_too_many_entities(
1952 entity_tags.len(),
1953 MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES,
1954 ));
1955 }
1956 let descriptor =
1957 AcceptedRowLayoutRuntimeContract::from_accepted_schema(item_catalog.snapshot())?;
1958 let Some(mutation) = lower_typed_mutation_intent(
1959 &item_catalog,
1960 &descriptor,
1961 &binding,
1962 request,
1963 batch_position,
1964 )?
1965 else {
1966 return Ok(None);
1967 };
1968 if matches!(
1969 mutation,
1970 AcceptedStructuralMutation::Save {
1971 mode: MutationMode::Insert,
1972 target: AcceptedStructuralMutationTarget::ResolveFromAfterImage,
1973 ..
1974 }
1975 ) {
1976 identity_candidate_count = identity_candidate_count.saturating_add(1);
1977 }
1978 result_catalogs.push(item_catalog.clone());
1979 items.push(AcceptedStructuralMutationBatchItem {
1980 catalog: item_catalog,
1981 mutation,
1982 });
1983 }
1984
1985 self.execute_lowered_mixed_mutation_batch(
1986 &catalog,
1987 items,
1988 result_catalogs,
1989 identity_candidate_count,
1990 )
1991 .map(Some)
1992 }
1993
1994 pub fn execute_trusted_dynamic_insert_batch(
2000 &self,
2001 entity: &str,
2002 patches: Vec<DynamicStructuralPatch>,
2003 ) -> Result<DynamicMutationResult, InternalError> {
2004 let mutations = patches
2005 .into_iter()
2006 .map(|patch| DynamicMutation::Insert {
2007 entity: entity.to_string(),
2008 patch,
2009 })
2010 .collect();
2011 self.execute_trusted_dynamic_mutation_batch_with_result_policy(mutations, false)
2012 }
2013}
2014
2015#[cfg(test)]
2016mod typed_adapter_tests {
2017 mod incarnation_tests;
2018 mod input_handoff_tests;
2019
2020 use super::{
2021 AcceptedFieldKind, DbSession, DynamicTypedBindingError, DynamicTypedEntityBinding,
2022 DynamicTypedMutation, DynamicWriteCell, TypedEntityDescriptor, TypedFieldType,
2023 typed_adapter_field_kind_matches, typed_descriptor_field_type,
2024 };
2025 use crate::{
2026 db::{
2027 TypedFieldDescriptor,
2028 data::DataStore,
2029 index::IndexStore,
2030 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
2031 schema::{
2032 AcceptedSchemaRevision, FieldId, FieldStorageDecode, LeafCodec,
2033 PersistedFieldSnapshot, PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot,
2034 SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
2035 accepted_schema_candidate_with_field_bindings_for_tests,
2036 },
2037 },
2038 traits::{CanisterKind, Path},
2039 types::EntityTag,
2040 value::InputValue,
2041 };
2042 use icydb_schema::{FieldSourceKey, ScalarType};
2043 use std::{cell::RefCell, collections::BTreeMap};
2044
2045 const STORE_PATH: &str = "session::write::typed_adapter_tests::Store";
2046 const OTHER_STORE_PATH: &str = "session::write::typed_adapter_tests::OtherStore";
2047 const ENTITY_SOURCE: &str = "session::write::typed_adapter_tests::Entity";
2048 const OTHER_ENTITY_SOURCE: &str = "session::write::typed_adapter_tests::OtherEntity";
2049 const ID_SOURCE: &str = "session::write::typed_adapter_tests::Entity::id";
2050 const VALUE_SOURCE: &str = "session::write::typed_adapter_tests::Entity::value";
2051 const REPLACEMENT_SOURCE: &str =
2052 "session::write::typed_adapter_tests::Entity::replacement_value";
2053 const OTHER_ID_SOURCE: &str = "session::write::typed_adapter_tests::OtherEntity::id";
2054 const ENTITY_DESCRIPTOR: TypedEntityDescriptor = TypedEntityDescriptor::new(
2055 ENTITY_SOURCE,
2056 &[ID_SOURCE],
2057 &[
2058 TypedFieldDescriptor::new(ID_SOURCE, TypedFieldType::Scalar(ScalarType::Nat64), false),
2059 TypedFieldDescriptor::new(
2060 VALUE_SOURCE,
2061 TypedFieldType::Scalar(ScalarType::Nat64),
2062 false,
2063 ),
2064 ],
2065 );
2066 const OTHER_ENTITY_DESCRIPTOR: TypedEntityDescriptor = TypedEntityDescriptor::new(
2067 OTHER_ENTITY_SOURCE,
2068 &[OTHER_ID_SOURCE],
2069 &[TypedFieldDescriptor::new(
2070 OTHER_ID_SOURCE,
2071 TypedFieldType::Scalar(ScalarType::Nat64),
2072 false,
2073 )],
2074 );
2075 const REPLACEMENT_DESCRIPTOR: TypedEntityDescriptor = TypedEntityDescriptor::new(
2076 ENTITY_SOURCE,
2077 &[ID_SOURCE],
2078 &[
2079 TypedFieldDescriptor::new(ID_SOURCE, TypedFieldType::Scalar(ScalarType::Nat64), false),
2080 TypedFieldDescriptor::new(
2081 REPLACEMENT_SOURCE,
2082 TypedFieldType::Scalar(ScalarType::Nat64),
2083 false,
2084 ),
2085 ],
2086 );
2087
2088 struct TestCanister;
2089
2090 impl Path for TestCanister {
2091 const PATH: &'static str = "session::write::typed_adapter_tests::Canister";
2092 }
2093
2094 impl CanisterKind for TestCanister {
2095 const COMMIT_MEMORY_ID: u8 = 41;
2096 const COMMIT_STABLE_KEY: &'static str = "icydb.typed_adapter_tests.commit.v1";
2097 const STARTUP_MEMORY_ID: u8 = 49;
2098 const STARTUP_STABLE_KEY: &'static str = "icydb.typed_adapter_tests.startup.control.v1";
2099 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 42;
2100 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
2101 "icydb.typed_adapter_tests.integrity.progress.v1";
2102 }
2103
2104 thread_local! {
2105 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
2106 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
2107 static SCHEMA_STORE: RefCell<SchemaStore> =
2108 const { RefCell::new(SchemaStore::init_heap()) };
2109 static OTHER_DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
2110 static OTHER_INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
2111 static OTHER_SCHEMA_STORE: RefCell<SchemaStore> =
2112 const { RefCell::new(SchemaStore::init_heap()) };
2113 static STORE_REGISTRY: StoreRegistry = {
2114 let mut registry = StoreRegistry::new();
2115 registry.register_store(
2116 STORE_PATH,
2117 &DATA_STORE,
2118 &INDEX_STORE,
2119 &SCHEMA_STORE,
2120 StoreAllocationIdentities::absent(),
2121 StoreRuntimeStorageCapabilities::heap(),
2122 ).expect("typed adapter test store should register");
2123 registry.register_store(
2124 OTHER_STORE_PATH,
2125 &OTHER_DATA_STORE,
2126 &OTHER_INDEX_STORE,
2127 &OTHER_SCHEMA_STORE,
2128 StoreAllocationIdentities::absent(),
2129 StoreRuntimeStorageCapabilities::heap(),
2130 ).expect("second typed adapter test store should register");
2131 registry
2132 };
2133 }
2134
2135 fn nat64_field(id: u32, name: &str, slot: u16) -> PersistedFieldSnapshot {
2136 PersistedFieldSnapshot::new_initial(
2137 FieldId::new(id),
2138 name.to_string(),
2139 SchemaFieldSlot::new(slot),
2140 AcceptedFieldKind::Nat64,
2141 Vec::new(),
2142 false,
2143 SchemaInsertDefault::None,
2144 FieldStorageDecode::ByKind,
2145 LeafCodec::Scalar(ScalarCodec::Nat64),
2146 )
2147 }
2148
2149 fn snapshot(
2150 entity_source: &str,
2151 entity_name: &str,
2152 fields: Vec<PersistedFieldSnapshot>,
2153 ) -> PersistedSchemaSnapshot {
2154 let layout = SchemaRowLayout::initial(
2155 fields
2156 .iter()
2157 .map(|field| (field.id(), field.slot()))
2158 .collect(),
2159 );
2160 PersistedSchemaSnapshot::new(
2161 SchemaVersion::initial(),
2162 entity_source.to_string(),
2163 entity_name.to_string(),
2164 FieldId::new(1),
2165 layout,
2166 fields,
2167 )
2168 }
2169
2170 fn field_source(source: &str) -> FieldSourceKey {
2171 FieldSourceKey::try_new(source).expect("typed field source should admit")
2172 }
2173
2174 fn publish(
2175 session: &DbSession<TestCanister>,
2176 expected: AcceptedSchemaRevision,
2177 revision: AcceptedSchemaRevision,
2178 snapshots: BTreeMap<EntityTag, PersistedSchemaSnapshot>,
2179 fields: BTreeMap<(EntityTag, FieldSourceKey), FieldId>,
2180 ) {
2181 publish_to_store(session, STORE_PATH, expected, revision, snapshots, fields);
2182 }
2183
2184 fn publish_to_store(
2185 session: &DbSession<TestCanister>,
2186 store_path: &'static str,
2187 expected: AcceptedSchemaRevision,
2188 revision: AcceptedSchemaRevision,
2189 snapshots: BTreeMap<EntityTag, PersistedSchemaSnapshot>,
2190 fields: BTreeMap<(EntityTag, FieldSourceKey), FieldId>,
2191 ) {
2192 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
2193 store_path, revision, snapshots, fields,
2194 );
2195 let store = session
2196 .db
2197 .store_handle(store_path)
2198 .expect("typed adapter test store should resolve");
2199 crate::db::commit::publish_accepted_schema_candidate(
2200 store_path, store, expected, &candidate,
2201 )
2202 .expect("typed binding candidate should publish");
2203 }
2204
2205 fn initialize_typed_session() -> DbSession<TestCanister> {
2206 let entity_tag = EntityTag::new(91);
2207 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2208 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2209 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2210 OTHER_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2211 OTHER_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2212 OTHER_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2213 let session = DbSession::<TestCanister>::new(
2214 &STORE_REGISTRY,
2215 &crate::db::RequestExecutionRoot::__new_runtime_root(),
2216 );
2217 session
2218 .db
2219 .drive_startup_recovery_page()
2220 .expect("typed adapter test database should initialize");
2221 publish(
2222 &session,
2223 AcceptedSchemaRevision::NONE,
2224 AcceptedSchemaRevision::INITIAL,
2225 BTreeMap::from([(
2226 entity_tag,
2227 snapshot(
2228 ENTITY_SOURCE,
2229 "Entity",
2230 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
2231 ),
2232 )]),
2233 BTreeMap::from([
2234 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
2235 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
2236 ]),
2237 );
2238 session
2239 }
2240
2241 fn initialize_mixed_typed_session(other_store: bool) -> DbSession<TestCanister> {
2242 let entity_tag = EntityTag::new(91);
2243 let other_entity_tag = EntityTag::new(92);
2244 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2245 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2246 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2247 OTHER_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2248 OTHER_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2249 OTHER_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2250 let session = DbSession::<TestCanister>::new(
2251 &STORE_REGISTRY,
2252 &crate::db::RequestExecutionRoot::__new_runtime_root(),
2253 );
2254 session
2255 .db
2256 .drive_startup_recovery_page()
2257 .expect("mixed typed adapter database should initialize");
2258
2259 let entity_snapshot = snapshot(
2260 ENTITY_SOURCE,
2261 "Entity",
2262 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
2263 );
2264 let other_snapshot = snapshot(
2265 OTHER_ENTITY_SOURCE,
2266 "OtherEntity",
2267 vec![nat64_field(1, "id", 0)],
2268 );
2269 let entity_fields = BTreeMap::from([
2270 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
2271 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
2272 ]);
2273 if other_store {
2274 publish(
2275 &session,
2276 AcceptedSchemaRevision::NONE,
2277 AcceptedSchemaRevision::INITIAL,
2278 BTreeMap::from([(entity_tag, entity_snapshot)]),
2279 entity_fields,
2280 );
2281 publish_to_store(
2282 &session,
2283 OTHER_STORE_PATH,
2284 AcceptedSchemaRevision::NONE,
2285 AcceptedSchemaRevision::INITIAL,
2286 BTreeMap::from([(other_entity_tag, other_snapshot)]),
2287 BTreeMap::from([(
2288 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
2289 FieldId::new(1),
2290 )]),
2291 );
2292 } else {
2293 let mut fields = entity_fields;
2294 fields.insert(
2295 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
2296 FieldId::new(1),
2297 );
2298 publish(
2299 &session,
2300 AcceptedSchemaRevision::NONE,
2301 AcceptedSchemaRevision::INITIAL,
2302 BTreeMap::from([
2303 (entity_tag, entity_snapshot),
2304 (other_entity_tag, other_snapshot),
2305 ]),
2306 fields,
2307 );
2308 }
2309 session
2310 }
2311
2312 fn typed_insert(
2313 binding: &DynamicTypedEntityBinding,
2314 id: u64,
2315 value: u64,
2316 ) -> DynamicTypedMutation {
2317 let patch = binding
2318 .bind_write_ordinals(vec![
2319 (0, DynamicWriteCell::Value(InputValue::nat64(id))),
2320 (1, DynamicWriteCell::Value(InputValue::nat64(value))),
2321 ])
2322 .expect("typed insert patch should bind");
2323 DynamicTypedMutation::Insert { patch }
2324 }
2325
2326 fn typed_other_insert(binding: &DynamicTypedEntityBinding, id: u64) -> DynamicTypedMutation {
2327 let patch = binding
2328 .bind_write_ordinals(vec![(0, DynamicWriteCell::Value(InputValue::nat64(id)))])
2329 .expect("other typed insert patch should bind");
2330 DynamicTypedMutation::Insert { patch }
2331 }
2332
2333 fn typed_delete(id: u64) -> DynamicTypedMutation {
2334 DynamicTypedMutation::Delete {
2335 key: InputValue::nat64(id),
2336 }
2337 }
2338
2339 fn typed_value_patch(
2340 binding: &DynamicTypedEntityBinding,
2341 value: u64,
2342 ) -> super::DynamicTypedStructuralPatch {
2343 binding
2344 .bind_write_ordinals(vec![(1, DynamicWriteCell::Value(InputValue::nat64(value)))])
2345 .expect("typed value patch should bind")
2346 }
2347
2348 fn assert_query_diagnostic(
2349 error: crate::db::QueryError,
2350 code: icydb_diagnostic_code::DiagnosticCode,
2351 origin: icydb_diagnostic_code::ErrorOrigin,
2352 detail: icydb_diagnostic_code::DiagnosticDetail,
2353 ) {
2354 let diagnostic = error.diagnostic();
2355 assert_eq!(diagnostic.code(), code);
2356 assert_eq!(diagnostic.origin(), origin);
2357 assert_eq!(diagnostic.detail(), Some(&detail));
2358 }
2359
2360 #[test]
2361 fn typed_adapter_kind_matching_is_exact_but_accepts_relation_key_wrappers() {
2362 let relation = AcceptedFieldKind::Relation {
2363 target_path: "test::Target".to_string(),
2364 target_entity_name: "Target".to_string(),
2365 target_entity_tag: EntityTag::new(7),
2366 target_store_path: "test::Store".to_string(),
2367 key_kind: Box::new(AcceptedFieldKind::Nat64),
2368 };
2369
2370 assert!(typed_adapter_field_kind_matches(
2371 &relation,
2372 &AcceptedFieldKind::Nat64,
2373 ));
2374 assert!(typed_adapter_field_kind_matches(
2375 &AcceptedFieldKind::List(Box::new(relation)),
2376 &AcceptedFieldKind::List(Box::new(AcceptedFieldKind::Nat64)),
2377 ));
2378 assert!(!typed_adapter_field_kind_matches(
2379 &AcceptedFieldKind::Nat64,
2380 &AcceptedFieldKind::Nat32,
2381 ));
2382 }
2383
2384 #[test]
2385 fn typed_adapter_field_contract_rejects_invalid_named_source_identity() {
2386 const NAT64: TypedFieldType = TypedFieldType::Scalar(ScalarType::Nat64);
2387
2388 assert!(matches!(
2389 typed_descriptor_field_type(TypedFieldType::Named("")),
2390 Err(DynamicTypedBindingError::FieldUnavailable),
2391 ));
2392 assert!(matches!(
2393 typed_descriptor_field_type(TypedFieldType::Scalar(ScalarType::Nat16)),
2394 Ok(icydb_schema::FieldType::Scalar(ScalarType::Nat16)),
2395 ));
2396 assert!(matches!(
2397 typed_descriptor_field_type(TypedFieldType::List(&NAT64)),
2398 Ok(icydb_schema::FieldType::List(item))
2399 if *item == icydb_schema::FieldType::Scalar(ScalarType::Nat64),
2400 ));
2401 }
2402
2403 #[test]
2404 fn typed_descriptor_primary_key_must_match_accepted_source_order() {
2405 const PRIMARY_KEY_MISMATCH: TypedEntityDescriptor =
2406 TypedEntityDescriptor::new(ENTITY_SOURCE, &[VALUE_SOURCE], ENTITY_DESCRIPTOR.fields);
2407 const NULLABILITY_MISMATCH: TypedEntityDescriptor = TypedEntityDescriptor::new(
2408 ENTITY_SOURCE,
2409 &[ID_SOURCE],
2410 &[
2411 TypedFieldDescriptor::new(
2412 ID_SOURCE,
2413 TypedFieldType::Scalar(ScalarType::Nat64),
2414 false,
2415 ),
2416 TypedFieldDescriptor::new(
2417 VALUE_SOURCE,
2418 TypedFieldType::Scalar(ScalarType::Nat64),
2419 true,
2420 ),
2421 ],
2422 );
2423
2424 let session = initialize_typed_session();
2425 assert!(matches!(
2426 session.issue_typed_entity_binding(&PRIMARY_KEY_MISMATCH),
2427 Err(DynamicTypedBindingError::IncompatibleField),
2428 ));
2429 assert!(matches!(
2430 session.issue_typed_entity_binding(&NULLABILITY_MISMATCH),
2431 Err(DynamicTypedBindingError::IncompatibleField),
2432 ));
2433 }
2434
2435 #[test]
2436 fn typed_mutation_batch_is_bounded_and_atomic() {
2437 let session = initialize_typed_session();
2438 let binding = session
2439 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2440 .expect("typed batch binding should issue");
2441
2442 session
2443 .execute_trusted_typed_mutation_batch(Vec::new())
2444 .expect_err("empty typed batch should reject");
2445 let insert = typed_insert(&binding, 1, 10);
2446 let oversized = (0..=super::MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS)
2447 .map(|_| (binding.clone(), insert.clone()))
2448 .collect();
2449 session
2450 .execute_trusted_typed_mutation_batch(oversized)
2451 .expect_err("oversized typed batch should reject");
2452
2453 let duplicate = vec![
2454 (binding.clone(), insert.clone()),
2455 (binding.clone(), typed_insert(&binding, 1, 11)),
2456 ];
2457 session
2458 .execute_trusted_typed_mutation_batch(duplicate)
2459 .expect_err("late duplicate key should reject the whole typed batch");
2460 let empty = session
2461 .execute_trusted_live_page(&crate::db::DynamicQuery::new("Entity"), None)
2462 .expect("failed typed batch should leave the entity readable");
2463 assert!(empty.rows.is_empty());
2464
2465 let result = session
2466 .execute_trusted_typed_mutation_batch(vec![
2467 (binding.clone(), insert),
2468 (binding.clone(), typed_insert(&binding, 2, 20)),
2469 ])
2470 .expect("valid typed batch should execute")
2471 .expect("exact binding should remain current");
2472 assert_eq!(result.len(), 2);
2473 assert!(result.iter().all(|item| item.affected_rows == 1));
2474 assert_eq!(
2475 result
2476 .into_iter()
2477 .map(|item| item.rows.into_iter().next().expect("one row per request"))
2478 .collect::<Vec<_>>(),
2479 vec![
2480 vec![
2481 crate::value::OutputValue::nat64(1),
2482 crate::value::OutputValue::nat64(10),
2483 ],
2484 vec![
2485 crate::value::OutputValue::nat64(2),
2486 crate::value::OutputValue::nat64(20),
2487 ],
2488 ]
2489 );
2490
2491 let mut mismatched = binding.clone();
2492 mismatched.accepted_revision = mismatched.accepted_revision.saturating_add(1);
2493 let mismatch = session
2494 .execute_trusted_typed_mutation_batch(vec![
2495 (binding.clone(), typed_insert(&binding, 3, 30)),
2496 (mismatched.clone(), typed_insert(&binding, 4, 40)),
2497 ])
2498 .expect("mismatched typed batch should fail closed");
2499 assert!(mismatch.is_none());
2500 let stale = session
2501 .execute_trusted_typed_mutation_batch(vec![(mismatched, typed_insert(&binding, 5, 50))])
2502 .expect("stale typed batch should fail closed");
2503 assert!(stale.is_none());
2504 }
2505
2506 #[test]
2507 fn same_entity_typed_mutation_batch_rejects_empty_oversized_and_stale_input() {
2508 let session = initialize_typed_session();
2509 let binding = session
2510 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2511 .expect("typed batch binding should issue");
2512
2513 session
2514 .execute_trusted_same_entity_typed_mutation_batch(&binding, Vec::new())
2515 .expect_err("empty same-entity typed batch should reject");
2516 let insert = typed_insert(&binding, 1, 10);
2517 session
2518 .execute_trusted_same_entity_typed_mutation_batch(
2519 &binding,
2520 vec![insert.clone(); super::MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS + 1],
2521 )
2522 .expect_err("oversized same-entity typed batch should reject");
2523
2524 let mut stale = binding;
2525 stale.accepted_revision = stale.accepted_revision.saturating_add(1);
2526 let result = session
2527 .execute_trusted_same_entity_typed_mutation_batch(&stale, vec![insert])
2528 .expect("stale same-entity typed admission should remain an adapter outcome");
2529 assert!(result.is_none());
2530 }
2531
2532 #[test]
2533 fn typed_mutation_batch_accepts_mixed_same_store_bindings_and_rejects_late_stale_input() {
2534 let session = initialize_mixed_typed_session(false);
2535 let binding = session
2536 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2537 .expect("first typed entity should bind");
2538 let other = session
2539 .issue_typed_entity_binding(&OTHER_ENTITY_DESCRIPTOR)
2540 .expect("second typed entity should bind");
2541
2542 let mut stale_other = other.clone();
2543 stale_other.accepted_revision = stale_other.accepted_revision.saturating_add(1);
2544 let stale = session
2545 .execute_trusted_typed_mutation_batch(vec![
2546 (binding.clone(), typed_insert(&binding, 1, 10)),
2547 (stale_other, typed_other_insert(&other, 1)),
2548 ])
2549 .expect("stale typed admission should remain an adapter outcome");
2550 assert!(stale.is_none());
2551 for entity in ["Entity", "OtherEntity"] {
2552 let rows = session
2553 .execute_trusted_live_page(&crate::db::DynamicQuery::new(entity), None)
2554 .expect("failed mixed admission should leave both entities readable");
2555 assert!(rows.rows.is_empty());
2556 }
2557
2558 let results = session
2559 .execute_trusted_typed_mutation_batch(vec![
2560 (other.clone(), typed_other_insert(&other, 2)),
2561 (binding.clone(), typed_insert(&binding, 3, 30)),
2562 ])
2563 .expect("same-store typed batch should execute")
2564 .expect("both typed bindings should remain current");
2565 assert_eq!(results.len(), 2);
2566 assert_eq!(results[0].entity, "OtherEntity");
2567 assert_eq!(
2568 results[0].rows,
2569 vec![vec![crate::value::OutputValue::nat64(2)]]
2570 );
2571 assert_eq!(results[1].entity, "Entity");
2572 assert_eq!(
2573 results[1].rows,
2574 vec![vec![
2575 crate::value::OutputValue::nat64(3),
2576 crate::value::OutputValue::nat64(30),
2577 ]],
2578 );
2579 }
2580
2581 #[test]
2582 fn typed_mutation_batch_rejects_cross_store_bindings_before_writes() {
2583 let session = initialize_mixed_typed_session(true);
2584 let binding = session
2585 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2586 .expect("first store typed entity should bind");
2587 let other = session
2588 .issue_typed_entity_binding(&OTHER_ENTITY_DESCRIPTOR)
2589 .expect("second store typed entity should bind");
2590
2591 let error = session
2592 .execute_trusted_typed_mutation_batch(vec![
2593 (binding.clone(), typed_insert(&binding, 1, 10)),
2594 (other.clone(), typed_other_insert(&other, 1)),
2595 ])
2596 .expect_err("typed cross-store rows must reject");
2597 assert!(matches!(
2598 error.diagnostic().detail(),
2599 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
2600 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchStoreMismatch,
2601 })
2602 ));
2603 for entity in ["Entity", "OtherEntity"] {
2604 let rows = session
2605 .execute_trusted_live_page(&crate::db::DynamicQuery::new(entity), None)
2606 .expect("cross-store rejection should leave both entities readable");
2607 assert!(rows.rows.is_empty());
2608 }
2609 }
2610
2611 #[test]
2612 fn typed_mutation_batch_rechecks_late_field_identity_under_current_authority() {
2613 let session = initialize_typed_session();
2614 let binding = session
2615 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2616 .expect("entity should bind");
2617
2618 for (field_id, slot) in [(3, 1), (2, 2)] {
2621 let mismatched = DynamicTypedEntityBinding::new(
2622 binding.database_incarnation,
2623 binding.entity_source.clone(),
2624 binding.entity_label.clone(),
2625 binding.entity_tag,
2626 binding.accepted_revision,
2627 binding.accepted_fingerprint,
2628 binding.entity_generation,
2629 vec![
2630 (ID_SOURCE.to_string(), 1, 0, "id".to_string()),
2631 (
2632 VALUE_SOURCE.to_string(),
2633 field_id,
2634 slot,
2635 "value".to_string(),
2636 ),
2637 ],
2638 binding.named_types.clone(),
2639 binding.enum_variants.clone(),
2640 binding.composite_fields.clone(),
2641 )
2642 .expect("distinct field mapping should form an opaque binding");
2643 let result = session
2644 .execute_trusted_typed_mutation_batch(vec![
2645 (binding.clone(), typed_insert(&binding, 1, 10)),
2646 (mismatched, typed_insert(&binding, 2, 20)),
2647 ])
2648 .expect("mismatched mapping should remain an adapter rejection");
2649 assert!(result.is_none());
2650 DATA_STORE.with(|store| assert_eq!(store.borrow().len(), 0));
2651 }
2652
2653 let result = session
2654 .execute_trusted_typed_mutation_batch(vec![
2655 (binding.clone(), typed_insert(&binding, 1, 10)),
2656 (binding.clone(), typed_insert(&binding, 2, 20)),
2657 ])
2658 .expect("corrected batch should execute after rejected borrows")
2659 .expect("current binding should remain valid");
2660 assert_eq!(result.len(), 2);
2661 }
2662
2663 #[test]
2664 fn same_entity_typed_mutation_batch_preserves_mixed_result_order() {
2665 let session = initialize_typed_session();
2666 let binding = session
2667 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2668 .expect("typed batch binding should issue");
2669 session
2670 .execute_trusted_same_entity_typed_mutation_batch(
2671 &binding,
2672 vec![
2673 typed_insert(&binding, 1, 10),
2674 typed_insert(&binding, 2, 20),
2675 typed_insert(&binding, 4, 40),
2676 ],
2677 )
2678 .expect("typed fixture batch should execute")
2679 .expect("typed fixture binding should be current");
2680
2681 let result = session
2682 .execute_trusted_same_entity_typed_mutation_batch(
2683 &binding,
2684 vec![
2685 DynamicTypedMutation::Update {
2686 key: InputValue::nat64(1),
2687 patch: typed_value_patch(&binding, 11),
2688 },
2689 DynamicTypedMutation::Replace {
2690 key: InputValue::nat64(2),
2691 patch: typed_value_patch(&binding, 22),
2692 },
2693 typed_insert(&binding, 3, 30),
2694 typed_delete(4),
2695 ],
2696 )
2697 .expect("mixed typed batch should execute")
2698 .expect("mixed typed binding should remain current");
2699 assert_eq!(result.len(), 4);
2700 assert_eq!(result.affected_rows, 4);
2701 assert_eq!(
2702 result.rows,
2703 vec![
2704 vec![
2705 crate::value::OutputValue::nat64(1),
2706 crate::value::OutputValue::nat64(11),
2707 ],
2708 vec![
2709 crate::value::OutputValue::nat64(2),
2710 crate::value::OutputValue::nat64(22),
2711 ],
2712 vec![
2713 crate::value::OutputValue::nat64(3),
2714 crate::value::OutputValue::nat64(30),
2715 ],
2716 vec![
2717 crate::value::OutputValue::nat64(4),
2718 crate::value::OutputValue::nat64(40),
2719 ],
2720 ],
2721 );
2722 }
2723
2724 #[expect(clippy::too_many_lines)]
2727 #[test]
2728 fn typed_binding_uses_accepted_ids_and_slots_across_renames_and_name_reuse() {
2729 let entity_tag = EntityTag::new(91);
2730 let other_entity_tag = EntityTag::new(92);
2731 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2732 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2733 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2734 OTHER_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2735 OTHER_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2736 OTHER_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2737
2738 let session = DbSession::<TestCanister>::new(
2739 &STORE_REGISTRY,
2740 &crate::db::RequestExecutionRoot::__new_runtime_root(),
2741 );
2742 session
2743 .db
2744 .drive_startup_recovery_page()
2745 .expect("typed adapter test database should initialize");
2746 publish(
2747 &session,
2748 AcceptedSchemaRevision::NONE,
2749 AcceptedSchemaRevision::INITIAL,
2750 BTreeMap::from([(
2751 entity_tag,
2752 snapshot(
2753 ENTITY_SOURCE,
2754 "Entity",
2755 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
2756 ),
2757 )]),
2758 BTreeMap::from([
2759 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
2760 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
2761 ]),
2762 );
2763
2764 let initial_catalog = session
2765 .find_accepted_schema_catalog_context_for_entity_source_key(ENTITY_SOURCE)
2766 .expect("initial source catalog lookup should inspect")
2767 .expect("initial source catalog should exist");
2768 assert_eq!(initial_catalog.identity().entity_tag(), entity_tag);
2769 let initial = session
2770 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2771 .expect("initial typed binding should issue");
2772 assert_eq!(initial.field_slot(ID_SOURCE), Some(0));
2773 assert_eq!(initial.field_slot(VALUE_SOURCE), Some(1));
2774 assert_eq!(initial.output_field_slot("value"), Some(1));
2775 let initial_patch = initial
2776 .bind_write_ordinals(vec![(1, DynamicWriteCell::Value(InputValue::nat64(7)))])
2777 .expect("source-bound patch should lower");
2778 assert_eq!(
2779 initial_patch.fields(),
2780 &[(1, DynamicWriteCell::Value(InputValue::nat64(7)))]
2781 );
2782 assert!(
2783 initial
2784 .bind_write_ordinals(vec![(2, DynamicWriteCell::Value(InputValue::nat64(8)),)])
2785 .is_none(),
2786 "out-of-range descriptor ordinals must fail closed",
2787 );
2788 assert!(
2789 initial
2790 .bind_write_ordinals(vec![
2791 (1, DynamicWriteCell::Omitted),
2792 (1, DynamicWriteCell::Default),
2793 ])
2794 .is_none(),
2795 "duplicate descriptor ordinals must fail closed",
2796 );
2797 assert!(
2798 initial
2799 .bind_write_ordinals(vec![
2800 (1, DynamicWriteCell::Omitted),
2801 (0, DynamicWriteCell::Default),
2802 ])
2803 .is_none(),
2804 "out-of-order descriptor ordinals must fail closed",
2805 );
2806
2807 publish(
2808 &session,
2809 AcceptedSchemaRevision::INITIAL,
2810 AcceptedSchemaRevision::new(2),
2811 BTreeMap::from([
2812 (
2813 entity_tag,
2814 snapshot(
2815 ENTITY_SOURCE,
2816 "RenamedEntity",
2817 vec![
2818 nat64_field(1, "id", 0),
2819 nat64_field(2, "renamed_value", 1),
2820 nat64_field(3, "value", 2),
2821 ],
2822 ),
2823 ),
2824 (
2825 other_entity_tag,
2826 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
2827 ),
2828 ]),
2829 BTreeMap::from([
2830 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
2831 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
2832 (
2833 (entity_tag, field_source(REPLACEMENT_SOURCE)),
2834 FieldId::new(3),
2835 ),
2836 (
2837 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
2838 FieldId::new(1),
2839 ),
2840 ]),
2841 );
2842
2843 let stale_authority = session
2844 .ensure_accepted_schema_authority_is_current_for_store_path(
2845 STORE_PATH,
2846 initial_catalog.value_catalog_handle().authority(),
2847 )
2848 .expect_err("the initial accepted authority must be stale after revision two");
2849 assert_eq!(
2850 stale_authority.diagnostic_facts(),
2851 vec![
2852 (
2853 icydb_diagnostic_code::DiagnosticFactTag::ExpectedRevision,
2854 AcceptedSchemaRevision::INITIAL.get(),
2855 ),
2856 (
2857 icydb_diagnostic_code::DiagnosticFactTag::CurrentRevision,
2858 AcceptedSchemaRevision::new(2).get(),
2859 ),
2860 ],
2861 );
2862
2863 assert!(
2864 !session
2865 .typed_entity_binding_is_current(&initial)
2866 .expect("renamed binding currentness should inspect")
2867 );
2868 let renamed = session
2869 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2870 .expect("renamed source-bound adapter should rebind");
2871 assert_eq!(renamed.entity(), "RenamedEntity");
2872 assert_eq!(renamed.field_slot(VALUE_SOURCE), Some(1));
2873 assert_eq!(renamed.output_field_slot("renamed_value"), Some(1));
2874 assert_eq!(renamed.output_field_slot("value"), None);
2875
2876 publish(
2877 &session,
2878 AcceptedSchemaRevision::new(2),
2879 AcceptedSchemaRevision::new(3),
2880 BTreeMap::from([
2881 (
2882 entity_tag,
2883 snapshot(
2884 ENTITY_SOURCE,
2885 "RenamedEntity",
2886 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
2887 ),
2888 ),
2889 (
2890 other_entity_tag,
2891 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
2892 ),
2893 ]),
2894 BTreeMap::from([
2895 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
2896 (
2897 (entity_tag, field_source(REPLACEMENT_SOURCE)),
2898 FieldId::new(2),
2899 ),
2900 (
2901 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
2902 FieldId::new(1),
2903 ),
2904 ]),
2905 );
2906
2907 assert!(matches!(
2908 session.issue_typed_entity_binding(&ENTITY_DESCRIPTOR),
2909 Err(DynamicTypedBindingError::FieldUnavailable),
2910 ));
2911 assert!(
2912 !session
2913 .typed_entity_binding_is_current(&renamed)
2914 .expect("removed source binding should become stale")
2915 );
2916
2917 let replacement = session
2918 .issue_typed_entity_binding(&REPLACEMENT_DESCRIPTOR)
2919 .expect("explicit replacement source should bind");
2920 assert!(
2921 session
2922 .execute_trusted_typed_mutation(
2923 &replacement,
2924 DynamicTypedMutation::Insert {
2925 patch: initial_patch
2926 },
2927 )
2928 .expect("cross-binding patch should fail closed")
2929 .is_none()
2930 );
2931 let patch = replacement
2932 .bind_write_ordinals(vec![
2933 (0, DynamicWriteCell::Value(InputValue::nat64(1))),
2934 (1, DynamicWriteCell::Value(InputValue::nat64(9))),
2935 ])
2936 .expect("replacement source write should bind by accepted IDs and slots");
2937 let result = session
2938 .execute_trusted_typed_mutation(&replacement, DynamicTypedMutation::Insert { patch })
2939 .expect("typed insert should use the accepted mutation pipeline")
2940 .expect("replacement binding should remain current");
2941 assert_eq!(result.entity, "RenamedEntity");
2942 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
2943 assert_eq!(
2944 result.rows,
2945 vec![vec![
2946 crate::value::OutputValue::nat64(1),
2947 crate::value::OutputValue::nat64(9)
2948 ]]
2949 );
2950 assert_eq!(result.affected_rows, 1);
2951
2952 let second_patch = replacement
2953 .bind_write_ordinals(vec![
2954 (0, DynamicWriteCell::Value(InputValue::nat64(2))),
2955 (1, DynamicWriteCell::Value(InputValue::nat64(10))),
2956 ])
2957 .expect("second source-bound patch should lower");
2958 session
2959 .execute_trusted_typed_mutation(
2960 &replacement,
2961 DynamicTypedMutation::Insert {
2962 patch: second_patch,
2963 },
2964 )
2965 .expect("second typed insert should use the accepted mutation pipeline")
2966 .expect("replacement binding should remain current");
2967
2968 {
2969 let query = crate::db::DynamicQuery::new("RenamedEntity")
2970 .select(["id", "value"])
2971 .order_by(crate::db::asc("id"))
2972 .limit(1);
2973 let result = session
2974 .execute_trusted_live_page(&query, None)
2975 .expect("SQL-free dynamic execution should use accepted authority");
2976 assert_eq!(result.entity, "RenamedEntity");
2977 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
2978 assert_eq!(
2979 result.rows,
2980 vec![vec![
2981 crate::value::OutputValue::nat64(1),
2982 crate::value::OutputValue::nat64(9)
2983 ]]
2984 );
2985 assert_eq!(result.row_count, 1);
2986 assert_query_diagnostic(
2987 session
2988 .execute_trusted_live_page(&query.cursor("00"), None)
2989 .expect_err("scalar execution must reject grouped cursor state"),
2990 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
2991 icydb_diagnostic_code::ErrorOrigin::Query,
2992 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
2993 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
2994 },
2995 );
2996 assert_query_diagnostic(
2997 session
2998 .execute_public_dynamic_grouped_query(
2999 &crate::db::DynamicQuery::new("RenamedEntity").grouped_limits(1, 1024),
3000 )
3001 .expect_err("grouped execution must reject scalar query state"),
3002 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
3003 icydb_diagnostic_code::ErrorOrigin::Query,
3004 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
3005 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
3006 },
3007 );
3008
3009 let grouped_query = crate::db::DynamicQuery::new("RenamedEntity")
3010 .filter(crate::db::FieldRef::new("id").eq(1_u64))
3011 .group_by("value")
3012 .aggregate(crate::db::count())
3013 .grouped_limits(1, 16 * 1024)
3014 .limit(1);
3015 let grouped = session
3016 .execute_public_dynamic_grouped_query(&grouped_query)
3017 .expect("SQL-free grouped execution should use accepted authority");
3018 let typed_grouped = session
3019 .execute_public_dynamic_grouped_query_for_typed_binding(
3020 &replacement,
3021 &grouped_query,
3022 )
3023 .expect("typed grouped execution should inspect accepted authority")
3024 .expect("replacement binding should remain current");
3025 assert_eq!(typed_grouped, grouped);
3026 assert!(
3027 session
3028 .execute_public_dynamic_grouped_query_for_typed_binding(
3029 &renamed,
3030 &grouped_query,
3031 )
3032 .expect("stale grouped binding should inspect accepted authority")
3033 .is_none(),
3034 "stale typed grouped bindings must fail closed before execution"
3035 );
3036 assert_eq!(grouped.entity, "RenamedEntity");
3037 assert_eq!(grouped.row_count, 1);
3038 assert_eq!(grouped.rows.len(), 1);
3039 assert_eq!(
3040 grouped.rows[0].group_key(),
3041 &[crate::value::OutputValue::nat64(9)]
3042 );
3043 assert_eq!(
3044 grouped.rows[0].aggregate_values(),
3045 &[crate::value::OutputValue::nat64(1)]
3046 );
3047 assert_eq!(grouped.next_cursor, None);
3048
3049 let grouped_state_error = session
3050 .execute_trusted_dynamic_grouped_query(&grouped_query.clone().grouped_limits(1, 1))
3051 .expect_err("grouped retained state must respect its explicit byte ceiling");
3052 assert!(matches!(
3053 grouped_state_error.diagnostic().detail(),
3054 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3055 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
3056 })
3057 ));
3058 assert_eq!(
3059 grouped_state_error.diagnostic_facts()[0],
3060 (
3061 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
3062 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::GroupDistinctStateBytes.raw(),
3063 ),
3064 );
3065
3066 assert_query_diagnostic(
3067 session
3068 .execute_public_dynamic_grouped_query(&grouped_query.clone().select(["value"]))
3069 .expect_err("grouped output must reject scalar selection"),
3070 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
3071 icydb_diagnostic_code::ErrorOrigin::Query,
3072 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
3073 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
3074 },
3075 );
3076 assert_query_diagnostic(
3077 session
3078 .execute_public_dynamic_grouped_query(
3079 &crate::db::DynamicQuery::new("RenamedEntity")
3080 .group_by("value")
3081 .aggregate(crate::db::count()),
3082 )
3083 .expect_err("public grouped execution must require explicit limits"),
3084 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
3085 icydb_diagnostic_code::ErrorOrigin::Query,
3086 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
3087 reason:
3088 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
3089 },
3090 );
3091 assert_query_diagnostic(
3092 session
3093 .execute_trusted_dynamic_grouped_query(
3094 &crate::db::DynamicQuery::new("RenamedEntity")
3095 .group_by("value")
3096 .aggregate(crate::db::count())
3097 .grouped_limits(0, 1024),
3098 )
3099 .expect_err("trusted grouped execution must reject zero limits"),
3100 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
3101 icydb_diagnostic_code::ErrorOrigin::Query,
3102 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
3103 reason:
3104 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
3105 },
3106 );
3107 assert_query_diagnostic(
3108 session
3109 .execute_public_dynamic_grouped_query(&grouped_query.grouped_limits(101, 1024))
3110 .expect_err("public grouped execution must enforce its group budget"),
3111 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
3112 icydb_diagnostic_code::ErrorOrigin::Query,
3113 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
3114 reason:
3115 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryExceedsBudget,
3116 },
3117 );
3118
3119 let paged_query = crate::db::DynamicQuery::new("RenamedEntity")
3120 .group_by("value")
3121 .aggregate(crate::db::count())
3122 .grouped_limits(2, 16 * 1024)
3123 .limit(1);
3124 assert_query_diagnostic(
3125 session
3126 .execute_public_dynamic_grouped_query(&paged_query)
3127 .expect_err("public grouped execution must reject an unbounded full scan"),
3128 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
3129 icydb_diagnostic_code::ErrorOrigin::Query,
3130 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
3131 reason:
3132 icydb_diagnostic_code::QueryReadAdmissionCode::UnboundedFullScanRejected,
3133 },
3134 );
3135 let first_page = session
3136 .execute_trusted_dynamic_grouped_query(&paged_query)
3137 .expect("SQL-free grouped first page should execute");
3138 assert_eq!(first_page.row_count, 1);
3139 assert_eq!(
3140 first_page.rows[0].group_key(),
3141 &[crate::value::OutputValue::nat64(9)]
3142 );
3143 let cursor = first_page
3144 .next_cursor
3145 .expect("first grouped page should return a continuation cursor");
3146 assert_query_diagnostic(
3147 session
3148 .execute_trusted_dynamic_grouped_query(
3149 &paged_query.clone().cursor(format!("{cursor}0")),
3150 )
3151 .expect_err("tampered grouped cursor must fail closed"),
3152 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
3153 icydb_diagnostic_code::ErrorOrigin::Cursor,
3154 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
3155 kind: icydb_diagnostic_code::QueryErrorKind::InvalidContinuationCursor,
3156 },
3157 );
3158 let second_page = session
3159 .execute_trusted_dynamic_grouped_query(&paged_query.cursor(cursor))
3160 .expect("SQL-free grouped continuation should execute");
3161 assert_eq!(second_page.row_count, 1);
3162 assert_eq!(
3163 second_page.rows[0].group_key(),
3164 &[crate::value::OutputValue::nat64(10)]
3165 );
3166 assert_eq!(second_page.next_cursor, None);
3167 }
3168 }
3169}
3170
3171#[cfg(test)]
3172mod mixed_relation_batch_tests {
3173 use super::{DbSession, DynamicMutation, DynamicStructuralPatch, DynamicWriteCell};
3174 use crate::{
3175 db::{
3176 DynamicQuery, asc,
3177 data::DataStore,
3178 desc,
3179 index::IndexStore,
3180 query::expr::FilterExpr,
3181 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
3182 schema::{
3183 AcceptedConstraintCatalog, AcceptedFieldKind, AcceptedSchemaRevision, FieldId,
3184 FieldStorageDecode, FieldWriteManagement, LeafCodec, PersistedFieldSnapshot,
3185 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
3186 PersistedRelationEdgeSnapshot, PersistedSchemaSnapshot, RelationId, ScalarCodec,
3187 SchemaFieldSlot, SchemaFieldWritePolicy, SchemaIndexId, SchemaInsertDefault,
3188 SchemaRowLayout, SchemaStore, SchemaVersion,
3189 accepted_schema_candidate_with_field_bindings_for_tests,
3190 },
3191 },
3192 error::{ErrorClass, ErrorOrigin},
3193 traits::{CanisterKind, Path},
3194 types::EntityTag,
3195 value::{InputValue, OutputValue},
3196 };
3197 use icydb_schema::FieldSourceKey;
3198 use std::{cell::RefCell, collections::BTreeMap};
3199
3200 const STORE_PATH: &str = "session::write::mixed_relation_batch_tests::Store";
3201 const ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node";
3202 const ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::id";
3203 const PARENT_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::parent_id";
3204 const CODE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::code";
3205 const ENTITY_NAME: &str = "MixedRelationNode";
3206 const ENTITY_TAG: EntityTag = EntityTag::new(94);
3207 const OTHER_ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other";
3208 const OTHER_ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::id";
3209 const OTHER_VALUE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::value";
3210 const OTHER_NODE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::node_id";
3211 const OTHER_ENTITY_NAME: &str = "MixedRelationOther";
3212 const OTHER_ENTITY_TAG: EntityTag = EntityTag::new(95);
3213 const CROSS_STORE_PATH: &str = "session::write::mixed_relation_batch_tests::OtherStore";
3214 const CROSS_ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::CrossStore";
3215 const CROSS_ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::CrossStore::id";
3216 const CROSS_ENTITY_NAME: &str = "MixedCrossStore";
3217 const CROSS_ENTITY_TAG: EntityTag = EntityTag::new(2_000);
3218 fn batch_rows(results: &[crate::db::DynamicMutationResult]) -> Vec<Vec<OutputValue>> {
3219 results
3220 .iter()
3221 .flat_map(|result| result.rows.iter().cloned())
3222 .collect()
3223 }
3224
3225 struct TestCanister;
3226
3227 impl Path for TestCanister {
3228 const PATH: &'static str = "session::write::mixed_relation_batch_tests::Canister";
3229 }
3230
3231 impl CanisterKind for TestCanister {
3232 const COMMIT_MEMORY_ID: u8 = 47;
3233 const COMMIT_STABLE_KEY: &'static str = "icydb.mixed_relation_batch_tests.commit.v1";
3234 const STARTUP_MEMORY_ID: u8 = 50;
3235 const STARTUP_STABLE_KEY: &'static str =
3236 "icydb.mixed_relation_batch_tests.startup.control.v1";
3237 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 48;
3238 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3239 "icydb.mixed_relation_batch_tests.integrity.progress.v1";
3240 }
3241
3242 thread_local! {
3243 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
3244 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
3245 static SCHEMA_STORE: RefCell<SchemaStore> =
3246 const { RefCell::new(SchemaStore::init_heap()) };
3247 static CROSS_DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
3248 static CROSS_INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
3249 static CROSS_SCHEMA_STORE: RefCell<SchemaStore> =
3250 const { RefCell::new(SchemaStore::init_heap()) };
3251 static STORE_REGISTRY: StoreRegistry = {
3252 let mut registry = StoreRegistry::new();
3253 registry.register_store(
3254 STORE_PATH,
3255 &DATA_STORE,
3256 &INDEX_STORE,
3257 &SCHEMA_STORE,
3258 StoreAllocationIdentities::absent(),
3259 StoreRuntimeStorageCapabilities::heap(),
3260 ).expect("mixed relation test store should register");
3261 registry.register_store(
3262 CROSS_STORE_PATH,
3263 &CROSS_DATA_STORE,
3264 &CROSS_INDEX_STORE,
3265 &CROSS_SCHEMA_STORE,
3266 StoreAllocationIdentities::absent(),
3267 StoreRuntimeStorageCapabilities::heap(),
3268 ).expect("cross-store test store should register");
3269 registry
3270 };
3271 }
3272
3273 fn source_key(source: &str) -> FieldSourceKey {
3274 FieldSourceKey::try_new(source).expect("mixed relation field source should admit")
3275 }
3276
3277 fn relation_snapshot() -> PersistedSchemaSnapshot {
3278 let fields = vec![
3279 PersistedFieldSnapshot::new_initial(
3280 FieldId::new(1),
3281 "id".to_string(),
3282 SchemaFieldSlot::new(0),
3283 AcceptedFieldKind::Nat64,
3284 Vec::new(),
3285 false,
3286 SchemaInsertDefault::None,
3287 FieldStorageDecode::ByKind,
3288 LeafCodec::Scalar(ScalarCodec::Nat64),
3289 ),
3290 PersistedFieldSnapshot::new_initial(
3291 FieldId::new(2),
3292 "parent_id".to_string(),
3293 SchemaFieldSlot::new(1),
3294 AcceptedFieldKind::Nat64,
3295 Vec::new(),
3296 true,
3297 SchemaInsertDefault::None,
3298 FieldStorageDecode::ByKind,
3299 LeafCodec::Scalar(ScalarCodec::Nat64),
3300 ),
3301 PersistedFieldSnapshot::new_initial(
3302 FieldId::new(3),
3303 "code".to_string(),
3304 SchemaFieldSlot::new(2),
3305 AcceptedFieldKind::Nat64,
3306 Vec::new(),
3307 false,
3308 SchemaInsertDefault::None,
3309 FieldStorageDecode::ByKind,
3310 LeafCodec::Scalar(ScalarCodec::Nat64),
3311 ),
3312 ];
3313 let relation = PersistedRelationEdgeSnapshot::new_direct(
3314 RelationId::new(1).expect("mixed relation identity should be non-zero"),
3315 "parent".to_string(),
3316 ENTITY_SOURCE.to_string(),
3317 vec![FieldId::new(2)],
3318 );
3319 let snapshot = PersistedSchemaSnapshot::new_with_indexes(
3320 SchemaVersion::initial(),
3321 ENTITY_SOURCE.to_string(),
3322 ENTITY_NAME.to_string(),
3323 FieldId::new(1),
3324 SchemaRowLayout::initial(
3325 fields
3326 .iter()
3327 .map(|field| (field.id(), field.slot()))
3328 .collect(),
3329 ),
3330 fields,
3331 vec![PersistedIndexSnapshot::new(
3332 SchemaIndexId::new(1).expect("mixed unique index identity should be non-zero"),
3333 1,
3334 "by_code".to_string(),
3335 STORE_PATH.to_string(),
3336 true,
3337 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
3338 FieldId::new(3),
3339 SchemaFieldSlot::new(2),
3340 vec!["code".to_string()],
3341 AcceptedFieldKind::Nat64,
3342 false,
3343 )]),
3344 None,
3345 )],
3346 )
3347 .with_relations(vec![relation]);
3348 let constraints = AcceptedConstraintCatalog::initial(
3349 snapshot.fields(),
3350 snapshot.indexes(),
3351 snapshot.relations(),
3352 )
3353 .expect("mixed relation constraints should close");
3354 snapshot.with_constraint_catalog(constraints)
3355 }
3356
3357 fn other_snapshot() -> PersistedSchemaSnapshot {
3358 let fields = vec![
3359 PersistedFieldSnapshot::new_initial(
3360 FieldId::new(1),
3361 "id".to_string(),
3362 SchemaFieldSlot::new(0),
3363 AcceptedFieldKind::Nat64,
3364 Vec::new(),
3365 false,
3366 SchemaInsertDefault::None,
3367 FieldStorageDecode::ByKind,
3368 LeafCodec::Scalar(ScalarCodec::Nat64),
3369 ),
3370 PersistedFieldSnapshot::new_initial(
3371 FieldId::new(2),
3372 "value".to_string(),
3373 SchemaFieldSlot::new(1),
3374 AcceptedFieldKind::Nat64,
3375 Vec::new(),
3376 false,
3377 SchemaInsertDefault::None,
3378 FieldStorageDecode::ByKind,
3379 LeafCodec::Scalar(ScalarCodec::Nat64),
3380 ),
3381 PersistedFieldSnapshot::new_initial(
3382 FieldId::new(3),
3383 "node_id".to_string(),
3384 SchemaFieldSlot::new(2),
3385 AcceptedFieldKind::Nat64,
3386 Vec::new(),
3387 true,
3388 SchemaInsertDefault::None,
3389 FieldStorageDecode::ByKind,
3390 LeafCodec::Scalar(ScalarCodec::Nat64),
3391 ),
3392 ];
3393 let relation = PersistedRelationEdgeSnapshot::new_direct(
3394 RelationId::new(1).expect("cross-entity relation identity should be non-zero"),
3395 "node".to_string(),
3396 ENTITY_SOURCE.to_string(),
3397 vec![FieldId::new(3)],
3398 );
3399 let snapshot = PersistedSchemaSnapshot::new(
3400 SchemaVersion::initial(),
3401 OTHER_ENTITY_SOURCE.to_string(),
3402 OTHER_ENTITY_NAME.to_string(),
3403 FieldId::new(1),
3404 SchemaRowLayout::initial(
3405 fields
3406 .iter()
3407 .map(|field| (field.id(), field.slot()))
3408 .collect(),
3409 ),
3410 fields,
3411 )
3412 .with_relations(vec![relation]);
3413 let constraints = AcceptedConstraintCatalog::initial(
3414 snapshot.fields(),
3415 snapshot.indexes(),
3416 snapshot.relations(),
3417 )
3418 .expect("cross-entity relation constraints should close");
3419 snapshot.with_constraint_catalog(constraints)
3420 }
3421
3422 fn bounded_entity_snapshot(index: usize) -> PersistedSchemaSnapshot {
3423 let fields = vec![
3424 PersistedFieldSnapshot::new_initial(
3425 FieldId::new(1),
3426 "id".to_string(),
3427 SchemaFieldSlot::new(0),
3428 AcceptedFieldKind::Nat64,
3429 Vec::new(),
3430 false,
3431 SchemaInsertDefault::None,
3432 FieldStorageDecode::ByKind,
3433 LeafCodec::Scalar(ScalarCodec::Nat64),
3434 ),
3435 PersistedFieldSnapshot::new_initial_with_write_policy(
3436 FieldId::new(2),
3437 "updated_at".to_string(),
3438 SchemaFieldSlot::new(1),
3439 AcceptedFieldKind::Timestamp,
3440 Vec::new(),
3441 false,
3442 SchemaInsertDefault::None,
3443 SchemaFieldWritePolicy::from_model_policies(
3444 None,
3445 Some(FieldWriteManagement::UpdatedAt),
3446 ),
3447 FieldStorageDecode::ByKind,
3448 LeafCodec::Scalar(ScalarCodec::Timestamp),
3449 ),
3450 ];
3451 PersistedSchemaSnapshot::new(
3452 SchemaVersion::initial(),
3453 format!("session::write::mixed_relation_batch_tests::Bounded{index}"),
3454 format!("MixedBounded{index}"),
3455 FieldId::new(1),
3456 SchemaRowLayout::initial(
3457 fields
3458 .iter()
3459 .map(|field| (field.id(), field.slot()))
3460 .collect(),
3461 ),
3462 fields,
3463 )
3464 }
3465
3466 fn cross_store_snapshot() -> PersistedSchemaSnapshot {
3467 let field = PersistedFieldSnapshot::new_initial(
3468 FieldId::new(1),
3469 "id".to_string(),
3470 SchemaFieldSlot::new(0),
3471 AcceptedFieldKind::Nat64,
3472 Vec::new(),
3473 false,
3474 SchemaInsertDefault::None,
3475 FieldStorageDecode::ByKind,
3476 LeafCodec::Scalar(ScalarCodec::Nat64),
3477 );
3478 PersistedSchemaSnapshot::new(
3479 SchemaVersion::initial(),
3480 CROSS_ENTITY_SOURCE.to_string(),
3481 CROSS_ENTITY_NAME.to_string(),
3482 FieldId::new(1),
3483 SchemaRowLayout::initial(vec![(field.id(), field.slot())]),
3484 vec![field],
3485 )
3486 }
3487
3488 fn initialize() -> DbSession<TestCanister> {
3489 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3490 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3491 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3492 CROSS_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3493 CROSS_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3494 CROSS_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3495 let session = DbSession::<TestCanister>::new(
3496 &STORE_REGISTRY,
3497 &crate::db::RequestExecutionRoot::__new_runtime_root(),
3498 );
3499 session
3500 .db
3501 .drive_startup_recovery_page()
3502 .expect("mixed relation database should initialize");
3503 let mut snapshots = BTreeMap::from([
3504 (ENTITY_TAG, relation_snapshot()),
3505 (OTHER_ENTITY_TAG, other_snapshot()),
3506 ]);
3507 let mut field_bindings = BTreeMap::from([
3508 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
3509 ((ENTITY_TAG, source_key(PARENT_SOURCE)), FieldId::new(2)),
3510 ((ENTITY_TAG, source_key(CODE_SOURCE)), FieldId::new(3)),
3511 (
3512 (OTHER_ENTITY_TAG, source_key(OTHER_ID_SOURCE)),
3513 FieldId::new(1),
3514 ),
3515 (
3516 (OTHER_ENTITY_TAG, source_key(OTHER_VALUE_SOURCE)),
3517 FieldId::new(2),
3518 ),
3519 (
3520 (OTHER_ENTITY_TAG, source_key(OTHER_NODE_SOURCE)),
3521 FieldId::new(3),
3522 ),
3523 ]);
3524 for index in 0..65 {
3525 let tag = EntityTag::new(1_000 + index as u64);
3526 snapshots.insert(tag, bounded_entity_snapshot(index));
3527 field_bindings.insert(
3528 (
3529 tag,
3530 source_key(
3531 format!("session::write::mixed_relation_batch_tests::Bounded{index}::id")
3532 .as_str(),
3533 ),
3534 ),
3535 FieldId::new(1),
3536 );
3537 field_bindings.insert(
3538 (
3539 tag,
3540 source_key(
3541 format!(
3542 "session::write::mixed_relation_batch_tests::Bounded{index}::updated_at"
3543 )
3544 .as_str(),
3545 ),
3546 ),
3547 FieldId::new(2),
3548 );
3549 }
3550 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3551 STORE_PATH,
3552 AcceptedSchemaRevision::INITIAL,
3553 snapshots,
3554 field_bindings,
3555 );
3556 let store = session
3557 .db
3558 .store_handle(STORE_PATH)
3559 .expect("mixed relation store should resolve");
3560 crate::db::commit::publish_accepted_schema_candidate(
3561 STORE_PATH,
3562 store,
3563 AcceptedSchemaRevision::NONE,
3564 &candidate,
3565 )
3566 .expect("mixed relation candidate should publish");
3567 let cross_candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3568 CROSS_STORE_PATH,
3569 AcceptedSchemaRevision::INITIAL,
3570 BTreeMap::from([(CROSS_ENTITY_TAG, cross_store_snapshot())]),
3571 BTreeMap::from([(
3572 (CROSS_ENTITY_TAG, source_key(CROSS_ID_SOURCE)),
3573 FieldId::new(1),
3574 )]),
3575 );
3576 let cross_store = session
3577 .db
3578 .store_handle(CROSS_STORE_PATH)
3579 .expect("cross-store fixture should resolve");
3580 crate::db::commit::publish_accepted_schema_candidate(
3581 CROSS_STORE_PATH,
3582 cross_store,
3583 AcceptedSchemaRevision::NONE,
3584 &cross_candidate,
3585 )
3586 .expect("cross-store candidate should publish");
3587 session
3588 }
3589
3590 fn patch(id: Option<u64>, parent: Option<u64>, code: Option<u64>) -> DynamicStructuralPatch {
3591 let mut fields = Vec::new();
3592 if let Some(id) = id {
3593 fields.push((
3594 "id".to_string(),
3595 DynamicWriteCell::Value(InputValue::nat64(id)),
3596 ));
3597 }
3598 fields.push((
3599 "parent_id".to_string(),
3600 parent.map_or(DynamicWriteCell::Null, |parent| {
3601 DynamicWriteCell::Value(InputValue::nat64(parent))
3602 }),
3603 ));
3604 if let Some(code) = code {
3605 fields.push((
3606 "code".to_string(),
3607 DynamicWriteCell::Value(InputValue::nat64(code)),
3608 ));
3609 }
3610 DynamicStructuralPatch::new(fields)
3611 }
3612
3613 fn insert(id: u64, parent: Option<u64>) -> DynamicMutation {
3614 insert_with_code(id, parent, id)
3615 }
3616
3617 fn insert_with_code(id: u64, parent: Option<u64>, code: u64) -> DynamicMutation {
3618 DynamicMutation::Insert {
3619 entity: ENTITY_NAME.to_string(),
3620 patch: patch(Some(id), parent, Some(code)),
3621 }
3622 }
3623
3624 fn update_parent(id: u64, parent: Option<u64>) -> DynamicMutation {
3625 DynamicMutation::Update {
3626 entity: ENTITY_NAME.to_string(),
3627 key: InputValue::nat64(id),
3628 patch: patch(None, parent, None),
3629 }
3630 }
3631
3632 fn update_code(id: u64, code: u64) -> DynamicMutation {
3633 DynamicMutation::Update {
3634 entity: ENTITY_NAME.to_string(),
3635 key: InputValue::nat64(id),
3636 patch: DynamicStructuralPatch::new(vec![(
3637 "code".to_string(),
3638 DynamicWriteCell::Value(InputValue::nat64(code)),
3639 )]),
3640 }
3641 }
3642
3643 fn delete(id: u64) -> DynamicMutation {
3644 DynamicMutation::Delete {
3645 entity: ENTITY_NAME.to_string(),
3646 key: InputValue::nat64(id),
3647 }
3648 }
3649
3650 fn expected_row(id: u64, parent: Option<u64>) -> Vec<OutputValue> {
3651 expected_row_with_code(id, parent, id)
3652 }
3653
3654 fn expected_row_with_code(id: u64, parent: Option<u64>, code: u64) -> Vec<OutputValue> {
3655 vec![
3656 OutputValue::nat64(id),
3657 parent.map_or_else(OutputValue::null, OutputValue::nat64),
3658 OutputValue::nat64(code),
3659 ]
3660 }
3661
3662 fn other_patch(id: Option<u64>, value: u64) -> DynamicStructuralPatch {
3663 other_patch_with_node(id, value, None)
3664 }
3665
3666 fn other_patch_with_node(
3667 id: Option<u64>,
3668 value: u64,
3669 node_id: Option<u64>,
3670 ) -> DynamicStructuralPatch {
3671 let mut fields = Vec::new();
3672 if let Some(id) = id {
3673 fields.push((
3674 "id".to_string(),
3675 DynamicWriteCell::Value(InputValue::nat64(id)),
3676 ));
3677 }
3678 fields.push((
3679 "value".to_string(),
3680 DynamicWriteCell::Value(InputValue::nat64(value)),
3681 ));
3682 fields.push((
3683 "node_id".to_string(),
3684 node_id.map_or(DynamicWriteCell::Null, |node_id| {
3685 DynamicWriteCell::Value(InputValue::nat64(node_id))
3686 }),
3687 ));
3688 DynamicStructuralPatch::new(fields)
3689 }
3690
3691 fn assert_relation_violation(error: &crate::error::InternalError) {
3692 assert!(error.diagnostic_facts().contains(&(
3693 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
3694 icydb_diagnostic_code::DiagnosticConstraintKind::Relation.raw(),
3695 )));
3696 }
3697
3698 #[test]
3699 fn live_pages_resume_mixed_projection_from_authenticated_hidden_order_values() {
3700 let session = initialize();
3701 session
3702 .execute_trusted_dynamic_mutation_batch(vec![
3703 insert_with_code(1, None, 10),
3704 insert_with_code(2, Some(1), 20),
3705 insert_with_code(3, None, 30),
3706 ])
3707 .expect("live-page rows should insert");
3708 let query = DynamicQuery::new(ENTITY_NAME)
3709 .select(["id"])
3710 .order_by(desc("code"));
3711
3712 let first = session
3713 .execute_public_live_page(&query, None)
3714 .expect("initial live page should execute");
3715 assert_eq!(
3716 first.rows,
3717 vec![vec![OutputValue::nat64(3)], vec![OutputValue::nat64(2)]]
3718 );
3719 let cursor = first
3720 .continuation
3721 .as_deref()
3722 .expect("unreturned matching row should produce continuation");
3723 let second = session
3724 .execute_public_live_page(&query, Some(cursor))
3725 .expect("authenticated live continuation should resume");
3726 assert_eq!(second.rows, vec![vec![OutputValue::nat64(1)]]);
3727 assert_eq!(second.continuation, None);
3728
3729 let total_limit = session
3730 .execute_public_live_page(&query.clone().limit(2), None)
3731 .expect("total live-page limit should execute");
3732 assert_eq!(
3733 total_limit.rows,
3734 vec![vec![OutputValue::nat64(3)], vec![OutputValue::nat64(2)]],
3735 );
3736 assert_eq!(
3737 total_limit.continuation, None,
3738 "query LIMIT is a total traversal window rather than a page size",
3739 );
3740
3741 let three_row_window = query.clone().limit(3);
3742 let limited_first = session
3743 .execute_public_live_page(&three_row_window, None)
3744 .expect("first total-window page should execute");
3745 let limited_cursor = limited_first
3746 .continuation
3747 .as_deref()
3748 .expect("a partially consumed total window should continue");
3749 let limited_second = session
3750 .execute_public_live_page(&three_row_window, Some(limited_cursor))
3751 .expect("remaining total window should preserve the plan signature");
3752 assert_eq!(limited_second.rows, vec![vec![OutputValue::nat64(1)]]);
3753 assert_eq!(limited_second.continuation, None);
3754
3755 let mixed_order = DynamicQuery::new(ENTITY_NAME)
3756 .select(["id"])
3757 .order_by(desc("parent_id"))
3758 .order_by(asc("id"));
3759 let mixed_first = session
3760 .execute_trusted_live_page(&mixed_order, None)
3761 .expect("mixed-direction nullable order should execute");
3762 assert_eq!(
3763 mixed_first.rows,
3764 vec![vec![OutputValue::nat64(2)], vec![OutputValue::nat64(1)]],
3765 );
3766 let mixed_cursor = mixed_first
3767 .continuation
3768 .as_deref()
3769 .expect("duplicate null order values should retain continuation");
3770 let mixed_second = session
3771 .execute_trusted_live_page(&mixed_order, Some(mixed_cursor))
3772 .expect("mixed-direction nullable order should resume");
3773 assert_eq!(mixed_second.rows, vec![vec![OutputValue::nat64(3)]]);
3774 assert_eq!(mixed_second.continuation, None);
3775
3776 let mismatched_window = session
3777 .execute_public_live_page(&query.clone().limit(3), Some(cursor))
3778 .expect_err("a changed total limit must invalidate the continuation");
3779 assert_eq!(
3780 mismatched_window.diagnostic_code(),
3781 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
3782 );
3783
3784 let mut tampered = cursor.as_bytes().to_vec();
3785 let last = tampered.len().saturating_sub(1);
3786 tampered[last] = if tampered[last] == b'0' { b'1' } else { b'0' };
3787 let tampered = String::from_utf8(tampered).expect("Base64 cursor should remain UTF-8");
3788 let error = session
3789 .execute_public_live_page(&query, Some(tampered.as_str()))
3790 .expect_err("tampered cursor must fail closed");
3791 assert_eq!(
3792 error.diagnostic_code(),
3793 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
3794 );
3795 }
3796
3797 #[test]
3798 fn live_pages_resume_across_changed_output_work_envelopes() {
3799 let session = initialize();
3800 session
3801 .execute_trusted_dynamic_mutation_batch(vec![
3802 insert(1, None),
3803 insert(2, None),
3804 insert(3, None),
3805 ])
3806 .expect("output-envelope rows should insert");
3807 let query = DynamicQuery::new(ENTITY_NAME)
3808 .select(["id"])
3809 .order_by(desc("code"));
3810 let first = session
3811 .execute_trusted_live_page_with_result_bytes_limit_for_tests(&query, None, 32)
3812 .expect("small output envelope should publish the first bounded page");
3813 assert_eq!(first.rows, vec![vec![OutputValue::nat64(3)]]);
3814 let continuation = first
3815 .continuation
3816 .expect("small output envelope should leave authenticated progress");
3817
3818 let second = session
3819 .execute_trusted_live_page_with_result_bytes_limit_for_tests(
3820 &query,
3821 Some(continuation.as_str()),
3822 64,
3823 )
3824 .unwrap_or_else(|error| {
3825 panic!(
3826 "larger output envelope should resume the same query: {error:?}, facts={:?}",
3827 error.diagnostic_facts(),
3828 )
3829 });
3830 assert_eq!(
3831 second.rows,
3832 vec![vec![OutputValue::nat64(2)], vec![OutputValue::nat64(1)]]
3833 );
3834 let second_continuation = second
3835 .continuation
3836 .as_deref()
3837 .expect("an exact-full page still needs to prove physical exhaustion");
3838 assert_ne!(first.work.envelope_identity, second.work.envelope_identity);
3839
3840 let terminal = session
3841 .execute_trusted_live_page_with_result_bytes_limit_for_tests(
3842 &query,
3843 Some(second_continuation),
3844 48,
3845 )
3846 .expect("a third finite envelope should prove exhaustion without replaying rows");
3847 assert!(terminal.rows.is_empty());
3848 assert_eq!(terminal.continuation, None);
3849 assert_ne!(
3850 second.work.envelope_identity,
3851 terminal.work.envelope_identity
3852 );
3853
3854 assert_eq!(
3855 [first.rows, second.rows, terminal.rows].concat(),
3856 vec![
3857 vec![OutputValue::nat64(3)],
3858 vec![OutputValue::nat64(2)],
3859 vec![OutputValue::nat64(1)],
3860 ]
3861 );
3862 }
3863
3864 #[test]
3865 fn distinct_live_pages_resume_adjacent_groups_and_global_replay_end_to_end() {
3866 let session = initialize();
3867 session
3868 .execute_trusted_dynamic_mutation_batch(vec![
3869 insert(1, None),
3870 insert(2, None),
3871 insert(3, Some(1)),
3872 insert(4, Some(2)),
3873 insert(5, Some(1)),
3874 insert(6, Some(3)),
3875 insert(7, Some(2)),
3876 ])
3877 .expect("DISTINCT continuation rows should insert atomically");
3878
3879 let adjacent = DynamicQuery::new(ENTITY_NAME)
3880 .select(["parent_id"])
3881 .order_by(asc("parent_id"))
3882 .order_by(asc("id"))
3883 .distinct_for_internal_execution();
3884 let global = DynamicQuery::new(ENTITY_NAME)
3885 .select(["parent_id"])
3886 .order_by(asc("id"))
3887 .distinct_for_internal_execution();
3888
3889 let traverse = |query: &DynamicQuery, strategy: &str| {
3890 let mut continuation = None;
3891 let mut rows = Vec::new();
3892 let mut cursors = std::collections::BTreeSet::new();
3893 let mut pages = 0_u32;
3894 let mut entries_visited = 0_u64;
3895 loop {
3896 let page = session
3897 .execute_trusted_live_page(query, continuation.as_deref())
3898 .unwrap_or_else(|error| {
3899 panic!("{strategy} DISTINCT page should execute: {error:?}")
3900 });
3901 pages = pages.saturating_add(1);
3902 entries_visited = entries_visited.saturating_add(page.work.entries_visited);
3903 assert_eq!(page.row_count as usize, page.rows.len());
3904 assert_eq!(page.work.result_rows, page.row_count);
3905 rows.extend(page.rows);
3906 let Some(cursor) = page.continuation else {
3907 break;
3908 };
3909 assert!(
3910 cursors.insert(cursor.clone()),
3911 "{strategy} DISTINCT continuation must advance monotonically",
3912 );
3913 continuation = Some(cursor);
3914 assert!(pages < 8, "{strategy} DISTINCT traversal must terminate");
3915 }
3916
3917 (rows, pages, entries_visited)
3918 };
3919
3920 let expected = vec![
3921 vec![OutputValue::null()],
3922 vec![OutputValue::nat64(1)],
3923 vec![OutputValue::nat64(2)],
3924 vec![OutputValue::nat64(3)],
3925 ];
3926 let (adjacent_rows, adjacent_pages, adjacent_entries) = traverse(&adjacent, "adjacent");
3927 let (global_rows, global_pages, global_entries) = traverse(&global, "global");
3928
3929 assert_eq!(adjacent_rows, expected);
3930 assert_eq!(global_rows, expected);
3931 assert_eq!(adjacent_pages, 2);
3932 assert_eq!(global_pages, 2);
3933 assert!(adjacent_entries > 0);
3934 assert!(global_entries > 0);
3935 }
3936
3937 #[test]
3938 fn selective_live_pages_publish_monotonic_empty_physical_progress() {
3939 let session = initialize();
3940 session
3941 .execute_trusted_dynamic_mutation_batch(
3942 (1..=9)
3943 .map(|id| {
3944 let parent = match id {
3945 1 => Some(2),
3946 9 => Some(1),
3947 _ => None,
3948 };
3949 insert(id, parent)
3950 })
3951 .collect(),
3952 )
3953 .expect("selective live-page rows should insert");
3954 let query = DynamicQuery::new(ENTITY_NAME)
3955 .select(["id"])
3956 .filter(FilterExpr::eq("parent_id", 1_u64))
3957 .order_by(asc("id"))
3958 .limit(1);
3959
3960 let first = session
3961 .execute_trusted_live_page(&query, None)
3962 .expect("first selective page should stop with physical progress");
3963 assert!(first.rows.is_empty());
3964 assert_eq!(first.work.entries_visited, 4);
3965 let first_cursor = first
3966 .continuation
3967 .expect("filtered physical progress must return a continuation");
3968
3969 let second = session
3970 .execute_trusted_live_page(&query, Some(first_cursor.as_str()))
3971 .expect("second selective page should resume after the first physical frontier");
3972 assert!(second.rows.is_empty());
3973 assert_eq!(second.work.entries_visited, 4);
3974 let second_cursor = second
3975 .continuation
3976 .expect("second filtered frontier must remain resumable");
3977 assert_ne!(second_cursor, first_cursor);
3978
3979 let third = session
3980 .execute_trusted_live_page(&query, Some(second_cursor.as_str()))
3981 .expect("final selective page should return the late match");
3982 assert_eq!(third.rows, vec![vec![OutputValue::nat64(9)]]);
3983 assert_eq!(third.work.entries_visited, 1);
3984 assert_eq!(third.continuation, None);
3985
3986 let descending = DynamicQuery::new(ENTITY_NAME)
3987 .select(["id"])
3988 .filter(FilterExpr::eq("parent_id", 2_u64))
3989 .order_by(desc("id"))
3990 .limit(1);
3991 let descending_first = session
3992 .execute_trusted_live_page(&descending, None)
3993 .expect("descending selective page should stop with physical progress");
3994 assert!(descending_first.rows.is_empty());
3995 let descending_first_cursor = descending_first
3996 .continuation
3997 .expect("descending filtered progress must return a continuation");
3998 let descending_second = session
3999 .execute_trusted_live_page(&descending, Some(descending_first_cursor.as_str()))
4000 .expect("descending progress should resume after its physical frontier");
4001 assert!(descending_second.rows.is_empty());
4002 let descending_second_cursor = descending_second
4003 .continuation
4004 .expect("descending second frontier must remain resumable");
4005 assert_ne!(descending_second_cursor, descending_first_cursor);
4006 let descending_third = session
4007 .execute_trusted_live_page(&descending, Some(descending_second_cursor.as_str()))
4008 .expect("descending final page should return the late match");
4009 assert_eq!(descending_third.rows, vec![vec![OutputValue::nat64(1)]]);
4010 assert_eq!(descending_third.continuation, None);
4011 }
4012
4013 #[test]
4014 fn accepted_relation_edges_drive_catalog_and_describe_introspection() {
4015 let session = initialize();
4016 let entities = session
4017 .show_entities()
4018 .expect("accepted entity catalog should resolve");
4019 let source = entities
4020 .iter()
4021 .find(|entity| entity.entity_name() == ENTITY_NAME)
4022 .expect("relation source should be listed");
4023 assert_eq!(source.relations(), 1);
4024
4025 let description = session
4026 .try_describe_entity_by_name(ENTITY_NAME)
4027 .expect("accepted relation source should describe");
4028 let [relation] = description.relations() else {
4029 panic!("accepted relation edge should produce one relation row");
4030 };
4031 assert_eq!(relation.field(), "parent_id");
4032 assert_eq!(relation.target_path(), ENTITY_SOURCE);
4033 assert_eq!(relation.target_entity_name(), ENTITY_NAME);
4034 assert_eq!(relation.target_store_path(), STORE_PATH);
4035 assert_eq!(
4036 relation.cardinality(),
4037 crate::db::EntityRelationCardinality::Single,
4038 );
4039 }
4040
4041 #[test]
4042 fn mixed_relation_validation_uses_the_complete_final_row_overlay() {
4043 let session = initialize();
4044 session
4045 .execute_trusted_dynamic_mutation_batch(vec![insert(1, None), insert(2, Some(1))])
4046 .expect("the initial relation should commit");
4047
4048 let blocked = session
4049 .execute_trusted_dynamic_mutation(&delete(1))
4050 .expect_err("an unaffected committed source must block target deletion");
4051 assert_relation_violation(&blocked);
4052
4053 let deleted = session
4054 .execute_trusted_dynamic_mutation_batch(vec![delete(2), delete(1)])
4055 .expect("a source and its target should delete atomically");
4056 assert_eq!(
4057 batch_rows(&deleted),
4058 vec![expected_row(2, Some(1)), expected_row(1, None)],
4059 );
4060
4061 session
4062 .execute_trusted_dynamic_mutation_batch(vec![insert(3, None), insert(4, Some(3))])
4063 .expect("the update-away fixture should commit");
4064 let updated_away = session
4065 .execute_trusted_dynamic_mutation_batch(vec![update_parent(4, None), delete(3)])
4066 .expect("an updated final source may release a deleted target");
4067 assert_eq!(
4068 batch_rows(&updated_away),
4069 vec![expected_row(4, None), expected_row(3, None)],
4070 );
4071
4072 session
4073 .execute_trusted_dynamic_mutation_batch(vec![insert(5, None), insert(6, Some(5))])
4074 .expect("the retained-reference fixture should commit");
4075 let retained = session
4076 .execute_trusted_dynamic_mutation_batch(vec![update_parent(6, Some(5)), delete(5)])
4077 .expect_err("a final updated source must still block target deletion");
4078 assert_relation_violation(&retained);
4079
4080 session
4081 .execute_trusted_dynamic_mutation(&insert(7, None))
4082 .expect("the inserted-reference fixture target should commit");
4083 let inserted_reference = session
4084 .execute_trusted_dynamic_mutation_batch(vec![insert(8, Some(7)), delete(7)])
4085 .expect_err("a final inserted source must not reference a deleted target");
4086 assert_relation_violation(&inserted_reference);
4087
4088 let inserted_target = session
4089 .execute_trusted_dynamic_mutation_batch(vec![insert(10, Some(9)), insert(9, None)])
4090 .expect("an inserted relation should see its batch-final target");
4091 assert_eq!(
4092 batch_rows(&inserted_target),
4093 vec![expected_row(10, Some(9)), expected_row(9, None)],
4094 );
4095
4096 session
4097 .execute_trusted_dynamic_mutation(&insert(11, None))
4098 .expect("the updated-reference fixture source should commit");
4099 let updated_target = session
4100 .execute_trusted_dynamic_mutation_batch(vec![
4101 update_parent(11, Some(12)),
4102 insert(12, None),
4103 ])
4104 .expect("an updated relation should see its batch-final target");
4105 assert_eq!(
4106 batch_rows(&updated_target),
4107 vec![expected_row(11, Some(12)), expected_row(12, None)],
4108 );
4109 }
4110
4111 #[test]
4112 fn mixed_batch_commits_cross_entity_then_rejects_late_failures_atomically() {
4113 let session = initialize();
4114 session
4115 .execute_trusted_dynamic_mutation(&insert(1, None))
4116 .expect("the primary mixed fixture row should commit");
4117 session
4118 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4119 entity: OTHER_ENTITY_NAME.to_string(),
4120 patch: other_patch(Some(1), 10),
4121 })
4122 .expect("the secondary mixed fixture row should commit");
4123
4124 let mixed_entity = session
4125 .execute_trusted_dynamic_mutation_batch(vec![
4126 update_code(1, 11),
4127 DynamicMutation::Update {
4128 entity: OTHER_ENTITY_NAME.to_string(),
4129 key: InputValue::nat64(1),
4130 patch: other_patch(None, 11),
4131 },
4132 ])
4133 .expect("one atomic batch may span accepted entities in the same store");
4134 assert_eq!(
4135 batch_rows(&mixed_entity),
4136 vec![
4137 expected_row_with_code(1, None, 11),
4138 vec![
4139 OutputValue::nat64(1),
4140 OutputValue::nat64(11),
4141 OutputValue::null(),
4142 ],
4143 ],
4144 );
4145
4146 let missing = session
4147 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 12), delete(99)])
4148 .expect_err("a late missing delete must reject the earlier staged update");
4149 assert_eq!(missing.class(), ErrorClass::NotFound);
4150
4151 session
4152 .execute_trusted_dynamic_mutation(&insert(2, None))
4153 .expect("the collision fixture should commit");
4154 let collision = session
4155 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 13), insert(2, None)])
4156 .expect_err("an insert collision must reject the earlier staged update");
4157 assert_eq!(collision.class(), ErrorClass::Conflict);
4158 let failures_unchanged = session
4159 .execute_trusted_dynamic_mutation(&update_code(1, 11))
4160 .expect("failed batches must preserve the original unique value");
4161 assert_eq!(failures_unchanged.affected_rows, 0);
4162
4163 let replaced = session
4164 .execute_trusted_dynamic_mutation_batch(vec![
4165 update_code(1, 14),
4166 DynamicMutation::Replace {
4167 entity: ENTITY_NAME.to_string(),
4168 key: InputValue::nat64(99),
4169 patch: patch(None, None, Some(99)),
4170 },
4171 ])
4172 .expect("ordinary caller-key replace should insert its absent final row");
4173 assert_eq!(
4174 batch_rows(&replaced),
4175 vec![
4176 expected_row_with_code(1, None, 14),
4177 expected_row_with_code(99, None, 99),
4178 ],
4179 );
4180
4181 let unchanged = session
4182 .execute_trusted_dynamic_mutation(&update_code(1, 14))
4183 .expect("the successful mixed replace must publish its preceding update");
4184 assert_eq!(unchanged.affected_rows, 0);
4185 let other_unchanged = session
4186 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
4187 entity: OTHER_ENTITY_NAME.to_string(),
4188 key: InputValue::nat64(1),
4189 patch: other_patch(None, 11),
4190 })
4191 .expect("the cross-entity commit must publish the secondary row");
4192 assert_eq!(other_unchanged.affected_rows, 0);
4193 }
4194
4195 #[test]
4196 fn structural_unknown_root_and_dotted_subpath_reject_before_commit() {
4197 let session = initialize();
4198 session
4199 .execute_trusted_dynamic_mutation_batch(vec![insert(1, None), insert(2, None)])
4200 .expect("structural rejection fixtures should commit");
4201
4202 let unknown_root = session
4203 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
4204 entity: ENTITY_NAME.to_string(),
4205 key: InputValue::nat64(1),
4206 patch: DynamicStructuralPatch::new(vec![(
4207 "missing".to_string(),
4208 DynamicWriteCell::Value(InputValue::nat64(10)),
4209 )]),
4210 })
4211 .expect_err("an unknown structural root field must reject");
4212 assert_eq!(unknown_root.class(), ErrorClass::Unsupported);
4213 assert_eq!(unknown_root.origin(), ErrorOrigin::Executor);
4214 assert_eq!(
4215 unknown_root.diagnostic_code(),
4216 icydb_diagnostic_code::DiagnosticCode::RuntimeUnsupported,
4217 );
4218 assert!(unknown_root.diagnostic_facts().is_empty());
4219
4220 let dotted_subpath = session
4221 .execute_trusted_dynamic_mutation_batch(vec![
4222 update_code(1, 11),
4223 DynamicMutation::Update {
4224 entity: ENTITY_NAME.to_string(),
4225 key: InputValue::nat64(2),
4226 patch: DynamicStructuralPatch::new(vec![(
4227 "code.value".to_string(),
4228 DynamicWriteCell::Value(InputValue::nat64(12)),
4229 )]),
4230 },
4231 ])
4232 .expect_err("a dotted structural subpath must reject the complete batch");
4233 assert_eq!(dotted_subpath.class(), ErrorClass::Unsupported);
4234 assert_eq!(dotted_subpath.origin(), ErrorOrigin::Executor);
4235 assert_eq!(
4236 dotted_subpath.diagnostic_code(),
4237 icydb_diagnostic_code::DiagnosticCode::RuntimeUnsupported,
4238 );
4239 assert!(dotted_subpath.diagnostic_facts().is_empty());
4240
4241 let unchanged = session
4242 .execute_trusted_dynamic_mutation(&update_code(1, 1))
4243 .expect("the rejected batch must preserve the earlier row");
4244 assert_eq!(unchanged.affected_rows, 0);
4245
4246 let whole_field = session
4247 .execute_trusted_dynamic_mutation(&update_code(2, 12))
4248 .expect("a complete root-field update must remain supported");
4249 assert_eq!(whole_field.affected_rows, 1);
4250 assert_eq!(whole_field.rows, vec![expected_row_with_code(2, None, 12)]);
4251 }
4252
4253 #[test]
4254 fn cross_entity_relations_observe_one_complete_final_overlay() {
4255 let session = initialize();
4256 let inserted = session
4257 .execute_trusted_dynamic_mutation_batch(vec![
4258 DynamicMutation::Insert {
4259 entity: OTHER_ENTITY_NAME.to_string(),
4260 patch: other_patch_with_node(Some(20), 200, Some(42)),
4261 },
4262 insert(42, None),
4263 ])
4264 .expect("a source may precede its same-batch target in another entity");
4265 assert_eq!(inserted.len(), 2);
4266
4267 session
4268 .execute_trusted_dynamic_mutation_batch(vec![
4269 delete(42),
4270 DynamicMutation::Delete {
4271 entity: OTHER_ENTITY_NAME.to_string(),
4272 key: InputValue::nat64(20),
4273 },
4274 ])
4275 .expect("a target and cross-entity source may delete in either request order");
4276
4277 session
4278 .execute_trusted_dynamic_mutation_batch(vec![
4279 insert(43, None),
4280 DynamicMutation::Insert {
4281 entity: OTHER_ENTITY_NAME.to_string(),
4282 patch: other_patch_with_node(Some(21), 210, Some(43)),
4283 },
4284 ])
4285 .expect("the retained cross-entity relation fixture should commit");
4286 let blocked = session
4287 .execute_trusted_dynamic_mutation_batch(vec![delete(43)])
4288 .expect_err("a retained source in another entity must protect its target");
4289 assert_relation_violation(&blocked);
4290 }
4291
4292 #[test]
4293 fn mixed_batch_admits_64_entities_with_one_timestamp_and_rejects_the_65th() {
4294 let session = initialize();
4295 let requests = (0..64)
4296 .map(|index| DynamicMutation::Insert {
4297 entity: format!("MixedBounded{index}"),
4298 patch: DynamicStructuralPatch::new(vec![(
4299 "id".to_string(),
4300 DynamicWriteCell::Value(InputValue::nat64(1)),
4301 )]),
4302 })
4303 .collect();
4304 let admitted = session
4305 .execute_trusted_dynamic_mutation_batch(requests)
4306 .expect("exactly 64 same-store entities should admit");
4307 assert_eq!(admitted.len(), 64);
4308 let timestamps = admitted
4309 .iter()
4310 .map(|result| {
4311 result
4312 .rows
4313 .first()
4314 .and_then(|row| row.get(1))
4315 .expect("every bounded entity should return its managed timestamp")
4316 })
4317 .collect::<Vec<_>>();
4318 assert!(timestamps.windows(2).all(|pair| pair[0] == pair[1]));
4319
4320 let over_limit = (0..65)
4321 .map(|index| DynamicMutation::Insert {
4322 entity: format!("MixedBounded{index}"),
4323 patch: DynamicStructuralPatch::new(vec![(
4324 "id".to_string(),
4325 DynamicWriteCell::Value(InputValue::nat64(2)),
4326 )]),
4327 })
4328 .collect();
4329 let error = session
4330 .execute_trusted_dynamic_mutation_batch(over_limit)
4331 .expect_err("the 65th distinct entity must reject before staging");
4332 assert_eq!(error.class(), ErrorClass::Unsupported);
4333 assert_eq!(
4334 error.diagnostic_facts(),
4335 vec![
4336 (icydb_diagnostic_code::DiagnosticFactTag::ActualCount, 65),
4337 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 64),
4338 ],
4339 );
4340 }
4341
4342 #[test]
4343 fn mixed_batch_rejects_a_cross_store_item_with_bounded_tags() {
4344 let session = initialize();
4345 let error = session
4346 .execute_trusted_dynamic_mutation_batch(vec![
4347 insert(70, None),
4348 DynamicMutation::Insert {
4349 entity: CROSS_ENTITY_NAME.to_string(),
4350 patch: DynamicStructuralPatch::new(vec![(
4351 "id".to_string(),
4352 DynamicWriteCell::Value(InputValue::nat64(70)),
4353 )]),
4354 },
4355 ])
4356 .expect_err("a structural batch must remain inside one accepted store");
4357 assert_eq!(error.class(), ErrorClass::Conflict);
4358 assert_eq!(
4359 error.diagnostic_facts(),
4360 vec![
4361 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1),
4362 (
4363 icydb_diagnostic_code::DiagnosticFactTag::ExpectedEntityTag,
4364 ENTITY_TAG.value(),
4365 ),
4366 (
4367 icydb_diagnostic_code::DiagnosticFactTag::ActualEntityTag,
4368 CROSS_ENTITY_TAG.value(),
4369 ),
4370 ],
4371 );
4372 session
4373 .execute_trusted_dynamic_mutation(&insert(70, None))
4374 .expect("cross-store rejection must publish no first-item effect");
4375 }
4376
4377 #[test]
4378 fn mixed_batch_unique_swap_and_delete_release_use_the_final_overlay() {
4379 let session = initialize();
4380 session
4381 .execute_trusted_dynamic_mutation_batch(vec![
4382 insert_with_code(1, None, 10),
4383 insert_with_code(2, None, 20),
4384 ])
4385 .expect("the unique-overlay fixture should commit");
4386
4387 let swapped = session
4388 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 20), update_code(2, 10)])
4389 .expect("two final rows should atomically swap unique memberships");
4390 assert_eq!(
4391 batch_rows(&swapped),
4392 vec![
4393 expected_row_with_code(1, None, 20),
4394 expected_row_with_code(2, None, 10),
4395 ],
4396 );
4397
4398 let released = session
4399 .execute_trusted_dynamic_mutation_batch(vec![delete(1), insert_with_code(3, None, 20)])
4400 .expect("a delete should release unique membership to a final inserted row");
4401 assert_eq!(
4402 batch_rows(&released),
4403 vec![
4404 expected_row_with_code(1, None, 20),
4405 expected_row_with_code(3, None, 20),
4406 ],
4407 );
4408 }
4409}
4410
4411#[cfg(test)]
4412mod identity_pre_key_tests {
4413 #[cfg(feature = "sql")]
4414 mod grouped_count_tests;
4415 mod nested_relation_tests;
4416 mod replay_construction_tests;
4417 mod result_boundary_tests;
4418
4419 use super::DynamicTypedEntityBinding;
4420 use super::{
4421 AcceptedMutationIntentPatch, AcceptedRowLayoutRuntimeContract, AcceptedStructuralMutation,
4422 AcceptedStructuralMutationPacking, AcceptedStructuralMutationStagedAdmission,
4423 AcceptedStructuralMutationTarget, DbSession, DynamicMutation, DynamicStructuralPatch,
4424 DynamicTypedMutation, DynamicWriteCell, FieldSlot,
4425 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS, MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES,
4426 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES, MutationProgressRecordOp,
4427 TypedEntityDescriptor, TypedFieldType, add_structural_mutation_staged_bytes,
4428 admit_structural_mutation_staged_charge, checked_pre_key_candidate_count,
4429 insert_key_exists_after_generation, structural_mutation_staged_charge,
4430 validate_structural_mutation_result_bytes,
4431 };
4432 #[cfg(feature = "sql")]
4433 use crate::db::data::DecodedDataStoreKey;
4434 #[cfg(feature = "sql")]
4435 use crate::db::executor::budget::{
4436 HardExecutionBudget, HardExecutionContext, HardExecutionFailureHeadroom,
4437 with_execution_budget_for_tests, with_query_execution_budget_for_tests,
4438 };
4439 use crate::db::mutation_job::{MutationJobRecord, MutationJobTransition};
4440 #[cfg(feature = "sql")]
4441 use crate::db::{
4442 CompareProofAndAdvanceError, ExhaustiveReadError, MutationJobError,
4443 MutationJobRestartReason, PrimaryKeyComponent, PrimaryKeyValue, RawDataStoreKey,
4444 ReadSetRevisionError, ResumableJobAdvance, ResumableJobAdvanceRequest,
4445 ResumableJobAdvanceStatus, ResumableJobError, ResumableJobId, ResumableJobIdempotencyKey,
4446 ResumableJobStatus, asc,
4447 };
4448 use crate::db::{DynamicQuery, QueryExecutionError};
4449 use crate::{
4450 db::{
4451 GeneratedStartupDriverStep, MutationJobAdvanceRequest, MutationJobId,
4452 MutationJobIdempotencyKey, MutationJobPhase, MutationJobStatus, TypedFieldDescriptor,
4453 commit::{
4454 database_incarnation_id, forget_recovered_domain_for_tests,
4455 install_startup_recovery_wakeup,
4456 },
4457 data::DataStore,
4458 drive_generated_startup_recovery_page,
4459 executor::{MutationCommitInterruption, interrupt_next_mutation_commit_for_tests},
4460 index::{IndexId, IndexKey, IndexKeyKind, IndexStore, IndexStoreVisit},
4461 integrity::{
4462 InsertMutationJobResult, PhysicalUnitCheckpoint, QuickIntegrityStatus,
4463 RowInspectionLimits, execute_quick_integrity, execute_row_integrity_page,
4464 with_mutation_progress_store,
4465 },
4466 journal::{
4467 JournalBatch, JournalRecord, JournalSequence, JournalTailControl, JournalTailStore,
4468 encode_journal_batch,
4469 },
4470 registry::{
4471 StoreAllocationIdentities, StoreAllocationIdentity, StoreHandle, StoreRegistry,
4472 StoreRuntimeStorageCapabilities,
4473 },
4474 schema::{
4475 AcceptedConstraintCatalog, AcceptedFieldKind, AcceptedSchemaRevision, FieldId,
4476 FieldInsertGeneration, FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
4477 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
4478 PersistedRelationEdgeSnapshot, PersistedSchemaSnapshot, RelationId, ScalarCodec,
4479 SchemaFieldSlot, SchemaFieldWritePolicy, SchemaIndexId, SchemaInsertDefault,
4480 SchemaRowLayout, SchemaStore, SchemaVersion,
4481 accepted_schema_candidate_with_field_bindings_for_tests,
4482 cardinality_build::{
4483 CardinalityBuildAuthority, CardinalityGenerationPageOutcome,
4484 drive_cardinality_generation_page,
4485 },
4486 cardinality_generation::{CardinalityGenerationHeader, CardinalityGenerationState},
4487 },
4488 write_context::MutationMode,
4489 },
4490 error::{ErrorClass, ErrorOrigin, InternalError},
4491 testing::test_memory,
4492 traits::{CanisterKind, Path},
4493 types::{EntityTag, Timestamp},
4494 value::{InputValue, OutputValue, Value},
4495 };
4496 use icydb_schema::{FieldSourceKey, ScalarType};
4497 use std::{
4498 cell::{Cell, RefCell},
4499 collections::BTreeMap,
4500 };
4501
4502 const STORE_PATH: &str = "session::write::identity_pre_key_tests::Store";
4503 const ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::Entity";
4504 const ID_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::id";
4505 const PAYLOAD_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::payload";
4506 const ENTITY_NAME: &str = "IdentityRow";
4507 const ENTITY_TAG: EntityTag = EntityTag::new(93);
4508 const TYPED_DESCRIPTOR: TypedEntityDescriptor = TypedEntityDescriptor::new(
4509 ENTITY_SOURCE,
4510 &[ID_SOURCE],
4511 &[
4512 TypedFieldDescriptor::new(ID_SOURCE, TypedFieldType::Scalar(ScalarType::Nat64), false),
4513 TypedFieldDescriptor::new(
4514 PAYLOAD_SOURCE,
4515 TypedFieldType::Scalar(ScalarType::Nat64),
4516 false,
4517 ),
4518 ],
4519 );
4520 const SECOND_ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::SecondEntity";
4521 const SECOND_ID_SOURCE: &str = "session::write::identity_pre_key_tests::SecondEntity::id";
4522 const SECOND_PAYLOAD_SOURCE: &str =
4523 "session::write::identity_pre_key_tests::SecondEntity::payload";
4524 const SECOND_TARGET_SOURCE: &str =
4525 "session::write::identity_pre_key_tests::SecondEntity::target_id";
4526 const SECOND_ENTITY_NAME: &str = "SecondIdentityRow";
4527 const SECOND_ENTITY_TAG: EntityTag = EntityTag::new(96);
4528 const THIRD_ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::ThirdEntity";
4529 const THIRD_ID_SOURCE: &str = "session::write::identity_pre_key_tests::ThirdEntity::id";
4530 const THIRD_PAYLOAD_SOURCE: &str =
4531 "session::write::identity_pre_key_tests::ThirdEntity::payload";
4532 const THIRD_ENTITY_NAME: &str = "ThirdIdentityRow";
4533 const THIRD_ENTITY_TAG: EntityTag = EntityTag::new(97);
4534 const JOURNALED_STORE_PATH: &str = "session::write::identity_pre_key_tests::JournaledStore";
4535 const UNRELATED_STORE_PATH: &str = "session::write::identity_pre_key_tests::UnrelatedStore";
4536
4537 fn batch_rows(results: &[crate::db::DynamicMutationResult]) -> Vec<Vec<OutputValue>> {
4538 results
4539 .iter()
4540 .flat_map(|result| result.rows.iter().cloned())
4541 .collect()
4542 }
4543
4544 struct TestCanister;
4545
4546 impl Path for TestCanister {
4547 const PATH: &'static str = "session::write::identity_pre_key_tests::Canister";
4548 }
4549
4550 impl CanisterKind for TestCanister {
4551 const COMMIT_MEMORY_ID: u8 = 45;
4552 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.commit.v1";
4553 const STARTUP_MEMORY_ID: u8 = 49;
4554 const STARTUP_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.startup.control.v1";
4555 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 46;
4556 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
4557 "icydb.identity_pre_key_tests.integrity.progress.v1";
4558 }
4559
4560 thread_local! {
4561 static STARTUP_WAKEUPS: Cell<u32> = const { Cell::new(0) };
4562 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
4563 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
4564 static SCHEMA_STORE: RefCell<SchemaStore> =
4565 const { RefCell::new(SchemaStore::init_heap()) };
4566 static UNRELATED_DATA_STORE: RefCell<DataStore> =
4567 const { RefCell::new(DataStore::init_heap()) };
4568 static UNRELATED_INDEX_STORE: RefCell<IndexStore> =
4569 const { RefCell::new(IndexStore::init_heap()) };
4570 static UNRELATED_SCHEMA_STORE: RefCell<SchemaStore> =
4571 const { RefCell::new(SchemaStore::init_heap()) };
4572 static STORE_REGISTRY: StoreRegistry = {
4573 let mut registry = StoreRegistry::new();
4574 registry.register_store(
4575 STORE_PATH,
4576 &DATA_STORE,
4577 &INDEX_STORE,
4578 &SCHEMA_STORE,
4579 StoreAllocationIdentities::absent(),
4580 StoreRuntimeStorageCapabilities::heap(),
4581 ).expect("identity pre-key test store should register");
4582 registry.register_store(
4583 UNRELATED_STORE_PATH,
4584 &UNRELATED_DATA_STORE,
4585 &UNRELATED_INDEX_STORE,
4586 &UNRELATED_SCHEMA_STORE,
4587 StoreAllocationIdentities::absent(),
4588 StoreRuntimeStorageCapabilities::heap(),
4589 ).expect("unrelated identity test store should register");
4590 registry
4591 };
4592 static JOURNALED_DATA_STORE: RefCell<DataStore> =
4593 RefCell::new(DataStore::init_journaled(test_memory(186)));
4594 static JOURNALED_INDEX_STORE: RefCell<IndexStore> =
4595 RefCell::new(IndexStore::init_journaled(test_memory(187)));
4596 static JOURNALED_SCHEMA_STORE: RefCell<SchemaStore> =
4597 RefCell::new(SchemaStore::init_journaled(test_memory(188)));
4598 static JOURNALED_TAIL_STORE: RefCell<JournalTailStore> =
4599 RefCell::new(JournalTailStore::init(test_memory(189)));
4600 static JOURNALED_STORE_REGISTRY: StoreRegistry = {
4601 let mut registry = StoreRegistry::new();
4602 registry.register_journaled_store(
4603 JOURNALED_STORE_PATH,
4604 &JOURNALED_DATA_STORE,
4605 &JOURNALED_INDEX_STORE,
4606 &JOURNALED_SCHEMA_STORE,
4607 &JOURNALED_TAIL_STORE,
4608 StoreAllocationIdentities::new_journaled(
4609 StoreAllocationIdentity::new(186, "icydb.test.identity_range.data.v1"),
4610 StoreAllocationIdentity::new(187, "icydb.test.identity_range.index.v1"),
4611 StoreAllocationIdentity::new(188, "icydb.test.identity_range.schema.v1"),
4612 StoreAllocationIdentity::new(189, "icydb.test.identity_range.journal.v1"),
4613 ),
4614 StoreRuntimeStorageCapabilities::journaled(),
4615 ).expect("identity range journaled store should register");
4616 registry
4617 };
4618 }
4619
4620 fn record_startup_wakeup() {
4621 STARTUP_WAKEUPS.with(|wakeups| wakeups.set(wakeups.get().saturating_add(1)));
4622 }
4623
4624 struct JournaledTestCanister;
4625
4626 impl Path for JournaledTestCanister {
4627 const PATH: &'static str = "session::write::identity_pre_key_tests::JournaledCanister";
4628 }
4629
4630 impl CanisterKind for JournaledTestCanister {
4631 const COMMIT_MEMORY_ID: u8 = 190;
4632 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_range_tests.commit.v1";
4633 const STARTUP_MEMORY_ID: u8 = 192;
4634 const STARTUP_STABLE_KEY: &'static str = "icydb.identity_range_tests.startup.control.v1";
4635 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 191;
4636 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
4637 "icydb.identity_range_tests.integrity.progress.v1";
4638 }
4639
4640 fn source_key(source: &str) -> FieldSourceKey {
4641 FieldSourceKey::try_new(source).expect("identity test field source should admit")
4642 }
4643
4644 fn identity_snapshot(store_path: &str, payload_unique: bool) -> PersistedSchemaSnapshot {
4645 identity_snapshot_for_entity(
4646 store_path,
4647 payload_unique,
4648 false,
4649 false,
4650 ENTITY_SOURCE,
4651 ENTITY_NAME,
4652 None,
4653 )
4654 }
4655
4656 fn identity_snapshot_with_nullable_payload(store_path: &str) -> PersistedSchemaSnapshot {
4657 identity_snapshot_for_entity(
4658 store_path,
4659 false,
4660 false,
4661 true,
4662 ENTITY_SOURCE,
4663 ENTITY_NAME,
4664 None,
4665 )
4666 }
4667
4668 fn identity_snapshot_with_payload_index(
4669 store_path: &str,
4670 payload_unique: bool,
4671 composite: bool,
4672 ) -> PersistedSchemaSnapshot {
4673 identity_snapshot_for_entity(
4674 store_path,
4675 payload_unique,
4676 composite,
4677 false,
4678 ENTITY_SOURCE,
4679 ENTITY_NAME,
4680 None,
4681 )
4682 }
4683
4684 fn identity_snapshot_for_entity(
4685 store_path: &str,
4686 payload_unique: bool,
4687 composite: bool,
4688 payload_nullable: bool,
4689 entity_source: &str,
4690 entity_name: &str,
4691 relation_target: Option<&str>,
4692 ) -> PersistedSchemaSnapshot {
4693 let mut fields = vec![
4694 PersistedFieldSnapshot::new_initial_with_write_policy(
4695 FieldId::new(1),
4696 "id".to_string(),
4697 SchemaFieldSlot::new(0),
4698 AcceptedFieldKind::Nat64,
4699 Vec::new(),
4700 false,
4701 SchemaInsertDefault::None,
4702 SchemaFieldWritePolicy::from_model_policies(
4703 Some(FieldInsertGeneration::Identity),
4704 None,
4705 ),
4706 FieldStorageDecode::ByKind,
4707 LeafCodec::Scalar(ScalarCodec::Nat64),
4708 ),
4709 PersistedFieldSnapshot::new_initial(
4710 FieldId::new(2),
4711 "payload".to_string(),
4712 SchemaFieldSlot::new(1),
4713 AcceptedFieldKind::Nat64,
4714 Vec::new(),
4715 payload_nullable,
4716 SchemaInsertDefault::None,
4717 FieldStorageDecode::ByKind,
4718 LeafCodec::Scalar(ScalarCodec::Nat64),
4719 ),
4720 ];
4721 if relation_target.is_some() {
4722 fields.push(PersistedFieldSnapshot::new_initial(
4723 FieldId::new(3),
4724 "target_id".to_string(),
4725 SchemaFieldSlot::new(2),
4726 AcceptedFieldKind::Nat64,
4727 Vec::new(),
4728 true,
4729 SchemaInsertDefault::None,
4730 FieldStorageDecode::ByKind,
4731 LeafCodec::Scalar(ScalarCodec::Nat64),
4732 ));
4733 }
4734 let mut index_fields = vec![PersistedIndexFieldPathSnapshot::new(
4735 FieldId::new(2),
4736 SchemaFieldSlot::new(1),
4737 vec!["payload".to_string()],
4738 AcceptedFieldKind::Nat64,
4739 payload_nullable,
4740 )];
4741 if composite {
4742 index_fields.push(PersistedIndexFieldPathSnapshot::new(
4743 FieldId::new(1),
4744 SchemaFieldSlot::new(0),
4745 vec!["id".to_string()],
4746 AcceptedFieldKind::Nat64,
4747 false,
4748 ));
4749 }
4750 let snapshot = PersistedSchemaSnapshot::new_with_indexes(
4751 SchemaVersion::initial(),
4752 entity_source.to_string(),
4753 entity_name.to_string(),
4754 FieldId::new(1),
4755 SchemaRowLayout::initial(
4756 fields
4757 .iter()
4758 .map(|field| (field.id(), field.slot()))
4759 .collect(),
4760 ),
4761 fields,
4762 vec![PersistedIndexSnapshot::new(
4763 SchemaIndexId::new(1).expect("identity test index ID should admit"),
4764 1,
4765 if composite {
4766 "by_payload_id".to_string()
4767 } else {
4768 "by_payload".to_string()
4769 },
4770 store_path.to_string(),
4771 payload_unique,
4772 PersistedIndexKeySnapshot::FieldPath(index_fields),
4773 None,
4774 )],
4775 );
4776 let Some(relation_target) = relation_target else {
4777 return snapshot;
4778 };
4779 let snapshot = snapshot.with_relations(vec![PersistedRelationEdgeSnapshot::new_direct(
4780 RelationId::new(1).expect("mixed recovery relation identity should be non-zero"),
4781 "target".to_string(),
4782 relation_target.to_string(),
4783 vec![FieldId::new(3)],
4784 )]);
4785 let constraints = AcceptedConstraintCatalog::initial(
4786 snapshot.fields(),
4787 snapshot.indexes(),
4788 snapshot.relations(),
4789 )
4790 .expect("mixed recovery relation constraints should close");
4791 snapshot.with_constraint_catalog(constraints)
4792 }
4793
4794 fn initialize() -> DbSession<TestCanister> {
4795 initialize_with_snapshot(identity_snapshot(STORE_PATH, false))
4796 }
4797
4798 fn initialize_with_composite_payload_index() -> DbSession<TestCanister> {
4799 initialize_with_snapshot(identity_snapshot_with_payload_index(
4800 STORE_PATH, false, true,
4801 ))
4802 }
4803
4804 fn initialize_with_snapshot(snapshot: PersistedSchemaSnapshot) -> DbSession<TestCanister> {
4805 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
4806 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
4807 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
4808 UNRELATED_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
4809 UNRELATED_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
4810 UNRELATED_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
4811 let session = DbSession::<TestCanister>::new(
4812 &STORE_REGISTRY,
4813 &crate::db::RequestExecutionRoot::__new_runtime_root(),
4814 );
4815 session
4816 .db
4817 .drive_startup_recovery_page()
4818 .expect("identity pre-key test database should initialize");
4819 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
4820 STORE_PATH,
4821 AcceptedSchemaRevision::INITIAL,
4822 BTreeMap::from([(ENTITY_TAG, snapshot)]),
4823 BTreeMap::from([
4824 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
4825 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
4826 ]),
4827 );
4828 let store = session
4829 .db
4830 .store_handle(STORE_PATH)
4831 .expect("identity pre-key test store should resolve");
4832 crate::db::commit::publish_accepted_schema_candidate(
4833 STORE_PATH,
4834 store,
4835 AcceptedSchemaRevision::NONE,
4836 &candidate,
4837 )
4838 .expect("identity candidate should publish with explicit zero state");
4839 session
4840 }
4841
4842 fn initialize_journaled_with_root_and_payload_uniqueness(
4843 payload_unique: bool,
4844 ) -> (
4845 DbSession<JournaledTestCanister>,
4846 crate::db::RequestExecutionRoot,
4847 ) {
4848 let root = crate::db::RequestExecutionRoot::__new_runtime_root();
4849 let session = DbSession::<JournaledTestCanister>::new(&JOURNALED_STORE_REGISTRY, &root);
4850 session
4851 .db
4852 .drive_startup_recovery_page()
4853 .expect("journaled identity database should initialize");
4854 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
4855 JOURNALED_STORE_PATH,
4856 AcceptedSchemaRevision::INITIAL,
4857 BTreeMap::from([(
4858 ENTITY_TAG,
4859 identity_snapshot(JOURNALED_STORE_PATH, payload_unique),
4860 )]),
4861 BTreeMap::from([
4862 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
4863 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
4864 ]),
4865 );
4866 let store = session
4867 .db
4868 .store_handle(JOURNALED_STORE_PATH)
4869 .expect("journaled identity store should resolve");
4870 crate::db::commit::publish_accepted_schema_candidate(
4871 JOURNALED_STORE_PATH,
4872 store,
4873 AcceptedSchemaRevision::NONE,
4874 &candidate,
4875 )
4876 .expect("journaled identity candidate should publish");
4877 (session, root)
4878 }
4879
4880 fn initialize_journaled_with_root() -> (
4881 DbSession<JournaledTestCanister>,
4882 crate::db::RequestExecutionRoot,
4883 ) {
4884 initialize_journaled_with_root_and_payload_uniqueness(false)
4885 }
4886
4887 fn initialize_journaled_multi_entity() -> DbSession<JournaledTestCanister> {
4888 let root = crate::db::RequestExecutionRoot::__new_runtime_root();
4889 let session = DbSession::<JournaledTestCanister>::new(&JOURNALED_STORE_REGISTRY, &root);
4890 session
4891 .db
4892 .drive_startup_recovery_page()
4893 .expect("multi-entity journaled database should initialize");
4894 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
4895 JOURNALED_STORE_PATH,
4896 AcceptedSchemaRevision::INITIAL,
4897 BTreeMap::from([
4898 (ENTITY_TAG, identity_snapshot(JOURNALED_STORE_PATH, false)),
4899 (
4900 SECOND_ENTITY_TAG,
4901 identity_snapshot_for_entity(
4902 JOURNALED_STORE_PATH,
4903 false,
4904 false,
4905 false,
4906 SECOND_ENTITY_SOURCE,
4907 SECOND_ENTITY_NAME,
4908 Some(ENTITY_SOURCE),
4909 ),
4910 ),
4911 (
4912 THIRD_ENTITY_TAG,
4913 identity_snapshot_for_entity(
4914 JOURNALED_STORE_PATH,
4915 false,
4916 false,
4917 false,
4918 THIRD_ENTITY_SOURCE,
4919 THIRD_ENTITY_NAME,
4920 None,
4921 ),
4922 ),
4923 ]),
4924 BTreeMap::from([
4925 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
4926 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
4927 (
4928 (SECOND_ENTITY_TAG, source_key(SECOND_ID_SOURCE)),
4929 FieldId::new(1),
4930 ),
4931 (
4932 (SECOND_ENTITY_TAG, source_key(SECOND_PAYLOAD_SOURCE)),
4933 FieldId::new(2),
4934 ),
4935 (
4936 (SECOND_ENTITY_TAG, source_key(SECOND_TARGET_SOURCE)),
4937 FieldId::new(3),
4938 ),
4939 (
4940 (THIRD_ENTITY_TAG, source_key(THIRD_ID_SOURCE)),
4941 FieldId::new(1),
4942 ),
4943 (
4944 (THIRD_ENTITY_TAG, source_key(THIRD_PAYLOAD_SOURCE)),
4945 FieldId::new(2),
4946 ),
4947 ]),
4948 );
4949 let store = session
4950 .db
4951 .store_handle(JOURNALED_STORE_PATH)
4952 .expect("multi-entity journaled store should resolve");
4953 crate::db::commit::publish_accepted_schema_candidate(
4954 JOURNALED_STORE_PATH,
4955 store,
4956 AcceptedSchemaRevision::NONE,
4957 &candidate,
4958 )
4959 .expect("multi-entity journaled candidate should publish");
4960 session
4961 }
4962
4963 fn initialize_journaled() -> DbSession<JournaledTestCanister> {
4964 initialize_journaled_with_root().0
4965 }
4966
4967 fn initialize_journaled_with_unique_payload() -> DbSession<JournaledTestCanister> {
4968 initialize_journaled_with_root_and_payload_uniqueness(true).0
4969 }
4970
4971 fn drive_journaled_recovery_to_completion(session: &DbSession<JournaledTestCanister>) {
4972 for _ in 0..8 {
4973 if session
4974 .db
4975 .drive_startup_recovery_page()
4976 .expect("dedicated driver recovery should remain valid")
4977 {
4978 return;
4979 }
4980 }
4981 panic!("dedicated driver recovery should quiesce within eight complete batches");
4982 }
4983
4984 fn drive_journaled_cardinality_to_ready(session: &DbSession<JournaledTestCanister>) {
4985 let handle = session
4986 .db
4987 .store_handle(JOURNALED_STORE_PATH)
4988 .expect("journaled cardinality store should resolve");
4989 for _ in 0..8 {
4990 let outcome = handle
4991 .with_data(|data| {
4992 handle.with_index(|index| {
4993 handle.with_schema_mut(|schema| {
4994 drive_cardinality_generation_page(data, index, schema, |schema| {
4995 let watermark = JOURNALED_TAIL_STORE
4996 .with(|tail| tail.borrow().fold_watermark())?;
4997 CardinalityBuildAuthority::derive(
4998 schema,
4999 database_incarnation_id()?,
5000 handle.allocation_identities(),
5001 watermark,
5002 )
5003 })
5004 })
5005 })
5006 })
5007 .expect("bounded cardinality generation should advance");
5008 if outcome == CardinalityGenerationPageOutcome::Quiescent {
5009 return;
5010 }
5011 }
5012 panic!("cardinality generation should become Ready within eight bounded pages");
5013 }
5014
5015 fn journaled_user_index_prefix() -> (IndexId, Vec<Vec<u8>>) {
5016 JOURNALED_INDEX_STORE.with(|store| {
5017 let mut selected = None;
5018 store
5019 .borrow()
5020 .visit_entries(|raw_key, _value| {
5021 let key = IndexKey::try_from_raw(raw_key)
5022 .expect("accepted user index key should decode");
5023 if key.key_kind() != IndexKeyKind::User {
5024 return Ok::<_, InternalError>(IndexStoreVisit::Continue);
5025 }
5026 let components = (0..key.component_count())
5027 .map(|index| {
5028 key.component(index)
5029 .expect("accepted index component should exist")
5030 .to_vec()
5031 })
5032 .collect::<Vec<_>>();
5033 selected = Some((*key.index_id(), components));
5034 Ok(IndexStoreVisit::Stop)
5035 })
5036 .expect("accepted user index should be inspectable");
5037 selected.expect("the cardinality fixture should contain one user index entry")
5038 })
5039 }
5040
5041 fn reset_journaled_cardinality_projections() -> u64 {
5042 JOURNALED_DATA_STORE.with(|store| {
5043 store
5044 .borrow_mut()
5045 .reset_journaled_live_projection()
5046 .expect("row projection should reset without a count scan");
5047 });
5048 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
5049 let fold_watermark = JOURNALED_TAIL_STORE
5050 .with(|store| store.borrow().fold_watermark())
5051 .expect("journal watermark should remain current-form");
5052 JOURNALED_INDEX_STORE.with(|store| {
5053 store
5054 .borrow_mut()
5055 .reset_journaled_live_projection(data_generation, fold_watermark)
5056 .expect("index projection should reset without a count scan");
5057 });
5058 data_generation
5059 }
5060
5061 fn assert_journaled_cardinality(
5062 handle: StoreHandle,
5063 index_id: IndexId,
5064 prefix_components: &[Vec<u8>],
5065 expected: u64,
5066 ) {
5067 assert_eq!(handle.exact_entity_count(ENTITY_TAG), Some(expected));
5068 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
5069 assert_eq!(
5070 handle.exact_user_index_prefix_count(
5071 data_generation,
5072 IndexKeyKind::User,
5073 index_id,
5074 prefix_components,
5075 ),
5076 Some(expected),
5077 );
5078 }
5079
5080 fn mark_journaled_cardinality_building() {
5081 let current = JOURNALED_SCHEMA_STORE.with(|store| {
5082 store
5083 .borrow()
5084 .cardinality_generation_header()
5085 .expect("Ready header should decode")
5086 .expect("Ready header should exist")
5087 });
5088 JOURNALED_SCHEMA_STORE.with(|store| {
5089 store
5090 .borrow_mut()
5091 .write_cardinality_generation_header(CardinalityGenerationHeader::new(
5092 current.generation(),
5093 CardinalityGenerationState::Building,
5094 current.slot(),
5095 current.source(),
5096 ))
5097 .expect("Building fallback fixture should persist");
5098 });
5099 }
5100
5101 fn payload_patch(value: u64) -> AcceptedMutationIntentPatch {
5102 AcceptedMutationIntentPatch::new()
5103 .set_authored(FieldSlot::from_validated_index(1), InputValue::nat64(value))
5104 }
5105
5106 fn dynamic_payload_patch(value: u64) -> DynamicStructuralPatch {
5107 DynamicStructuralPatch::new(vec![(
5108 "payload".to_string(),
5109 DynamicWriteCell::Value(InputValue::nat64(value)),
5110 )])
5111 }
5112
5113 fn related_dynamic_payload_patch(value: u64, target_id: u64) -> DynamicStructuralPatch {
5114 DynamicStructuralPatch::new(vec![
5115 (
5116 "payload".to_string(),
5117 DynamicWriteCell::Value(InputValue::nat64(value)),
5118 ),
5119 (
5120 "target_id".to_string(),
5121 DynamicWriteCell::Value(InputValue::nat64(target_id)),
5122 ),
5123 ])
5124 }
5125
5126 fn expected_dynamic_row(id: u64, payload: u64) -> Vec<OutputValue> {
5127 vec![OutputValue::nat64(id), OutputValue::nat64(payload)]
5128 }
5129
5130 fn exact_key_binding<C: CanisterKind>(session: &DbSession<C>) -> DynamicTypedEntityBinding {
5131 session
5132 .issue_typed_entity_binding(&TYPED_DESCRIPTOR)
5133 .expect("exact-key test binding should issue")
5134 }
5135
5136 fn typed_payload_insert(
5137 binding: &DynamicTypedEntityBinding,
5138 payload: u64,
5139 ) -> DynamicTypedMutation {
5140 let patch = binding
5141 .bind_write_ordinals(vec![(
5142 1,
5143 DynamicWriteCell::Value(InputValue::nat64(payload)),
5144 )])
5145 .expect("typed payload patch should bind");
5146 DynamicTypedMutation::Insert { patch }
5147 }
5148
5149 fn typed_payload_delete(id: u64) -> DynamicTypedMutation {
5150 DynamicTypedMutation::Delete {
5151 key: InputValue::nat64(id),
5152 }
5153 }
5154
5155 fn insert_exact_key_fixture<C: CanisterKind>(session: &DbSession<C>, payload: u64) -> u64 {
5156 let output = session
5157 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5158 entity: ENTITY_NAME.to_string(),
5159 patch: dynamic_payload_patch(payload),
5160 })
5161 .expect("exact-key fixture insert should commit");
5162 match output.rows.as_slice() {
5163 [row] => match row.as_slice() {
5164 [id, actual_payload] if matches!(actual_payload.as_public(), crate::value::PublicValue::Nat64(value) if *value == payload) =>
5165 {
5166 let crate::value::PublicValue::Nat64(id) = id.as_public() else {
5167 panic!("exact-key fixture should return a natural identity");
5168 };
5169 *id
5170 }
5171 _ => panic!("exact-key fixture should return its identity and payload"),
5172 },
5173 _ => panic!("exact-key fixture insert should return one row"),
5174 }
5175 }
5176
5177 #[cfg(feature = "sql")]
5178 fn sql_projection_rows(session: &DbSession<TestCanister>, sql: &str) -> Vec<Vec<OutputValue>> {
5179 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5180 .execute_trusted_sql_query(sql)
5181 .expect("focused SQL projection should execute")
5182 else {
5183 panic!("focused SQL projection should return rows")
5184 };
5185
5186 rows
5187 }
5188
5189 #[cfg(feature = "sql")]
5190 #[test]
5191 fn secondary_ordered_covering_limit_stops_at_the_present_row_window() {
5192 let session = initialize_with_composite_payload_index();
5193 for payload in [30, 10, 20, 20, 40] {
5194 insert_exact_key_fixture(&session, payload);
5195 }
5196
5197 assert_eq!(
5198 sql_projection_rows(
5199 &session,
5200 "SELECT payload FROM IdentityRow ORDER BY payload ASC, id ASC LIMIT 1",
5201 ),
5202 vec![vec![OutputValue::nat64(10)]],
5203 );
5204 #[cfg(feature = "sql")]
5205 assert_sql_query_fits_resource_limit(
5206 &session,
5207 "SELECT payload FROM IdentityRow ORDER BY payload ASC, id ASC LIMIT 1",
5208 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
5209 1,
5210 );
5211
5212 assert_eq!(
5213 sql_projection_rows(
5214 &session,
5215 "SELECT payload FROM IdentityRow ORDER BY payload DESC, id DESC LIMIT 1",
5216 ),
5217 vec![vec![OutputValue::nat64(40)]],
5218 );
5219 #[cfg(feature = "sql")]
5220 assert_sql_query_fits_resource_limit(
5221 &session,
5222 "SELECT payload FROM IdentityRow ORDER BY payload DESC, id DESC LIMIT 1",
5223 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
5224 1,
5225 );
5226
5227 assert_eq!(
5228 sql_projection_rows(
5229 &session,
5230 "SELECT id, payload FROM IdentityRow \
5231 ORDER BY payload ASC, id ASC LIMIT 2 OFFSET 1",
5232 ),
5233 vec![
5234 vec![OutputValue::nat64(3), OutputValue::nat64(20)],
5235 vec![OutputValue::nat64(4), OutputValue::nat64(20)],
5236 ],
5237 );
5238 #[cfg(feature = "sql")]
5239 assert_sql_query_fits_resource_limit(
5240 &session,
5241 "SELECT id, payload FROM IdentityRow \
5242 ORDER BY payload ASC, id ASC LIMIT 2 OFFSET 1",
5243 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
5244 3,
5245 );
5246
5247 assert_eq!(
5248 sql_projection_rows(
5249 &session,
5250 "SELECT payload FROM IdentityRow ORDER BY payload ASC, id ASC",
5251 ),
5252 [10, 20, 20, 30, 40]
5253 .into_iter()
5254 .map(|payload| vec![OutputValue::nat64(payload)])
5255 .collect::<Vec<_>>(),
5256 );
5257 }
5258
5259 #[cfg(feature = "sql")]
5260 #[test]
5261 fn secondary_ordered_covering_limit_fails_on_an_accessed_missing_row() {
5262 let session = initialize_with_composite_payload_index();
5263 let first = insert_exact_key_fixture(&session, 10);
5264 insert_exact_key_fixture(&session, 20);
5265 let raw_key =
5266 DecodedDataStoreKey::try_from_structural_key(ENTITY_TAG, &Value::Nat64(first))
5267 .expect("missing-row fixture key should decode")
5268 .to_raw()
5269 .expect("missing-row fixture key should encode");
5270 let store = session
5271 .db
5272 .store_handle(STORE_PATH)
5273 .expect("missing-row fixture store should resolve");
5274 assert!(
5275 store.with_data_mut(|data| data.remove(&raw_key)).is_some(),
5276 "fixture must remove only the authoritative row",
5277 );
5278
5279 let error = session
5280 .execute_trusted_sql_query(
5281 "SELECT payload FROM IdentityRow ORDER BY payload ASC, id ASC LIMIT 1",
5282 )
5283 .expect_err("an accessed accepted-index row must remain fail-closed");
5284 assert!(matches!(
5285 error,
5286 crate::db::QueryError::Execute(QueryExecutionError::Corruption(_))
5287 ));
5288 }
5289
5290 #[cfg(feature = "sql")]
5291 #[test]
5292 fn secondary_indexed_max_uses_one_descending_edge_across_ties() {
5293 let session = initialize_with_composite_payload_index();
5294 let mut inserted = Vec::new();
5295 for payload in [30, 10, 20, 20, 40, 40] {
5296 inserted.push(insert_exact_key_fixture(&session, payload));
5297 }
5298
5299 let sql = "SELECT MAX(payload) FROM IdentityRow";
5300 let data_reads_before = DataStore::current_get_call_count();
5301 let index_reads_before = IndexStore::current_entry_read_count();
5302 assert_eq!(
5303 sql_projection_rows(&session, sql),
5304 vec![vec![OutputValue::nat64(40)]],
5305 );
5306 assert_eq!(DataStore::current_get_call_count() - data_reads_before, 1);
5307 assert!(IndexStore::current_entry_read_count() - index_reads_before <= 1);
5308
5309 let range_sql = "SELECT MAX(payload) FROM IdentityRow WHERE payload < 40";
5310 let data_reads_before = DataStore::current_get_call_count();
5311 let index_reads_before = IndexStore::current_entry_read_count();
5312 assert_eq!(
5313 sql_projection_rows(&session, range_sql),
5314 vec![vec![OutputValue::nat64(30)]],
5315 );
5316 assert_eq!(DataStore::current_get_call_count() - data_reads_before, 1);
5317 assert!(IndexStore::current_entry_read_count() - index_reads_before <= 1);
5318
5319 let last = inserted
5320 .last()
5321 .copied()
5322 .expect("secondary MAX fixture should retain its last identity");
5323 let raw_key = DecodedDataStoreKey::try_from_structural_key(ENTITY_TAG, &Value::Nat64(last))
5324 .expect("missing-row fixture key should decode")
5325 .to_raw()
5326 .expect("missing-row fixture key should encode");
5327 let store = session
5328 .db
5329 .store_handle(STORE_PATH)
5330 .expect("missing-row fixture store should resolve");
5331 assert!(
5332 store.with_data_mut(|data| data.remove(&raw_key)).is_some(),
5333 "fixture must remove only the descending edge row",
5334 );
5335
5336 let error = session
5337 .execute_trusted_sql_query("SELECT MAX(payload) FROM IdentityRow")
5338 .expect_err("an accessed accepted-index row must remain fail-closed");
5339 assert!(matches!(
5340 error,
5341 crate::db::QueryError::Execute(QueryExecutionError::Corruption(_))
5342 ));
5343 }
5344
5345 #[cfg(feature = "sql")]
5346 #[test]
5347 fn secondary_indexed_max_upper_range_fails_on_an_accessed_missing_row() {
5348 let session = initialize_with_composite_payload_index();
5349 let upper_range_edge = insert_exact_key_fixture(&session, 30);
5350 for payload in [10, 20, 40] {
5351 insert_exact_key_fixture(&session, payload);
5352 }
5353 let raw_key = DecodedDataStoreKey::try_from_structural_key(
5354 ENTITY_TAG,
5355 &Value::Nat64(upper_range_edge),
5356 )
5357 .expect("missing-row fixture key should decode")
5358 .to_raw()
5359 .expect("missing-row fixture key should encode");
5360 let store = session
5361 .db
5362 .store_handle(STORE_PATH)
5363 .expect("missing-row fixture store should resolve");
5364 assert!(
5365 store.with_data_mut(|data| data.remove(&raw_key)).is_some(),
5366 "fixture must remove only the upper-range edge row",
5367 );
5368
5369 let error = session
5370 .execute_trusted_sql_query("SELECT MAX(payload) FROM IdentityRow WHERE payload < 40")
5371 .expect_err("an accessed upper-range edge row must remain fail-closed");
5372 assert!(matches!(
5373 error,
5374 crate::db::QueryError::Execute(QueryExecutionError::Corruption(_))
5375 ));
5376 }
5377
5378 #[test]
5379 fn exact_counts_use_entity_and_bounded_index_metadata_without_physical_reads() {
5380 let session = initialize();
5381 for payload in [10, 10, 20] {
5382 insert_exact_key_fixture(&session, payload);
5383 }
5384 let binding = exact_key_binding(&session);
5385 let entity = DynamicQuery::new(ENTITY_NAME);
5386 let tens =
5387 DynamicQuery::new(ENTITY_NAME).filter(crate::db::FieldRef::new("payload").eq(10_u64));
5388 let selected = DynamicQuery::new(ENTITY_NAME)
5389 .filter(crate::db::FieldRef::new("payload").in_list([10_u64, 10, 20, 99]));
5390 let missing =
5391 DynamicQuery::new(ENTITY_NAME).filter(crate::db::FieldRef::new("payload").eq(99_u64));
5392 let data_reads_before = DataStore::current_get_call_count();
5393 let index_reads_before = IndexStore::current_entry_read_count();
5394
5395 assert_eq!(session.execute_public_exact_count(&entity).unwrap(), 3);
5396 assert_eq!(session.execute_public_exact_count(&tens).unwrap(), 2);
5397 assert_eq!(session.execute_public_exact_count(&selected).unwrap(), 3);
5398 assert_eq!(session.execute_public_exact_count(&missing).unwrap(), 0);
5399 assert_eq!(
5400 session
5401 .execute_public_exact_count_for_typed_binding(&binding, &tens)
5402 .unwrap(),
5403 Some(2),
5404 );
5405 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5406 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5407
5408 session
5409 .execute_trusted_dynamic_insert_batch(
5410 ENTITY_NAME,
5411 (0..64).map(|_| dynamic_payload_patch(10)).collect(),
5412 )
5413 .expect("a larger matching population should commit");
5414 let data_reads_before = DataStore::current_get_call_count();
5415 let index_reads_before = IndexStore::current_entry_read_count();
5416 assert_eq!(session.execute_public_exact_count(&tens).unwrap(), 66);
5417 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5418 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5419 }
5420
5421 #[test]
5422 fn exact_count_accepts_the_leading_field_of_a_composite_user_index() {
5423 let session = initialize_with_composite_payload_index();
5424 for payload in [10, 10, 20] {
5425 insert_exact_key_fixture(&session, payload);
5426 }
5427 let tens =
5428 DynamicQuery::new(ENTITY_NAME).filter(crate::db::FieldRef::new("payload").eq(10_u64));
5429 let selected = DynamicQuery::new(ENTITY_NAME)
5430 .filter(crate::db::FieldRef::new("payload").in_list([10_u64, 20, 99]));
5431 let data_reads_before = DataStore::current_get_call_count();
5432 let index_reads_before = IndexStore::current_entry_read_count();
5433
5434 assert_eq!(session.execute_public_exact_count(&tens).unwrap(), 2);
5435 assert_eq!(session.execute_public_exact_count(&selected).unwrap(), 3);
5436 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5437 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5438 }
5439
5440 #[cfg(feature = "sql")]
5441 #[test]
5442 fn exact_count_shared_executor_preserves_sql_direct_count_results() {
5443 let session = initialize();
5444 let data_reads_before = DataStore::current_get_call_count();
5445 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5446 .execute_trusted_sql_query("SELECT COUNT(*) FROM IdentityRow")
5447 .expect("empty SQL direct count should succeed")
5448 else {
5449 panic!("empty SQL direct count should return one projection row")
5450 };
5451 assert_eq!(rows, vec![vec![OutputValue::nat64(0)]]);
5452 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5453
5454 for payload in [10, 10, 20] {
5455 insert_exact_key_fixture(&session, payload);
5456 }
5457
5458 let data_reads_before = DataStore::current_get_call_count();
5459 let index_reads_before = IndexStore::current_entry_read_count();
5460 for sql in [
5461 "SELECT COUNT(*) FROM IdentityRow",
5462 "SELECT COUNT(payload) FROM IdentityRow",
5463 "SELECT COUNT(1) FROM IdentityRow",
5464 "SELECT COUNT(*) FROM IdentityRow WHERE true",
5465 "SELECT COUNT(*) FROM IdentityRow WHERE payload IN (10, 10, 20, 99)",
5466 ] {
5467 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5468 .execute_trusted_sql_query(sql)
5469 .expect("SQL direct count should use the shared exact executor")
5470 else {
5471 panic!("SQL direct count should return one projection row")
5472 };
5473 assert_eq!(rows, vec![vec![OutputValue::nat64(3)]], "{sql}");
5474 }
5475 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5476 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5477
5478 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5479 .execute_trusted_sql_query("SELECT COUNT(*) FROM IdentityRow WHERE payload = 10")
5480 .expect("nontrivial exact-prefix count should preserve its predicate")
5481 else {
5482 panic!("nontrivial exact-prefix count should return one projection row")
5483 };
5484 assert_eq!(rows, vec![vec![OutputValue::nat64(2)]]);
5485 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5486 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5487
5488 let data_reads_before = DataStore::current_get_call_count();
5489 for (sql, expected) in [
5490 ("SELECT COUNT(*) FROM IdentityRow WHERE false", 0_u64),
5491 ("SELECT COUNT(*) FROM IdentityRow WHERE id = 1", 1),
5492 ] {
5493 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5494 .execute_trusted_sql_query(sql)
5495 .expect("non-entity count control should succeed")
5496 else {
5497 panic!("non-entity count control should return one projection row")
5498 };
5499 assert_eq!(rows, vec![vec![OutputValue::nat64(expected)]], "{sql}");
5500 }
5501 assert!(DataStore::current_get_call_count() > data_reads_before);
5502
5503 session
5504 .execute_trusted_sql_query("SELECT COUNT(*) FROM IdentityRow LIMIT 1")
5505 .expect_err("unordered aggregate input pagination must remain rejected");
5506
5507 let data_reads_before = DataStore::current_get_call_count();
5508 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5509 .execute_trusted_sql_query("SELECT COUNT(DISTINCT payload) FROM IdentityRow")
5510 .expect("distinct count should retain prepared execution")
5511 else {
5512 panic!("distinct count should return one projection row")
5513 };
5514 assert_eq!(rows, vec![vec![OutputValue::nat64(2)]]);
5515 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5516 }
5517
5518 #[cfg(feature = "sql")]
5519 #[test]
5520 fn exact_count_composite_prefix_admits_seventeen_canonical_keys_only() {
5521 let session = initialize_with_composite_payload_index();
5522 for payload in [10, 10, 20] {
5523 insert_exact_key_fixture(&session, payload);
5524 }
5525 let ids_at_count_cap = (1_u64..=17)
5526 .map(|id| id.to_string())
5527 .collect::<Vec<_>>()
5528 .join(", ");
5529 let at_count_cap_sql = format!(
5530 "SELECT COUNT(*) FROM IdentityRow WHERE payload = 10 AND id IN ({ids_at_count_cap})",
5531 );
5532 let data_reads_before = DataStore::current_get_call_count();
5533 let index_reads_before = IndexStore::current_entry_read_count();
5534 assert_eq!(
5535 sql_projection_rows(&session, at_count_cap_sql.as_str()),
5536 vec![vec![OutputValue::nat64(2)]],
5537 );
5538 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5539 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5540
5541 let authored_duplicate_sql = format!(
5542 "SELECT COUNT(*) FROM IdentityRow WHERE payload = 10 AND id IN (1, {ids_at_count_cap})",
5543 );
5544 assert_eq!(
5545 sql_projection_rows(&session, authored_duplicate_sql.as_str()),
5546 vec![vec![OutputValue::nat64(2)]],
5547 );
5548 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5549 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5550
5551 let ids_over_count_cap = format!("{ids_at_count_cap}, 18");
5552 let over_count_cap_sql = format!(
5553 "SELECT COUNT(*) FROM IdentityRow WHERE payload = 10 AND id IN ({ids_over_count_cap})",
5554 );
5555 assert_eq!(
5556 sql_projection_rows(&session, over_count_cap_sql.as_str()),
5557 vec![vec![OutputValue::nat64(2)]],
5558 );
5559 assert!(DataStore::current_get_call_count() > data_reads_before);
5560 }
5561
5562 #[cfg(feature = "sql")]
5563 #[test]
5564 fn exact_count_nullable_field_uses_prepared_borrowed_primary_scan() {
5565 let session = initialize_with_snapshot(identity_snapshot_with_nullable_payload(STORE_PATH));
5566 session
5567 .execute_trusted_dynamic_insert_batch(
5568 ENTITY_NAME,
5569 vec![
5570 dynamic_payload_patch(10),
5571 DynamicStructuralPatch::new(Vec::new()),
5572 ],
5573 )
5574 .expect("nullable count fixture should insert");
5575
5576 let data_reads_before = DataStore::current_get_call_count();
5577 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5578 .execute_trusted_sql_query("SELECT COUNT(payload) FROM IdentityRow")
5579 .expect("nullable count should retain prepared execution")
5580 else {
5581 panic!("nullable count should return one projection row")
5582 };
5583 assert_eq!(rows, vec![vec![OutputValue::nat64(1)]]);
5584 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5585 }
5586
5587 #[cfg(feature = "sql")]
5588 #[test]
5589 fn indexed_extrema_nullable_field_uses_prepared_borrowed_primary_scan() {
5590 let session = initialize_with_snapshot(identity_snapshot_with_nullable_payload(STORE_PATH));
5591 session
5592 .execute_trusted_dynamic_insert_batch(
5593 ENTITY_NAME,
5594 vec![DynamicStructuralPatch::new(Vec::new())],
5595 )
5596 .expect("all-null extrema fixture should insert");
5597
5598 for sql in [
5599 "SELECT MIN(payload) FROM IdentityRow",
5600 "SELECT MAX(payload) FROM IdentityRow",
5601 ] {
5602 let data_reads_before = DataStore::current_get_call_count();
5603 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5604 .execute_trusted_sql_query(sql)
5605 .expect("all-null extrema should retain complete reduction")
5606 else {
5607 panic!("all-null extrema should return one projection row")
5608 };
5609 assert_eq!(rows, vec![vec![OutputValue::null()]], "{sql}");
5610 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5611 }
5612
5613 session
5614 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(10)])
5615 .expect("mixed nullable extrema fixture should insert");
5616
5617 for sql in [
5618 "SELECT MIN(payload) FROM IdentityRow",
5619 "SELECT MAX(payload) FROM IdentityRow",
5620 ] {
5621 let data_reads_before = DataStore::current_get_call_count();
5622 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5623 .execute_trusted_sql_query(sql)
5624 .expect("mixed nullable extrema should retain complete reduction")
5625 else {
5626 panic!("mixed nullable extrema should return one projection row")
5627 };
5628 assert_eq!(rows, vec![vec![OutputValue::nat64(10)]], "{sql}");
5629 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5630 }
5631 }
5632
5633 #[test]
5634 fn exact_count_rejects_non_metadata_shapes_and_unready_cardinality() {
5635 let session = initialize();
5636 insert_exact_key_fixture(&session, 10);
5637 let rejected = [
5638 DynamicQuery::new(ENTITY_NAME).limit(1),
5639 DynamicQuery::new(ENTITY_NAME).select(["payload"]),
5640 DynamicQuery::new(ENTITY_NAME).order_by(crate::db::asc("payload")),
5641 DynamicQuery::new(ENTITY_NAME).filter(crate::db::FieldRef::new("id").eq(1_u64)),
5642 DynamicQuery::new(ENTITY_NAME).filter(crate::db::FilterExpr::and(vec![
5643 crate::db::FieldRef::new("payload").eq(10_u64),
5644 crate::db::FieldRef::new("id").eq(1_u64),
5645 ])),
5646 DynamicQuery::new(ENTITY_NAME)
5647 .filter(crate::db::FieldRef::new("payload").in_list(0_u64..=16)),
5648 ];
5649 for request in rejected {
5650 assert!(matches!(
5651 session.execute_public_exact_count(&request),
5652 Err(crate::db::QueryError::Execute(
5653 QueryExecutionError::Unsupported(_)
5654 )),
5655 ));
5656 }
5657
5658 let journaled = initialize_journaled();
5659 insert_exact_key_fixture(&journaled, 10);
5660 assert!(matches!(
5661 journaled.execute_public_exact_count(&DynamicQuery::new(ENTITY_NAME)),
5662 Err(crate::db::QueryError::Execute(
5663 QueryExecutionError::Unsupported(_)
5664 )),
5665 ));
5666 }
5667
5668 #[test]
5669 fn exact_count_typed_binding_fails_closed_after_accepted_revision_changes() {
5670 let session = initialize();
5671 let binding = exact_key_binding(&session);
5672 let request = DynamicQuery::new(ENTITY_NAME);
5673 assert_eq!(
5674 session
5675 .execute_public_exact_count_for_typed_binding(&binding, &request)
5676 .unwrap(),
5677 Some(0),
5678 );
5679
5680 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
5681 STORE_PATH,
5682 AcceptedSchemaRevision::new(2),
5683 BTreeMap::from([(ENTITY_TAG, identity_snapshot(STORE_PATH, false))]),
5684 BTreeMap::from([
5685 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
5686 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
5687 ]),
5688 );
5689 let store = session
5690 .db
5691 .store_handle(STORE_PATH)
5692 .expect("exact-count store should resolve");
5693 crate::db::commit::publish_accepted_schema_candidate(
5694 STORE_PATH,
5695 store,
5696 AcceptedSchemaRevision::INITIAL,
5697 &candidate,
5698 )
5699 .expect("successor accepted schema should publish");
5700
5701 assert_eq!(
5702 session
5703 .execute_public_exact_count_for_typed_binding(&binding, &request)
5704 .unwrap(),
5705 None,
5706 );
5707 }
5708
5709 #[cfg(feature = "sql")]
5710 fn identity_row_stored_bytes<C: CanisterKind>(
5711 session: &DbSession<C>,
5712 store_path: &'static str,
5713 key: u64,
5714 ) -> u64 {
5715 let data_key = DecodedDataStoreKey::try_from_structural_key(ENTITY_TAG, &Value::Nat64(key))
5716 .expect("identity row key should encode");
5717 let raw_key = data_key.to_raw().expect("identity raw key should encode");
5718 let store = session
5719 .db
5720 .recovered_store(store_path)
5721 .expect("identity store should resolve");
5722 store.with_data(|data_store| {
5723 u64::try_from(
5724 data_store
5725 .get(&raw_key)
5726 .expect("inserted identity row should exist")
5727 .len(),
5728 )
5729 .expect("bounded row length should fit u64")
5730 })
5731 }
5732
5733 #[cfg(feature = "sql")]
5734 fn with_stored_bytes_limit<T>(
5735 limit: u64,
5736 shape_fingerprint_prefix: u64,
5737 operation: impl FnOnce() -> Result<T, crate::db::query::intent::QueryError>,
5738 ) -> Result<T, crate::db::query::intent::QueryError> {
5739 let budget = HardExecutionBudget::uniform_for_tests(
5740 u64::MAX,
5741 HardExecutionFailureHeadroom::new(500, 256),
5742 )
5743 .with_limit_for_tests(
5744 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::StoredBytesRead,
5745 limit,
5746 );
5747 let context = HardExecutionContext::new(
5748 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
5749 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
5750 shape_fingerprint_prefix,
5751 );
5752
5753 with_query_execution_budget_for_tests(budget, context, operation)
5754 }
5755
5756 #[cfg(feature = "sql")]
5757 fn advance_with_exhausted_mutation_predicate_budget(
5758 session: &DbSession<JournaledTestCanister>,
5759 request: &MutationJobAdvanceRequest,
5760 ) -> Result<crate::db::MutationJobAdvanceReceipt, MutationJobError> {
5761 let budget = HardExecutionBudget::uniform_for_tests(
5762 u64::MAX,
5763 HardExecutionFailureHeadroom::new(1_000_000_000, 64 * 1_024),
5764 )
5765 .with_limit_for_tests(
5766 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::PredicateExpressionSteps,
5767 0,
5768 );
5769 let context = HardExecutionContext::new(
5770 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
5771 icydb_diagnostic_code::DiagnosticExecutionLane::Mutation,
5772 0x6d75_7461_7465_7465,
5773 );
5774 with_execution_budget_for_tests(
5775 budget,
5776 context,
5777 || session.advance_trusted_mutation_job(request),
5778 |_| MutationJobError::Internal,
5779 )
5780 }
5781
5782 #[cfg(feature = "sql")]
5783 const fn exact_key(value: u64) -> PrimaryKeyValue {
5784 PrimaryKeyValue::Scalar(PrimaryKeyComponent::Nat64(value))
5785 }
5786
5787 #[cfg(feature = "sql")]
5788 fn assert_exact_key_batch<C: CanisterKind>(session: &DbSession<C>) {
5789 let first = insert_exact_key_fixture(session, 41);
5790 let second = insert_exact_key_fixture(session, 42);
5791 let missing = u64::MAX;
5792 let binding = exact_key_binding(session);
5793 let gets_before = DataStore::current_get_call_count();
5794 let result = session
5795 .execute_public_exact_key_batch_for_typed_binding(
5796 &binding,
5797 &[
5798 exact_key(second),
5799 exact_key(missing),
5800 exact_key(first),
5801 exact_key(second),
5802 ],
5803 )
5804 .expect("exact-key batch should execute")
5805 .expect("exact-key binding should remain current");
5806
5807 assert_eq!(result.positions, vec![0, 1, 2, 0]);
5808 assert_eq!(
5809 result.distinct_rows,
5810 vec![
5811 Some(expected_dynamic_row(second, 42)),
5812 None,
5813 Some(expected_dynamic_row(first, 41)),
5814 ],
5815 );
5816 assert_eq!(
5817 DataStore::current_get_call_count().saturating_sub(gets_before),
5818 3,
5819 "four input positions with one duplicate must perform three physical reads",
5820 );
5821 }
5822
5823 #[cfg(feature = "sql")]
5824 #[test]
5825 fn exact_key_batches_preserve_semantics_across_heap_and_journaled_stores() {
5826 assert_exact_key_batch(&initialize());
5827 assert_exact_key_batch(&initialize_journaled());
5828 }
5829
5830 #[cfg(feature = "sql")]
5831 fn assert_primary_range_materialization_fetches_once<C: CanisterKind>(
5832 session: &DbSession<C>,
5833 store_path: &'static str,
5834 ) {
5835 let key = insert_exact_key_fixture(session, 41);
5836 let stored_bytes = identity_row_stored_bytes(session, store_path, key);
5837
5838 let scalar = DynamicQuery::new(ENTITY_NAME)
5839 .select(["id", "payload"])
5840 .order_by(asc("id"))
5841 .limit(1);
5842 let gets_before = DataStore::current_get_call_count();
5843 let scalar_page = with_stored_bytes_limit(stored_bytes, 0x7072_696d_6172_792d, || {
5844 session.execute_trusted_live_page(&scalar, None)
5845 })
5846 .expect("one scalar primary-range row should fit one payload-read allowance");
5847 assert_eq!(scalar_page.row_count, 1);
5848 assert_eq!(
5849 DataStore::current_get_call_count().saturating_sub(gets_before),
5850 1,
5851 "scalar primary traversal should fetch its emitted row exactly once",
5852 );
5853
5854 let grouped = DynamicQuery::new(ENTITY_NAME)
5855 .group_by("payload")
5856 .aggregate(crate::db::count())
5857 .grouped_limits(10, 16 * 1_024)
5858 .limit(1);
5859 let gets_before = DataStore::current_get_call_count();
5860 let grouped_page = with_stored_bytes_limit(stored_bytes, 0x6772_6f75_7065_642d, || {
5861 session.execute_trusted_dynamic_grouped_query(&grouped)
5862 })
5863 .expect("one grouped primary-range row should fit one payload-read allowance");
5864 assert_eq!(grouped_page.row_count, 1);
5865 assert_eq!(
5866 DataStore::current_get_call_count().saturating_sub(gets_before),
5867 1,
5868 "grouped primary traversal should fetch its source row exactly once",
5869 );
5870 }
5871
5872 #[cfg(feature = "sql")]
5873 #[test]
5874 fn row_materialization_fetches_each_required_payload_at_most_once() {
5875 assert_primary_range_materialization_fetches_once(&initialize(), STORE_PATH);
5876 assert_primary_range_materialization_fetches_once(
5877 &initialize_journaled(),
5878 JOURNALED_STORE_PATH,
5879 );
5880 }
5881
5882 #[cfg(feature = "sql")]
5883 #[test]
5884 fn ordered_grouped_pages_close_a_group_spanning_physical_refills_before_resume() {
5885 let session = initialize();
5886 let mut patches = Vec::new();
5887 for _ in 0..70 {
5888 patches.push(dynamic_payload_patch(10));
5889 }
5890 for _ in 0..3 {
5891 patches.push(dynamic_payload_patch(20));
5892 }
5893 patches.push(dynamic_payload_patch(30));
5894 let inserted = session
5895 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, patches)
5896 .expect("ordered grouped continuation rows should insert");
5897 assert_eq!(inserted.rows.len(), 74);
5898
5899 let query = DynamicQuery::new(ENTITY_NAME)
5900 .group_by("payload")
5901 .aggregate(crate::db::count())
5902 .aggregate(crate::db::sum("id"))
5903 .order_by(asc("payload"))
5904 .grouped_limits(4, 16 * 1_024)
5905 .limit(1);
5906 let expected = [
5907 (10_u64, 70_u64, crate::types::Decimal::new(2_485, 0)),
5908 (20, 3, crate::types::Decimal::new(216, 0)),
5909 (30, 1, crate::types::Decimal::new(74, 0)),
5910 ];
5911 let mut continuation: Option<String> = None;
5912 let mut seen_cursors = std::collections::BTreeSet::new();
5913
5914 for (page_index, (group_key, row_count, id_sum)) in expected.into_iter().enumerate() {
5915 let request = continuation.as_ref().map_or_else(
5916 || query.clone(),
5917 |cursor| query.clone().cursor(cursor.clone()),
5918 );
5919 let entries_before = IndexStore::current_entry_read_count();
5920 let rows_before = DataStore::current_get_call_count();
5921 let page = session
5922 .execute_trusted_dynamic_grouped_query(&request)
5923 .unwrap_or_else(|error| {
5924 panic!("ordered grouped page {page_index} should execute: {error:?}")
5925 });
5926 let entries_read =
5927 IndexStore::current_entry_read_count().saturating_sub(entries_before);
5928 let rows_read = DataStore::current_get_call_count().saturating_sub(rows_before);
5929
5930 assert_eq!(page.row_count, 1);
5931 let [row] = page.rows.as_slice() else {
5932 panic!("ordered grouped page must contain exactly one closed group")
5933 };
5934 assert_eq!(row.group_key(), &[OutputValue::nat64(group_key)]);
5935 assert_eq!(
5936 row.aggregate_values(),
5937 &[OutputValue::nat64(row_count), OutputValue::decimal(id_sum),],
5938 );
5939 if page_index == 0 {
5940 assert!(
5941 entries_read.saturating_add(rows_read) >= 70,
5942 "the first closed group must span the maintained 64-entry physical refill",
5943 );
5944 }
5945
5946 continuation = page.next_cursor;
5947 if page_index + 1 < expected.len() {
5948 let cursor = continuation
5949 .as_ref()
5950 .expect("another closed group should retain continuation");
5951 assert!(
5952 seen_cursors.insert(cursor.clone()),
5953 "ordered grouped continuation must advance monotonically",
5954 );
5955 } else {
5956 assert_eq!(continuation, None);
5957 }
5958 }
5959 }
5960
5961 #[cfg(feature = "sql")]
5962 #[test]
5963 fn exhaustive_pages_require_and_recompare_the_complete_source_proof() {
5964 let session = initialize();
5965 let first = insert_exact_key_fixture(&session, 41);
5966 let second = insert_exact_key_fixture(&session, 42);
5967 let third = insert_exact_key_fixture(&session, 43);
5968 let query = DynamicQuery::new(ENTITY_NAME)
5969 .select(["id", "payload"])
5970 .order_by(asc("id"));
5971
5972 let page = session
5973 .execute_trusted_exhaustive_page(&query, None, None)
5974 .expect("initial exhaustive page should capture its source proof");
5975 assert_eq!(
5976 page.rows,
5977 vec![
5978 expected_dynamic_row(first, 41),
5979 expected_dynamic_row(second, 42),
5980 ],
5981 );
5982 let continuation = page
5983 .continuation
5984 .as_deref()
5985 .expect("unreturned row should retain exhaustive continuation");
5986 assert!(matches!(
5987 session.execute_trusted_exhaustive_page(&query, Some(continuation), None),
5988 Err(ExhaustiveReadError::Revision(
5989 ReadSetRevisionError::ResumeProofRequired
5990 )),
5991 ));
5992 let resumed = session
5993 .execute_trusted_exhaustive_page(&query, Some(continuation), Some(&page.proof))
5994 .expect("unchanged proof should resume exhaustive traversal");
5995 assert_eq!(resumed.rows, vec![expected_dynamic_row(third, 43)]);
5996 assert_eq!(resumed.continuation, None);
5997
5998 let stale_page = session
5999 .execute_trusted_exhaustive_page(&query, None, None)
6000 .expect("fresh exhaustive page should capture current revision");
6001 let stale_continuation = stale_page
6002 .continuation
6003 .as_deref()
6004 .expect("fresh three-row traversal should retain continuation");
6005 let _ = insert_exact_key_fixture(&session, 44);
6006 assert!(matches!(
6007 session.execute_trusted_exhaustive_page(
6008 &query,
6009 Some(stale_continuation),
6010 Some(&stale_page.proof),
6011 ),
6012 Err(ExhaustiveReadError::Revision(
6013 ReadSetRevisionError::StoreDataChanged { .. }
6014 )),
6015 ));
6016 }
6017
6018 #[cfg(feature = "sql")]
6019 #[test]
6020 fn heap_sources_cannot_back_durable_resumable_jobs() {
6021 let session = initialize();
6022 let proof = session
6023 .capture_read_set_revision_proof(&[ENTITY_NAME])
6024 .expect("heap source proof should capture for one-call exhaustive reads");
6025 let job_id = ResumableJobId::try_from_bytes([70; 32])
6026 .expect("nonzero heap test job identity should admit");
6027
6028 assert!(matches!(
6029 session.start_resumable_job(job_id, proof, Vec::new()),
6030 Err(ResumableJobError::SourceProof(
6031 ReadSetRevisionError::DurableStoreRequired { .. }
6032 )),
6033 ));
6034 }
6035
6036 #[cfg(feature = "sql")]
6037 #[test]
6038 fn proof_and_progress_controls_charge_one_shared_request_scope() {
6039 let (session, root) = initialize_journaled_with_root();
6040 let resource = icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions;
6041 let before = root.observed(resource);
6042 let proof = session
6043 .capture_read_set_revision_proof(&[ENTITY_NAME])
6044 .expect("proof capture should use the retained request scope");
6045 let job_id = ResumableJobId::try_from_bytes([75; 32])
6046 .expect("nonzero accounting job identity should admit");
6047 session
6048 .start_resumable_job(job_id, proof, Vec::new())
6049 .expect("job start should use the same retained request scope");
6050 let _ = session
6051 .resumable_job_state(job_id)
6052 .expect("job load should use the same retained request scope");
6053
6054 assert_eq!(root.observed(resource).saturating_sub(before), 3);
6055 }
6056
6057 #[cfg(feature = "sql")]
6058 #[test]
6059 fn source_proofs_ignore_unrelated_stores_but_bind_access_state_changes() {
6060 let session = initialize();
6061 let proof = session
6062 .capture_read_set_revision_proof(&[ENTITY_NAME])
6063 .expect("source proof should cover only the entity's physical store");
6064 let shared_store_proof = session
6065 .capture_read_set_revision_proof(&[ENTITY_NAME, ENTITY_NAME])
6066 .expect("entities sharing one physical source should deduplicate");
6067 assert_eq!(shared_store_proof, proof);
6068 assert_eq!(shared_store_proof.stores().len(), 1);
6069 let unrelated = session
6070 .db
6071 .store_handle(UNRELATED_STORE_PATH)
6072 .expect("unrelated registered store should resolve");
6073 unrelated.with_data_mut(|store| {
6074 let _ = store.remove(&RawDataStoreKey::from_persisted_bytes(vec![1]));
6075 });
6076 session
6077 .verify_read_set_revision_proof(&proof)
6078 .expect("a nonparticipating store mutation must not invalidate the proof");
6079
6080 let source = session
6081 .db
6082 .store_handle(STORE_PATH)
6083 .expect("participating source store should resolve");
6084 source
6085 .mark_index_building()
6086 .expect("source access-state transition should advance its revision");
6087 assert!(matches!(
6088 session.verify_read_set_revision_proof(&proof),
6089 Err(ExhaustiveReadError::Revision(
6090 ReadSetRevisionError::StoreAccessChanged { .. }
6091 )),
6092 ));
6093 }
6094
6095 #[cfg(feature = "sql")]
6096 #[expect(
6097 clippy::too_many_lines,
6098 reason = "one lifecycle test proves successful replay plus pre-page and post-page source invalidation without sharing progress state across tests"
6099 )]
6100 #[test]
6101 fn journaled_job_advance_is_idempotent_and_revision_checked_on_both_sides() {
6102 let session = initialize_journaled();
6103 let proof = session
6104 .capture_read_set_revision_proof(&[ENTITY_NAME])
6105 .expect("journaled source proof should capture");
6106 let job_id =
6107 ResumableJobId::try_from_bytes([71; 32]).expect("nonzero job identity should admit");
6108 session
6109 .start_resumable_job(job_id, proof, vec![0])
6110 .expect("journaled job should start outside its protected source revision");
6111 let request = ResumableJobAdvanceRequest::new(
6112 job_id,
6113 0,
6114 ResumableJobIdempotencyKey::new("page-0")
6115 .expect("bounded idempotency key should admit"),
6116 );
6117 let calls = Cell::new(0_u8);
6118 let receipt = session
6119 .compare_proof_and_advance(&request, |state| {
6120 calls.set(calls.get() + 1);
6121 assert_eq!(state.application_state, vec![0]);
6122 Ok::<_, ()>(
6123 ResumableJobAdvance::new(Some("cursor-1".to_string()), vec![1], vec![9])
6124 .expect("bounded application advance should admit"),
6125 )
6126 })
6127 .expect("unchanged source should advance exactly once");
6128 assert_eq!(calls.get(), 1);
6129 assert_eq!(receipt.status, ResumableJobAdvanceStatus::Advanced);
6130 assert_eq!(receipt.committed_sequence, 1);
6131
6132 let replay = session
6133 .compare_proof_and_advance::<()>(&request, |_| {
6134 panic!("lost-response replay must not execute application work")
6135 })
6136 .expect("same request identity should return its persisted receipt");
6137 assert_eq!(replay, receipt);
6138 let retained = session
6139 .resumable_job_state(job_id)
6140 .expect("advanced state should remain durable");
6141 assert_eq!(retained.sequence, 1);
6142 assert_eq!(retained.application_state, vec![1]);
6143
6144 let _ = insert_exact_key_fixture(&session, 51);
6145 let pre_change_request = ResumableJobAdvanceRequest::new(
6146 job_id,
6147 1,
6148 ResumableJobIdempotencyKey::new("page-1")
6149 .expect("bounded idempotency key should admit"),
6150 );
6151 let pre_change_calls = Cell::new(0_u8);
6152 let invalidated = session
6153 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
6154 pre_change_calls.set(pre_change_calls.get() + 1);
6155 unreachable!("pre-page proof failure must reject before application work")
6156 })
6157 .expect("source drift should persist one replayable invalidation receipt");
6158 assert_eq!(pre_change_calls.get(), 0);
6159 assert_eq!(invalidated.status, ResumableJobAdvanceStatus::Invalidated);
6160 let invalidated_state = session
6161 .resumable_job_state(job_id)
6162 .expect("invalidated job should remain inspectable");
6163 assert_eq!(invalidated_state.status, ResumableJobStatus::Invalidated);
6164 assert_eq!(invalidated_state.continuation, None);
6165 assert_eq!(invalidated_state.application_state, vec![1]);
6166 assert_eq!(
6167 session
6168 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
6169 panic!("invalidation replay must not execute application work")
6170 })
6171 .expect("lost invalidation reply should replay exactly"),
6172 invalidated,
6173 );
6174
6175 let post_proof = session
6176 .capture_read_set_revision_proof(&[ENTITY_NAME])
6177 .expect("post-change journaled proof should capture");
6178 let post_job_id = ResumableJobId::try_from_bytes([72; 32])
6179 .expect("nonzero post-change job identity should admit");
6180 session
6181 .start_resumable_job(post_job_id, post_proof, vec![7])
6182 .expect("post-change journaled job should start");
6183 let post_request = ResumableJobAdvanceRequest::new(
6184 post_job_id,
6185 0,
6186 ResumableJobIdempotencyKey::new("post-page-0")
6187 .expect("bounded idempotency key should admit"),
6188 );
6189 let post_receipt = session
6190 .compare_proof_and_advance::<()>(&post_request, |_| {
6191 let _ = insert_exact_key_fixture(&session, 52);
6192 Ok(ResumableJobAdvance::new(None, vec![8], vec![10])
6193 .expect("bounded post-change candidate should admit"))
6194 })
6195 .expect("post-page drift should discard the candidate and persist invalidation");
6196 assert_eq!(post_receipt.status, ResumableJobAdvanceStatus::Invalidated);
6197 let post_state = session
6198 .resumable_job_state(post_job_id)
6199 .expect("post-page invalidation should remain inspectable");
6200 assert_eq!(post_state.status, ResumableJobStatus::Invalidated);
6201 assert_eq!(post_state.application_state, vec![7]);
6202 session
6203 .acknowledge_resumable_job(post_job_id, post_state.sequence)
6204 .expect("terminal job acknowledgement should remove retained progress");
6205 session
6206 .acknowledge_resumable_job(post_job_id, post_state.sequence)
6207 .expect("lost acknowledgement reply should be safely replayable");
6208 assert_eq!(
6209 session.resumable_job_state(post_job_id),
6210 Err(ResumableJobError::NotFound),
6211 );
6212
6213 let completed_job_id = ResumableJobId::try_from_bytes([74; 32])
6214 .expect("nonzero completed job identity should admit");
6215 let completed_proof = session
6216 .capture_read_set_revision_proof(&[ENTITY_NAME])
6217 .expect("completed-job source proof should capture");
6218 session
6219 .start_resumable_job(completed_job_id, completed_proof, Vec::new())
6220 .expect("completed-job fixture should start");
6221 let completed_request = ResumableJobAdvanceRequest::new(
6222 completed_job_id,
6223 0,
6224 ResumableJobIdempotencyKey::new("complete")
6225 .expect("bounded completion key should admit"),
6226 );
6227 let completed_receipt = session
6228 .compare_proof_and_advance::<()>(&completed_request, |_| {
6229 Ok(ResumableJobAdvance::new(None, vec![99], vec![100])
6230 .expect("bounded terminal advance should admit"))
6231 })
6232 .expect("null continuation should commit terminal completion");
6233 let completed_state = session
6234 .resumable_job_state(completed_job_id)
6235 .expect("completed state should remain replayable before acknowledgement");
6236 assert_eq!(completed_state.status, ResumableJobStatus::Completed);
6237 assert_eq!(
6238 session
6239 .compare_proof_and_advance::<()>(&completed_request, |_| {
6240 panic!("completed request replay must not execute application work")
6241 })
6242 .expect("completed request should replay until acknowledgement"),
6243 completed_receipt,
6244 );
6245 let after_completion = ResumableJobAdvanceRequest::new(
6246 completed_job_id,
6247 1,
6248 ResumableJobIdempotencyKey::new("after-complete")
6249 .expect("bounded post-completion key should admit"),
6250 );
6251 assert!(matches!(
6252 session.compare_proof_and_advance::<()>(&after_completion, |_| {
6253 panic!("completed jobs cannot execute another page")
6254 }),
6255 Err(CompareProofAndAdvanceError::Protocol(
6256 ResumableJobError::Completed
6257 )),
6258 ));
6259 session
6260 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
6261 .expect("completed job should acknowledge and free capacity");
6262 session
6263 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
6264 .expect("completion acknowledgement should be idempotent");
6265
6266 let stale_job_id = ResumableJobId::try_from_bytes([73; 32])
6267 .expect("nonzero stale-sequence job identity should admit");
6268 let stale_proof = session
6269 .capture_read_set_revision_proof(&[ENTITY_NAME])
6270 .expect("stale-sequence source proof should capture");
6271 session
6272 .start_resumable_job(stale_job_id, stale_proof, Vec::new())
6273 .expect("stale-sequence job should start");
6274 let stale_request = ResumableJobAdvanceRequest::new(
6275 stale_job_id,
6276 4,
6277 ResumableJobIdempotencyKey::new("stale").expect("bounded idempotency key should admit"),
6278 );
6279 assert!(matches!(
6280 session.compare_proof_and_advance::<()>(&stale_request, |_| {
6281 panic!("stale sequence must reject before application work")
6282 }),
6283 Err(CompareProofAndAdvanceError::Protocol(
6284 ResumableJobError::StaleSequence {
6285 expected: 4,
6286 actual: 0,
6287 }
6288 )),
6289 ));
6290 assert_eq!(
6291 session.acknowledge_resumable_job(stale_job_id, 0),
6292 Err(ResumableJobError::NotTerminal),
6293 );
6294 }
6295
6296 #[cfg(feature = "sql")]
6297 #[test]
6298 fn exact_key_batch_uses_typed_hard_execution_budget() {
6299 let session = initialize();
6300 let binding = exact_key_binding(&session);
6301 let budget =
6302 HardExecutionBudget::uniform_for_tests(0, HardExecutionFailureHeadroom::new(500, 256));
6303 let error = session
6304 .execute_exact_key_batch_with_hard_budget_for_tests(
6305 &binding,
6306 &[exact_key(u64::MAX)],
6307 &budget,
6308 )
6309 .expect_err("zero query budget should reject the exact-key route");
6310
6311 assert!(matches!(
6312 error.diagnostic().detail(),
6313 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
6314 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
6315 })
6316 ));
6317 let facts = error.diagnostic_facts();
6318 assert_eq!(
6319 &facts[..5],
6320 &[
6321 (
6322 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
6323 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions.raw(),
6324 ),
6325 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 0),
6326 (icydb_diagnostic_code::DiagnosticFactTag::Actual, 1),
6327 (
6328 icydb_diagnostic_code::DiagnosticFactTag::ExecutionBudgetScope,
6329 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution.raw(),
6330 ),
6331 (
6332 icydb_diagnostic_code::DiagnosticFactTag::ExecutionLane,
6333 icydb_diagnostic_code::DiagnosticExecutionLane::PublicRead.raw(),
6334 ),
6335 ],
6336 );
6337 assert_eq!(
6338 facts[5].0,
6339 icydb_diagnostic_code::DiagnosticFactTag::QueryShapeFingerprintPrefix,
6340 );
6341 assert_ne!(facts[5].1, 0);
6342 }
6343
6344 #[cfg(feature = "sql")]
6345 fn assert_planned_query_exhausts(
6346 session: &DbSession<TestCanister>,
6347 query: &crate::db::DynamicQuery,
6348 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
6349 ) {
6350 let budget = HardExecutionBudget::uniform_for_tests(
6351 u64::MAX,
6352 HardExecutionFailureHeadroom::new(500, 256),
6353 )
6354 .with_limit_for_tests(resource, 0);
6355 let context = HardExecutionContext::new(
6356 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
6357 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
6358 0x7068_7973_6963_616c,
6359 );
6360 let error = with_query_execution_budget_for_tests(budget, context, || {
6361 session.execute_trusted_live_page(query, None)
6362 })
6363 .expect_err("the injected zero resource allowance should reject planned execution");
6364
6365 assert!(matches!(
6366 error.diagnostic().detail(),
6367 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
6368 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
6369 })
6370 ));
6371 assert_eq!(
6372 error.diagnostic_facts()[0],
6373 (
6374 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
6375 resource.raw(),
6376 ),
6377 );
6378 }
6379
6380 #[cfg(feature = "sql")]
6381 fn assert_grouped_query_exhausts(
6382 session: &DbSession<TestCanister>,
6383 query: &crate::db::DynamicQuery,
6384 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
6385 ) {
6386 let budget = HardExecutionBudget::uniform_for_tests(
6387 u64::MAX,
6388 HardExecutionFailureHeadroom::new(500, 256),
6389 )
6390 .with_limit_for_tests(resource, 0);
6391 let context = HardExecutionContext::new(
6392 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
6393 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
6394 0x6772_6f75_7065_642d,
6395 );
6396 let error = with_query_execution_budget_for_tests(budget, context, || {
6397 session.execute_trusted_dynamic_grouped_query(query)
6398 })
6399 .expect_err("the injected zero resource allowance should reject grouped execution");
6400
6401 assert!(matches!(
6402 error.diagnostic().detail(),
6403 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
6404 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
6405 })
6406 ));
6407 assert_eq!(
6408 error.diagnostic_facts()[0],
6409 (
6410 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
6411 resource.raw(),
6412 ),
6413 );
6414 }
6415
6416 #[cfg(feature = "sql")]
6417 fn assert_sql_query_exhausts(
6418 session: &DbSession<TestCanister>,
6419 sql: &str,
6420 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
6421 ) {
6422 let budget = HardExecutionBudget::uniform_for_tests(
6423 u64::MAX,
6424 HardExecutionFailureHeadroom::new(500, 256),
6425 )
6426 .with_limit_for_tests(resource, 0);
6427 let context = HardExecutionContext::new(
6428 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
6429 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
6430 0x7371_6c2d_736f_7274,
6431 );
6432 let error = with_query_execution_budget_for_tests(budget, context, || {
6433 session.execute_trusted_sql_query(sql)
6434 })
6435 .expect_err("the injected zero resource allowance should reject SQL execution");
6436
6437 assert!(matches!(
6438 error.diagnostic().detail(),
6439 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
6440 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
6441 })
6442 ));
6443 assert_eq!(
6444 error.diagnostic_facts()[0],
6445 (
6446 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
6447 resource.raw(),
6448 ),
6449 );
6450 }
6451
6452 #[cfg(feature = "sql")]
6453 fn assert_sql_query_fits_resource_limit(
6454 session: &DbSession<TestCanister>,
6455 sql: &str,
6456 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
6457 limit: u64,
6458 ) {
6459 let budget = HardExecutionBudget::uniform_for_tests(
6460 u64::MAX,
6461 HardExecutionFailureHeadroom::new(500, 256),
6462 )
6463 .with_limit_for_tests(resource, limit);
6464 let context = HardExecutionContext::new(
6465 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
6466 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
6467 0x7371_6c2d_626f_756e,
6468 );
6469 with_query_execution_budget_for_tests(budget, context, || {
6470 session.execute_trusted_sql_query(sql)
6471 })
6472 .expect("bounded SQL execution should fit its physical-work limit");
6473 }
6474
6475 #[cfg(feature = "sql")]
6476 #[test]
6477 fn planned_read_routes_share_physical_resource_accounting() {
6478 let session = initialize();
6479 let first = insert_exact_key_fixture(&session, 41);
6480 insert_exact_key_fixture(&session, 42);
6481
6482 let fallback = crate::db::DynamicQuery::new(ENTITY_NAME)
6483 .filter(crate::db::FieldRef::new("id").eq(first))
6484 .select(["id", "payload"])
6485 .order_by(crate::db::asc("id"))
6486 .limit(1);
6487 assert_eq!(
6488 session
6489 .execute_trusted_live_page(&fallback, None)
6490 .expect("bounded fallback execution should preserve its result")
6491 .row_count,
6492 1,
6493 );
6494 assert_planned_query_exhausts(
6495 &session,
6496 &fallback,
6497 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::RowsVisited,
6498 );
6499
6500 let covering = crate::db::DynamicQuery::new(ENTITY_NAME)
6501 .filter(crate::db::FieldRef::new("payload").eq(41_u64))
6502 .select(["payload"])
6503 .order_by(crate::db::asc("payload"))
6504 .limit(1);
6505 assert_eq!(
6506 session
6507 .execute_trusted_live_page(&covering, None)
6508 .expect("bounded covering execution should preserve its result")
6509 .row_count,
6510 1,
6511 );
6512 assert_planned_query_exhausts(
6513 &session,
6514 &covering,
6515 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
6516 );
6517
6518 let residual = crate::db::DynamicQuery::new(ENTITY_NAME)
6519 .filter(crate::db::FieldRef::new("payload").eq_field("id"))
6520 .select(["id"])
6521 .order_by(crate::db::asc("id"))
6522 .limit(1);
6523 assert_eq!(
6524 session
6525 .execute_trusted_live_page(&residual, None)
6526 .expect("bounded residual execution should preserve its result")
6527 .row_count,
6528 0,
6529 );
6530 assert_planned_query_exhausts(
6531 &session,
6532 &residual,
6533 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::PredicateExpressionSteps,
6534 );
6535
6536 assert_planned_query_exhausts(
6537 &session,
6538 &fallback,
6539 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::ResultBytes,
6540 );
6541
6542 let grouped = crate::db::DynamicQuery::new(ENTITY_NAME)
6543 .group_by("payload")
6544 .aggregate(crate::db::count())
6545 .order_by(crate::db::asc("payload"))
6546 .grouped_limits(10, 16 * 1_024)
6547 .limit(1);
6548 let grouped_result = session
6549 .execute_trusted_dynamic_grouped_query(&grouped)
6550 .expect("bounded grouped execution should preserve its result");
6551 assert_eq!(grouped_result.row_count, 1);
6552 assert!(grouped_result.next_cursor.is_some());
6553 assert_grouped_query_exhausts(
6554 &session,
6555 &grouped,
6556 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::GroupDistinctEntries,
6557 );
6558 assert_grouped_query_exhausts(
6559 &session,
6560 &grouped,
6561 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::CursorSteps,
6562 );
6563
6564 assert_sql_query_exhausts(
6565 &session,
6566 "SELECT payload, COUNT(*) AS row_count FROM IdentityRow \
6567 GROUP BY payload ORDER BY row_count DESC, payload ASC LIMIT 1",
6568 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::SortEntries,
6569 );
6570 }
6571
6572 #[cfg(feature = "sql")]
6573 #[test]
6574 fn mutation_execution_budget_exhaustion_terminalizes_forward_and_verify() {
6575 let (session, _root) = initialize_journaled_with_root();
6576 assert_eq!(insert_exact_key_fixture(&session, 41), 1);
6577
6578 for (identity, sql, expected_phase) in [
6579 (
6580 91_u8,
6581 "UPDATE IdentityRow SET payload = 42 WHERE id = 1",
6582 MutationJobPhase::Forward,
6583 ),
6584 (
6585 92_u8,
6586 "UPDATE IdentityRow SET payload = 42 WHERE id = 999",
6587 MutationJobPhase::Verify,
6588 ),
6589 ] {
6590 let job_id = MutationJobId::try_from_bytes([identity; 32])
6591 .expect("budget fixture identity should admit");
6592 let mut state = session
6593 .start_trusted_sql_mutation_job(job_id, sql)
6594 .expect("budget fixture job should start");
6595 if expected_phase == MutationJobPhase::Verify {
6596 let forward = MutationJobAdvanceRequest::new(
6597 job_id,
6598 state.sequence,
6599 MutationJobIdempotencyKey::new(format!("budget-forward-{identity}"))
6600 .expect("bounded Forward replay identity should admit"),
6601 );
6602 let receipt = session
6603 .advance_trusted_mutation_job(&forward)
6604 .expect("nonmatching Forward page should enter Verify");
6605 assert_eq!(receipt.phase, MutationJobPhase::Verify);
6606 state = session
6607 .mutation_job_state(job_id)
6608 .expect("Verify predecessor should remain readable");
6609 }
6610 assert_eq!(state.phase, expected_phase);
6611
6612 let request = MutationJobAdvanceRequest::new(
6613 job_id,
6614 state.sequence,
6615 MutationJobIdempotencyKey::new(format!("budget-exhaust-{identity}"))
6616 .expect("bounded exhaustion replay identity should admit"),
6617 );
6618 let terminal = advance_with_exhausted_mutation_predicate_budget(&session, &request)
6619 .expect("admitted execution-budget failure should commit terminal progress");
6620 assert_eq!(
6621 terminal.status,
6622 MutationJobStatus::RestartRequired(
6623 MutationJobRestartReason::ExecutionBudgetPolicyExceeded,
6624 ),
6625 );
6626 assert_eq!(terminal.rows_updated, 0);
6627 assert_eq!(
6628 session.advance_trusted_mutation_job(&request),
6629 Ok(terminal.clone()),
6630 "exact terminal replay must not execute the exhausted page again",
6631 );
6632 assert_dynamic_payload(&session, 1, 41);
6633 session
6634 .acknowledge_mutation_job(job_id, terminal.committed_sequence)
6635 .expect("terminal budget fixture should acknowledge");
6636 }
6637 }
6638
6639 fn assert_dynamic_payload<C: CanisterKind>(
6640 session: &DbSession<C>,
6641 key: u64,
6642 expected_payload: u64,
6643 ) {
6644 let unchanged = session
6645 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
6646 entity: ENTITY_NAME.to_string(),
6647 key: InputValue::nat64(key),
6648 patch: dynamic_payload_patch(expected_payload),
6649 })
6650 .expect("the expected row should remain readable through a no-op update");
6651 assert_eq!(unchanged.affected_rows, 0);
6652 assert_eq!(
6653 unchanged.rows,
6654 vec![expected_dynamic_row(key, expected_payload)],
6655 );
6656 }
6657
6658 fn assert_exact_batch_backlog_pressure(
6659 pressure: &InternalError,
6660 before: JournalTailControl,
6661 next_sequence: u64,
6662 ) {
6663 assert_eq!(
6664 pressure.diagnostic().error_code(),
6665 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_CONVERGENCE_BACKLOG_PRESSURE,
6666 );
6667 assert_eq!(
6668 pressure.diagnostic_facts(),
6669 vec![
6670 (
6671 icydb_diagnostic_code::DiagnosticFactTag::BacklogResource,
6672 icydb_diagnostic_code::DiagnosticBacklogResource::Batches.raw(),
6673 ),
6674 (icydb_diagnostic_code::DiagnosticFactTag::CurrentCount, 64),
6675 (icydb_diagnostic_code::DiagnosticFactTag::ProposedCount, 1),
6676 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 64),
6677 ],
6678 );
6679 assert_eq!(
6680 crate::db::commit::next_database_commit_sequence()
6681 .expect("pressure must leave the database sequence readable"),
6682 next_sequence,
6683 );
6684 assert!(matches!(
6685 crate::db::commit::observe_commit_control()
6686 .expect("pressure must leave commit control observable"),
6687 crate::db::commit::CommitControlObservation::Present {
6688 marker_present: false,
6689 ..
6690 },
6691 ));
6692 assert_eq!(
6693 JOURNALED_TAIL_STORE.with(|tail| {
6694 tail.borrow()
6695 .current_tail_control()
6696 .expect("pressure must preserve the exact tail control")
6697 }),
6698 before,
6699 );
6700 }
6701
6702 fn batch(values: &[u64]) -> Vec<AcceptedStructuralMutation> {
6703 values
6704 .iter()
6705 .map(|value| {
6706 AcceptedStructuralMutation::save(
6707 MutationMode::Insert,
6708 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
6709 payload_patch(*value),
6710 )
6711 })
6712 .collect()
6713 }
6714
6715 fn atomic_progress_fixture(
6716 identity_byte: u8,
6717 ) -> (
6718 MutationJobRecord,
6719 MutationJobRecord,
6720 MutationProgressRecordOp,
6721 ) {
6722 let job_id = MutationJobId::try_from_bytes([identity_byte; 32])
6723 .expect("nonzero atomic progress job id should admit");
6724 let before = MutationJobRecord::new(job_id, vec![1, identity_byte], vec![2])
6725 .expect("atomic progress predecessor should admit");
6726 let request = MutationJobAdvanceRequest::new(
6727 job_id,
6728 0,
6729 MutationJobIdempotencyKey::new(format!("atomic-{identity_byte}"))
6730 .expect("atomic progress replay key should admit"),
6731 );
6732 let (after, _) = before
6733 .apply_transition(
6734 &request,
6735 MutationJobTransition::new(
6736 MutationJobStatus::Active,
6737 MutationJobPhase::Forward,
6738 vec![3],
6739 1,
6740 1,
6741 0,
6742 ),
6743 )
6744 .expect("atomic progress successor should admit");
6745 let operation = MutationProgressRecordOp::replace(&before, &after)
6746 .expect("atomic progress replacement should admit");
6747 (before, after, operation)
6748 }
6749
6750 fn assert_mutation_facts(
6751 error: &InternalError,
6752 session: &DbSession<TestCanister>,
6753 tail: Vec<(icydb_diagnostic_code::DiagnosticFactTag, u64)>,
6754 ) {
6755 use icydb_diagnostic_code::DiagnosticFactTag as Tag;
6756 let catalog = session
6757 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
6758 .unwrap();
6759 let fingerprint = catalog.fingerprint();
6760 let mut expected = vec![
6761 (
6762 Tag::AcceptedSchemaFingerprintMethod,
6763 u64::from(catalog.fingerprint_method_version()),
6764 ),
6765 (
6766 Tag::AcceptedSchemaFingerprintHigh,
6767 u64::from_be_bytes(fingerprint[..8].try_into().unwrap()),
6768 ),
6769 (
6770 Tag::AcceptedSchemaFingerprintLow,
6771 u64::from_be_bytes(fingerprint[8..].try_into().unwrap()),
6772 ),
6773 ];
6774 expected.extend(tail);
6775 assert_eq!(error.diagnostic_facts(), expected);
6776 assert_eq!(
6777 icydb_diagnostic_code::validate_known_diagnostic_fact_schema(
6778 error.diagnostic().error_code(),
6779 &expected,
6780 ),
6781 Ok(()),
6782 );
6783 }
6784
6785 fn assert_identity_boundary(error: &InternalError) {
6786 assert_eq!(error.class(), ErrorClass::Unsupported);
6787 assert_eq!(error.origin(), ErrorOrigin::Identity);
6788 }
6789
6790 #[test]
6791 fn generated_candidate_collision_is_identity_corruption_before_generic_uniqueness() {
6792 let generated = insert_key_exists_after_generation(true);
6793 assert_eq!(generated.class(), ErrorClass::Corruption);
6794 assert_eq!(generated.origin(), ErrorOrigin::Identity);
6795
6796 let ordinary = insert_key_exists_after_generation(false);
6797 assert_ne!(ordinary.origin(), ErrorOrigin::Identity);
6798 }
6799
6800 #[cfg(target_pointer_width = "64")]
6801 #[test]
6802 fn pre_key_candidate_count_rejects_values_beyond_the_persisted_u32_bound() {
6803 let error = checked_pre_key_candidate_count(
6804 usize::try_from(u64::from(u32::MAX) + 1).expect("64-bit usize should hold u32 + 1"),
6805 )
6806 .expect_err("candidate counts beyond u32 must reject");
6807 assert_identity_boundary(&error);
6808 }
6809
6810 #[test]
6811 #[expect(
6812 clippy::too_many_lines,
6813 reason = "one holding lifecycle proves split, merge, transfer, late-failure neutrality, result order, and Identity state"
6814 )]
6815 fn mixed_structural_batch_preserves_holding_conservation_and_failure_atomicity() {
6816 let session = initialize();
6817 let seeded = session
6818 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
6819 .expect("seed rows should commit");
6820 assert_eq!(seeded.affected_rows, 1);
6821
6822 let split = session
6823 .execute_trusted_dynamic_mutation_batch(vec![
6824 DynamicMutation::Update {
6825 entity: ENTITY_NAME.to_string(),
6826 key: InputValue::nat64(1),
6827 patch: dynamic_payload_patch(60),
6828 },
6829 DynamicMutation::Insert {
6830 entity: ENTITY_NAME.to_string(),
6831 patch: dynamic_payload_patch(40),
6832 },
6833 ])
6834 .expect("one holding should split atomically");
6835 assert_eq!(
6836 split.iter().map(|result| result.affected_rows).sum::<u32>(),
6837 2,
6838 );
6839 assert_eq!(
6840 batch_rows(&split),
6841 vec![expected_dynamic_row(1, 60), expected_dynamic_row(2, 40),],
6842 "split after-images must retain input order and exact quantity",
6843 );
6844
6845 let rejected_split = session
6846 .execute_trusted_dynamic_mutation_batch(vec![
6847 DynamicMutation::Update {
6848 entity: ENTITY_NAME.to_string(),
6849 key: InputValue::nat64(1),
6850 patch: dynamic_payload_patch(50),
6851 },
6852 DynamicMutation::Insert {
6853 entity: ENTITY_NAME.to_string(),
6854 patch: DynamicStructuralPatch::new(Vec::new()),
6855 },
6856 ])
6857 .expect_err("an invalid split output must reject the staged source update");
6858 assert_eq!(rejected_split.class(), ErrorClass::Unsupported);
6859 assert_eq!(rejected_split.origin(), ErrorOrigin::Executor);
6860 assert_mutation_facts(
6861 &rejected_split,
6862 &session,
6863 vec![
6864 (
6865 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
6866 ENTITY_TAG.value(),
6867 ),
6868 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 2),
6869 (
6870 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
6871 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
6872 ),
6873 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1),
6874 ],
6875 );
6876 assert_dynamic_payload(&session, 1, 60);
6877 assert_dynamic_payload(&session, 2, 40);
6878
6879 let transfer = session
6880 .execute_trusted_dynamic_mutation_batch(vec![
6881 DynamicMutation::Update {
6882 entity: ENTITY_NAME.to_string(),
6883 key: InputValue::nat64(1),
6884 patch: dynamic_payload_patch(70),
6885 },
6886 DynamicMutation::Update {
6887 entity: ENTITY_NAME.to_string(),
6888 key: InputValue::nat64(2),
6889 patch: dynamic_payload_patch(30),
6890 },
6891 ])
6892 .expect("distinct transfer patches should share one atomic batch");
6893 assert_eq!(
6894 batch_rows(&transfer),
6895 vec![expected_dynamic_row(1, 70), expected_dynamic_row(2, 30),],
6896 "the transfer must preserve the exact total quantity",
6897 );
6898
6899 let merge = session
6900 .execute_trusted_dynamic_mutation_batch(vec![
6901 DynamicMutation::Delete {
6902 entity: ENTITY_NAME.to_string(),
6903 key: InputValue::nat64(2),
6904 },
6905 DynamicMutation::Update {
6906 entity: ENTITY_NAME.to_string(),
6907 key: InputValue::nat64(1),
6908 patch: dynamic_payload_patch(100),
6909 },
6910 ])
6911 .expect("two holdings should merge atomically");
6912 assert_eq!(
6913 batch_rows(&merge),
6914 vec![expected_dynamic_row(2, 30), expected_dynamic_row(1, 100),],
6915 "delete before-images and update after-images must retain input order",
6916 );
6917
6918 let resplit = session
6919 .execute_trusted_dynamic_mutation_batch(vec![
6920 DynamicMutation::Update {
6921 entity: ENTITY_NAME.to_string(),
6922 key: InputValue::nat64(1),
6923 patch: dynamic_payload_patch(60),
6924 },
6925 DynamicMutation::Insert {
6926 entity: ENTITY_NAME.to_string(),
6927 patch: dynamic_payload_patch(40),
6928 },
6929 ])
6930 .expect("the merged holding should split again");
6931 assert_eq!(
6932 batch_rows(&resplit),
6933 vec![expected_dynamic_row(1, 60), expected_dynamic_row(3, 40),],
6934 );
6935
6936 let rejected_merge = session
6937 .execute_trusted_dynamic_mutation_batch(vec![
6938 DynamicMutation::Delete {
6939 entity: ENTITY_NAME.to_string(),
6940 key: InputValue::nat64(3),
6941 },
6942 DynamicMutation::Update {
6943 entity: ENTITY_NAME.to_string(),
6944 key: InputValue::nat64(99),
6945 patch: dynamic_payload_patch(100),
6946 },
6947 ])
6948 .expect_err("a late missing merge target must preserve the earlier staged delete");
6949 assert_eq!(rejected_merge.class(), ErrorClass::NotFound);
6950 assert_dynamic_payload(&session, 1, 60);
6951 assert_dynamic_payload(&session, 3, 40);
6952
6953 SCHEMA_STORE.with(|store| {
6954 let cursor = store
6955 .borrow()
6956 .identity_statement_cursor(
6957 database_incarnation_id().expect("database incarnation should remain readable"),
6958 ENTITY_TAG,
6959 FieldId::new(1),
6960 &AcceptedFieldKind::Nat64,
6961 )
6962 .expect("mixed Identity state should remain readable");
6963 assert_eq!(cursor.expected_high_water(), 3);
6964 assert!(!cursor.has_allocations());
6965 });
6966 }
6967
6968 #[test]
6969 fn mixed_structural_batch_rejects_duplicate_holding_targets_without_mutation() {
6970 let session = initialize();
6971 session
6972 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
6973 .expect("the holding fixture should initialize");
6974
6975 let duplicate = session
6976 .execute_trusted_dynamic_mutation_batch(vec![
6977 DynamicMutation::Update {
6978 entity: ENTITY_NAME.to_string(),
6979 key: InputValue::nat64(1),
6980 patch: dynamic_payload_patch(60),
6981 },
6982 DynamicMutation::Delete {
6983 entity: ENTITY_NAME.to_string(),
6984 key: InputValue::nat64(1),
6985 },
6986 ])
6987 .expect_err("duplicate targets across operation kinds must reject");
6988 assert!(matches!(
6989 duplicate.diagnostic().detail(),
6990 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
6991 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchDuplicateKey,
6992 }),
6993 ));
6994 assert_eq!(
6995 duplicate.diagnostic_facts(),
6996 vec![
6997 (
6998 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
6999 ENTITY_TAG.value(),
7000 ),
7001 (
7002 icydb_diagnostic_code::DiagnosticFactTag::FirstBatchPosition,
7003 0,
7004 ),
7005 (
7006 icydb_diagnostic_code::DiagnosticFactTag::DuplicateBatchPosition,
7007 1,
7008 ),
7009 ],
7010 );
7011 assert_dynamic_payload(&session, 1, 100);
7012 }
7013
7014 #[test]
7015 fn mixed_structural_batch_rejects_empty_and_over_bound_before_resolution() {
7016 let session = initialize();
7017 let empty = session
7018 .execute_trusted_dynamic_mutation_batch(Vec::new())
7019 .expect_err("an empty public batch must reject");
7020 assert!(matches!(
7021 empty.diagnostic().detail(),
7022 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
7023 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchEmpty,
7024 }),
7025 ));
7026 assert_eq!(
7027 empty.diagnostic_facts(),
7028 vec![(icydb_diagnostic_code::DiagnosticFactTag::ActualCount, 0,)],
7029 );
7030
7031 let requests = (0..=MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS)
7032 .map(|_| DynamicMutation::Delete {
7033 entity: ENTITY_NAME.to_string(),
7034 key: InputValue::nat64(1),
7035 })
7036 .collect();
7037 let over_bound = session
7038 .execute_trusted_dynamic_mutation_batch(requests)
7039 .expect_err("operation cap plus one must reject before row resolution");
7040 assert!(matches!(
7041 over_bound.diagnostic().detail(),
7042 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
7043 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchTooManyItems,
7044 }),
7045 ));
7046 assert_eq!(
7047 over_bound.diagnostic_facts(),
7048 vec![
7049 (
7050 icydb_diagnostic_code::DiagnosticFactTag::ActualCount,
7051 (MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS + 1) as u64,
7052 ),
7053 (
7054 icydb_diagnostic_code::DiagnosticFactTag::Limit,
7055 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS as u64,
7056 ),
7057 ],
7058 );
7059 }
7060
7061 #[test]
7062 fn mixed_structural_batch_staged_byte_bound_uses_checked_exact_boundary() {
7063 assert_eq!(
7064 structural_mutation_staged_charge([11, 13, 17])
7065 .expect("the writer-owned formula should sum all three row-image components"),
7066 41,
7067 );
7068 let mut exact = 0;
7069 add_structural_mutation_staged_bytes(
7070 &mut exact,
7071 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES],
7072 )
7073 .expect("the exact staged-byte boundary should admit");
7074 assert_eq!(exact, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
7075
7076 let error = add_structural_mutation_staged_bytes(&mut exact, [1])
7077 .expect_err("one byte above the staged-byte boundary must reject");
7078 assert!(matches!(
7079 error.diagnostic().detail(),
7080 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
7081 boundary:
7082 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchStagedBytesExceeded,
7083 }),
7084 ));
7085 assert_eq!(
7086 error.diagnostic_facts(),
7087 vec![
7088 (
7089 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
7090 (MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES + 1) as u64,
7091 ),
7092 (
7093 icydb_diagnostic_code::DiagnosticFactTag::Limit,
7094 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES as u64,
7095 ),
7096 ],
7097 );
7098
7099 let mut prefix = 0;
7100 assert_eq!(
7101 admit_structural_mutation_staged_charge(
7102 &mut prefix,
7103 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES],
7104 AcceptedStructuralMutationPacking::BoundedPrefix,
7105 )
7106 .expect("the exact prefix boundary should calculate"),
7107 AcceptedStructuralMutationStagedAdmission::Admitted,
7108 );
7109 assert_eq!(prefix, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
7110 assert_eq!(
7111 admit_structural_mutation_staged_charge(
7112 &mut prefix,
7113 [1],
7114 AcceptedStructuralMutationPacking::BoundedPrefix,
7115 )
7116 .expect("the next prefix candidate should calculate"),
7117 AcceptedStructuralMutationStagedAdmission::PageFull,
7118 );
7119 assert_eq!(prefix, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
7120
7121 let mut empty_prefix = 0;
7122 assert_eq!(
7123 admit_structural_mutation_staged_charge(
7124 &mut empty_prefix,
7125 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES + 1],
7126 AcceptedStructuralMutationPacking::BoundedPrefix,
7127 )
7128 .expect("one oversized candidate should classify without mutating the prefix"),
7129 AcceptedStructuralMutationStagedAdmission::CandidateExceedsPolicy,
7130 );
7131 assert_eq!(empty_prefix, 0);
7132
7133 validate_structural_mutation_result_bytes(MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES)
7134 .expect("the exact result-byte boundary should admit");
7135 let error = validate_structural_mutation_result_bytes(
7136 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1,
7137 )
7138 .expect_err("one byte above the result-byte boundary must reject");
7139 assert!(matches!(
7140 error.diagnostic().detail(),
7141 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
7142 boundary:
7143 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchResultBytesExceeded,
7144 }),
7145 ));
7146 assert_eq!(
7147 error.diagnostic_facts(),
7148 vec![
7149 (
7150 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
7151 (MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1) as u64,
7152 ),
7153 (
7154 icydb_diagnostic_code::DiagnosticFactTag::Limit,
7155 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES as u64,
7156 ),
7157 ],
7158 );
7159 }
7160
7161 #[expect(
7162 clippy::too_many_lines,
7163 reason = "one lifecycle proves shared materialization and every maintained frontend against the same zero-state owner"
7164 )]
7165 #[test]
7166 fn identity_insert_frontends_share_one_committed_range_without_rejected_consumption() {
7167 let session = initialize();
7168 let catalog = session
7169 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
7170 .expect("identity catalog should resolve");
7171 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
7172 .expect("identity row layout should build");
7173 let initial_description = session
7174 .try_describe_entity_by_name(ENTITY_NAME)
7175 .expect("accepted Identity description should resolve");
7176 assert_eq!(
7177 initial_description.entity_tag(),
7178 catalog.identity().entity_tag().value()
7179 );
7180 assert_eq!(
7181 initial_description.accepted_schema_fingerprint_method(),
7182 catalog.fingerprint_method_version()
7183 );
7184 assert_eq!(
7185 initial_description.accepted_schema_fingerprint(),
7186 catalog.fingerprint()
7187 );
7188 let initial_identity = initial_description
7189 .identity()
7190 .expect("accepted Identity policy should be described");
7191 assert_eq!(initial_identity.field(), "id");
7192 assert_eq!(initial_identity.generator(), "Identity::next");
7193 assert_eq!(initial_identity.accepted_kind(), "nat64");
7194 assert_eq!(initial_identity.minimum(), 1);
7195 assert_eq!(initial_identity.maximum(), u128::from(u64::MAX));
7196 assert_eq!(initial_identity.high_water(), 0);
7197 assert_eq!(initial_identity.remaining(), u128::from(u64::MAX));
7198 assert!(!initial_identity.exhausted());
7199
7200 let rejected = session
7201 .execute_accepted_structural_save_batch(
7202 &catalog,
7203 &descriptor,
7204 batch(&[1_000, 2_000]),
7205 Timestamp::from_millis(6),
7206 |_| Err::<(), _>(InternalError::executor_unsupported()),
7207 )
7208 .expect_err("a rejected precommit result must not publish its tentative range");
7209 assert_eq!(rejected.class(), ErrorClass::Unsupported);
7210 assert_eq!(DATA_STORE.with(|store| store.borrow().len()), 0);
7211
7212 let rows = session
7213 .execute_accepted_structural_save_batch(
7214 &catalog,
7215 &descriptor,
7216 batch(&[10, 20, 30]),
7217 Timestamp::from_millis(7),
7218 Ok,
7219 )
7220 .expect("one accepted batch should commit rows and one identity range");
7221 assert_eq!(
7222 rows.into_iter().map(|row| row.values).collect::<Vec<_>>(),
7223 vec![
7224 vec![Value::Nat64(1), Value::Nat64(10)],
7225 vec![Value::Nat64(2), Value::Nat64(20)],
7226 vec![Value::Nat64(3), Value::Nat64(30)],
7227 ],
7228 );
7229
7230 let dynamic = session
7231 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
7232 entity: ENTITY_NAME.to_string(),
7233 patch: DynamicStructuralPatch::new(vec![(
7234 "payload".to_string(),
7235 DynamicWriteCell::Value(InputValue::nat64(40)),
7236 )]),
7237 })
7238 .expect("dynamic omission should commit through shared Identity generation");
7239 assert_eq!(dynamic.affected_rows, 1);
7240
7241 for (request, operation) in [
7242 (
7243 DynamicMutation::Insert {
7244 entity: ENTITY_NAME.to_string(),
7245 patch: DynamicStructuralPatch::new(vec![
7246 (
7247 "id".to_string(),
7248 DynamicWriteCell::Value(InputValue::nat64(41)),
7249 ),
7250 (
7251 "payload".to_string(),
7252 DynamicWriteCell::Value(InputValue::nat64(42)),
7253 ),
7254 ]),
7255 },
7256 icydb_diagnostic_code::DiagnosticMutationOperation::Insert,
7257 ),
7258 (
7259 DynamicMutation::Update {
7260 entity: ENTITY_NAME.to_string(),
7261 key: InputValue::nat64(1),
7262 patch: DynamicStructuralPatch::new(vec![(
7263 "id".to_string(),
7264 DynamicWriteCell::Default,
7265 )]),
7266 },
7267 icydb_diagnostic_code::DiagnosticMutationOperation::Update,
7268 ),
7269 ] {
7270 let error = session
7271 .execute_trusted_dynamic_mutation(&request)
7272 .expect_err("structural Identity authorship and regeneration must reject");
7273 assert_eq!(error.class(), ErrorClass::Unsupported);
7274 assert_eq!(error.origin(), ErrorOrigin::Executor);
7275 assert_mutation_facts(
7276 &error,
7277 &session,
7278 vec![
7279 (
7280 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
7281 ENTITY_TAG.value(),
7282 ),
7283 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
7284 (
7285 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
7286 operation.raw(),
7287 ),
7288 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0),
7289 ],
7290 );
7291 }
7292
7293 let binding = session
7294 .issue_typed_entity_binding(&TYPED_DESCRIPTOR)
7295 .expect("typed output should bind the Identity field");
7296 let typed_patch = binding
7297 .bind_write_ordinals(vec![(1, DynamicWriteCell::Value(InputValue::nat64(50)))])
7298 .expect("typed payload should lower");
7299 let typed = session
7300 .execute_trusted_typed_mutation(
7301 &binding,
7302 DynamicTypedMutation::Insert { patch: typed_patch },
7303 )
7304 .expect("typed omission should commit through shared Identity generation");
7305 assert_eq!(
7306 typed
7307 .expect("typed insert should return one mutation result")
7308 .affected_rows,
7309 1,
7310 );
7311 let explicit_typed_patch = binding
7312 .bind_write_ordinals(vec![
7313 (0, DynamicWriteCell::Value(InputValue::nat64(51))),
7314 (1, DynamicWriteCell::Value(InputValue::nat64(52))),
7315 ])
7316 .expect("the low-level binding should retain exact authored intent");
7317 let explicit_typed_error = session
7318 .execute_trusted_typed_mutation(
7319 &binding,
7320 DynamicTypedMutation::Insert {
7321 patch: explicit_typed_patch,
7322 },
7323 )
7324 .expect_err("typed Identity authorship must reject before allocation");
7325 assert_eq!(explicit_typed_error.class(), ErrorClass::Unsupported);
7326 assert_eq!(explicit_typed_error.origin(), ErrorOrigin::Executor);
7327 assert_mutation_facts(
7328 &explicit_typed_error,
7329 &session,
7330 vec![
7331 (
7332 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
7333 ENTITY_TAG.value(),
7334 ),
7335 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
7336 (
7337 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
7338 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
7339 ),
7340 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0),
7341 ],
7342 );
7343
7344 let replace_error = session
7345 .execute_trusted_dynamic_mutation(&DynamicMutation::Replace {
7346 entity: ENTITY_NAME.to_string(),
7347 key: InputValue::nat64(99),
7348 patch: DynamicStructuralPatch::new(vec![(
7349 "payload".to_string(),
7350 DynamicWriteCell::Value(InputValue::nat64(60)),
7351 )]),
7352 })
7353 .expect_err("save-as-insert with a chosen Identity must reject");
7354 assert_eq!(replace_error.class(), ErrorClass::Unsupported);
7355 assert_eq!(replace_error.origin(), ErrorOrigin::Executor);
7356
7357 #[cfg(feature = "sql")]
7358 {
7359 for sql in [
7360 "INSERT INTO IdentityRow (payload) VALUES (70) RETURNING id, payload",
7361 "INSERT INTO IdentityRow (id, payload) VALUES (DEFAULT, 80) RETURNING id",
7362 ] {
7363 let _result = session
7364 .execute_trusted_sql_mutation(sql)
7365 .expect("SQL omission and DEFAULT should commit Identity generation");
7366 }
7367
7368 let error = session
7369 .execute_trusted_sql_mutation(
7370 "INSERT INTO IdentityRow (id, payload) VALUES (42, 90)",
7371 )
7372 .expect_err("an explicit SQL Identity value must reject before allocation");
7373 let diagnostic = error.diagnostic();
7374 assert_eq!(
7375 diagnostic.code(),
7376 icydb_diagnostic_code::DiagnosticCode::QuerySqlWriteBoundary,
7377 );
7378 assert!(matches!(
7379 diagnostic.detail(),
7380 Some(icydb_diagnostic_code::DiagnosticDetail::SqlWriteBoundary {
7381 boundary: icydb_diagnostic_code::SqlWriteBoundaryCode::ExplicitGeneratedField,
7382 }),
7383 ));
7384 }
7385
7386 let expected_committed = if cfg!(feature = "sql") { 7 } else { 5 };
7387 assert_eq!(
7388 DATA_STORE.with(|store| store.borrow().len()),
7389 expected_committed
7390 );
7391 SCHEMA_STORE.with(|store| {
7392 let cursor = store
7393 .borrow()
7394 .identity_statement_cursor(
7395 database_incarnation_id().expect("database incarnation should remain readable"),
7396 ENTITY_TAG,
7397 FieldId::new(1),
7398 &AcceptedFieldKind::Nat64,
7399 )
7400 .expect("committed writes must leave active state readable");
7401 assert_eq!(cursor.expected_high_water(), u128::from(expected_committed),);
7402 assert!(!cursor.has_allocations());
7403 });
7404 let committed_description = session
7405 .try_describe_entity_by_name(ENTITY_NAME)
7406 .expect("committed Identity description should resolve");
7407 let committed_identity = committed_description
7408 .identity()
7409 .expect("accepted Identity policy should remain described");
7410 assert_eq!(
7411 committed_identity.high_water(),
7412 u128::from(expected_committed),
7413 );
7414 assert_eq!(
7415 committed_identity.remaining(),
7416 u128::from(u64::MAX - expected_committed),
7417 );
7418 assert!(!committed_identity.exhausted());
7419 }
7420
7421 #[test]
7422 #[expect(
7423 clippy::too_many_lines,
7424 reason = "one ordered scenario proves target/progress atomicity, every interruption wake-up, state-only admission, and successful no-op wake-up behavior"
7425 )]
7426 fn mutation_progress_and_target_rows_recover_as_one_marker_transition() {
7427 let session = initialize_journaled();
7428 let initial_entity_revision = JOURNALED_TAIL_STORE
7429 .with(|tail| tail.borrow().entity_mutation_revision(ENTITY_TAG))
7430 .expect("direct initial schema publication must install entity revision authority");
7431 assert_eq!(initial_entity_revision, 1);
7432 install_startup_recovery_wakeup(record_startup_wakeup);
7433 let catalog = session
7434 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
7435 .expect("journaled atomic-progress catalog should resolve");
7436 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
7437 .expect("journaled atomic-progress row layout should build");
7438
7439 for (ordinal, interruption) in [
7440 MutationCommitInterruption::MarkerPersisted,
7441 MutationCommitInterruption::JournalPublished,
7442 MutationCommitInterruption::RowsPublished,
7443 MutationCommitInterruption::ProgressReplaced,
7444 ]
7445 .into_iter()
7446 .enumerate()
7447 {
7448 let identity_byte = 31 + u8::try_from(ordinal).expect("small ordinal should fit");
7449 let (before, after, operation) = atomic_progress_fixture(identity_byte);
7450 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7451 match store.insert_mutation(&before)? {
7452 InsertMutationJobResult::Inserted => Ok(()),
7453 InsertMutationJobResult::Occupied(_) => {
7454 Err(crate::db::MutationJobError::IdentityConflict)
7455 }
7456 }
7457 })
7458 .expect("atomic predecessor should insert once");
7459
7460 let wakeups_before = STARTUP_WAKEUPS.with(Cell::get);
7461 interrupt_next_mutation_commit_for_tests(interruption);
7462 let interrupted = session.execute_accepted_structural_update_with_mutation_progress(
7463 &catalog,
7464 &descriptor,
7465 batch(&[700 + u64::try_from(ordinal).expect("small ordinal should fit")]),
7466 Timestamp::from_millis(17),
7467 operation,
7468 );
7469 assert!(
7470 interrupted.is_err(),
7471 "selected atomic boundary should interrupt"
7472 );
7473 assert_eq!(
7474 STARTUP_WAKEUPS.with(Cell::get),
7475 wakeups_before.saturating_add(1),
7476 "a normally returned retained-marker error must register its wake-up",
7477 );
7478
7479 forget_recovered_domain_for_tests(&session.db)
7480 .expect("interruption should reset volatile recovery ownership");
7481 let retained_before =
7482 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7483 store.load_mutation(before.state().job_id)
7484 })
7485 .expect("pre-driver progress should load");
7486 let row_count_before = JOURNALED_DATA_STORE.with(|store| store.borrow().len());
7487 let pending = session
7488 .db
7489 .ensure_recovered_state()
7490 .expect_err("ordinary admission must not drive retained-marker recovery");
7491 assert_eq!(
7492 pending.diagnostic().error_code(),
7493 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
7494 );
7495 assert_eq!(
7496 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7497 store.load_mutation(before.state().job_id)
7498 })
7499 .expect("post-admission progress should load"),
7500 retained_before,
7501 );
7502 assert_eq!(
7503 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
7504 row_count_before,
7505 "state-only admission must not mutate target rows",
7506 );
7507 drive_journaled_recovery_to_completion(&session);
7508 let retained = with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7509 store.load_mutation(before.state().job_id)
7510 })
7511 .expect("recovered successor should load");
7512 assert_eq!(retained, after);
7513 assert_eq!(
7514 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
7515 u64::try_from(ordinal + 1).expect("small row count should fit"),
7516 );
7517 assert_eq!(
7518 JOURNALED_TAIL_STORE
7519 .with(|tail| tail.borrow().entity_mutation_revision(ENTITY_TAG))
7520 .expect("recovery must publish the target entity revision"),
7521 initial_entity_revision
7522 + u64::try_from(ordinal + 1).expect("small revision delta should fit"),
7523 "target rows, entity revision, and progress must recover as one transition",
7524 );
7525 }
7526
7527 let (before, after, operation) = atomic_progress_fixture(39);
7528 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7529 match store.insert_mutation(&before)? {
7530 InsertMutationJobResult::Inserted => Ok(()),
7531 InsertMutationJobResult::Occupied(_) => {
7532 Err(crate::db::MutationJobError::IdentityConflict)
7533 }
7534 }
7535 })
7536 .expect("final predecessor should insert once");
7537 let wakeups_before_success = STARTUP_WAKEUPS.with(Cell::get);
7538 session
7539 .execute_accepted_structural_update_with_mutation_progress(
7540 &catalog,
7541 &descriptor,
7542 batch(&[799]),
7543 Timestamp::from_millis(18),
7544 operation,
7545 )
7546 .expect("uninterrupted atomic transition should clear its marker");
7547 assert_eq!(
7548 STARTUP_WAKEUPS.with(Cell::get),
7549 wakeups_before_success.saturating_add(1),
7550 "a successful retained commit must request online convergence",
7551 );
7552 let retained = with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7553 store.load_mutation(before.state().job_id)
7554 })
7555 .expect("final successor should load");
7556 assert_eq!(retained, after);
7557 forget_recovered_domain_for_tests(&session.db)
7558 .expect("post-clear recovery ownership should reset");
7559 let pending = session
7560 .db
7561 .ensure_recovered_state()
7562 .expect_err("an upgrade epoch must remain gated until its driver runs");
7563 assert_eq!(
7564 pending.diagnostic().error_code(),
7565 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
7566 );
7567 drive_journaled_recovery_to_completion(&session);
7568 assert_eq!(
7569 JOURNALED_TAIL_STORE
7570 .with(|tail| tail.borrow().entity_mutation_revision(ENTITY_TAG))
7571 .expect("uninterrupted transition must retain its entity revision"),
7572 initial_entity_revision + 5,
7573 );
7574 }
7575
7576 fn assert_mixed_entity_recovered_state(session: &DbSession<JournaledTestCanister>) {
7577 for (entity_name, payload) in [
7578 (ENTITY_NAME, 100_u64),
7579 (SECOND_ENTITY_NAME, 1_100),
7580 (THIRD_ENTITY_NAME, 2_100),
7581 ] {
7582 let result = session
7583 .execute_trusted_live_page(
7584 &DynamicQuery::new(entity_name)
7585 .filter(crate::db::FieldRef::new("payload").eq(payload))
7586 .select(["id", "payload"])
7587 .order_by(crate::db::asc("id"))
7588 .limit(64),
7589 None,
7590 )
7591 .expect("every recovered mixed entity should remain queryable");
7592 assert_eq!(result.rows.len(), 1);
7593 }
7594 let retained_relation = session
7595 .execute_trusted_dynamic_mutation_batch(vec![DynamicMutation::Delete {
7596 entity: ENTITY_NAME.to_string(),
7597 key: InputValue::nat64(1),
7598 }])
7599 .expect_err("the recovered reverse relation must protect its target");
7600 assert!(retained_relation.diagnostic_facts().contains(&(
7601 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
7602 icydb_diagnostic_code::DiagnosticConstraintKind::Relation.raw(),
7603 )));
7604 JOURNALED_SCHEMA_STORE.with(|store| {
7605 let store = store.borrow();
7606 for entity_tag in [ENTITY_TAG, SECOND_ENTITY_TAG, THIRD_ENTITY_TAG] {
7607 let cursor = store
7608 .identity_statement_cursor(
7609 database_incarnation_id()
7610 .expect("database incarnation should remain readable"),
7611 entity_tag,
7612 FieldId::new(1),
7613 &AcceptedFieldKind::Nat64,
7614 )
7615 .expect("every mixed Identity owner should remain readable");
7616 assert_eq!(cursor.expected_high_water(), 1);
7617 assert!(!cursor.has_allocations());
7618 }
7619 });
7620 JOURNALED_TAIL_STORE.with(|tail| {
7621 let tail = tail.borrow();
7622 assert_eq!(
7623 tail.entity_mutation_revision(ENTITY_TAG)
7624 .expect("first entity revision should remain readable"),
7625 2,
7626 );
7627 assert_eq!(
7628 tail.entity_mutation_revision(SECOND_ENTITY_TAG)
7629 .expect("second entity revision should remain readable"),
7630 2,
7631 );
7632 assert_eq!(
7633 tail.entity_mutation_revision(THIRD_ENTITY_TAG)
7634 .expect("third entity revision should remain readable"),
7635 2,
7636 );
7637 });
7638 }
7639
7640 fn assert_mixed_entity_recovery(interruption: MutationCommitInterruption) {
7641 let session = initialize_journaled_multi_entity();
7642 interrupt_next_mutation_commit_for_tests(interruption);
7643 let interrupted = session.execute_trusted_dynamic_mutation_batch(vec![
7644 DynamicMutation::Insert {
7645 entity: ENTITY_NAME.to_string(),
7646 patch: dynamic_payload_patch(100),
7647 },
7648 DynamicMutation::Insert {
7649 entity: SECOND_ENTITY_NAME.to_string(),
7650 patch: related_dynamic_payload_patch(1_100, 1),
7651 },
7652 DynamicMutation::Insert {
7653 entity: THIRD_ENTITY_NAME.to_string(),
7654 patch: dynamic_payload_patch(2_100),
7655 },
7656 ]);
7657 let interruption_error =
7658 interrupted.expect_err("the selected marker boundary should interrupt");
7659 assert_eq!(interruption_error.class(), ErrorClass::InvariantViolation);
7660 if interruption == MutationCommitInterruption::MarkerPersisted {
7661 let (marker_bytes, journal_batch_bytes) =
7662 crate::db::commit::retained_commit_marker_measurement_for_tests()
7663 .expect("the retained marker measurement should remain readable")
7664 .expect("marker persistence should retain one marker");
7665 assert_eq!(marker_bytes, 770);
7666 assert_eq!(journal_batch_bytes, vec![740]);
7667 }
7668 if interruption != MutationCommitInterruption::MarkerPersisted {
7669 let retained_batch = JOURNALED_TAIL_STORE.with(|tail| {
7670 let tail = tail.borrow();
7671 let watermark = tail
7672 .fold_watermark()
7673 .expect("the interrupted fold watermark should decode")
7674 .highest_folded_journal_sequence();
7675 tail.next_batch_after(watermark)
7676 .expect("the interrupted journal tail should decode")
7677 .expect("the interrupted marker should publish one journal batch")
7678 });
7679 let row_paths = retained_batch
7680 .records()
7681 .iter()
7682 .filter_map(|record| match record {
7683 JournalRecord::RowPut { entity_path, .. }
7684 | JournalRecord::RowDelete { entity_path, .. } => Some(entity_path.as_str()),
7685 _ => None,
7686 })
7687 .collect::<Vec<_>>();
7688 assert_eq!(
7689 row_paths,
7690 vec![ENTITY_SOURCE, SECOND_ENTITY_SOURCE, THIRD_ENTITY_SOURCE],
7691 );
7692 }
7693
7694 forget_recovered_domain_for_tests(&session.db)
7695 .expect("the retained mixed marker should reset volatile recovery ownership");
7696 drive_journaled_recovery_to_completion(&session);
7697 assert_mixed_entity_recovered_state(&session);
7698 }
7699
7700 #[test]
7701 fn mixed_entity_recovery_after_marker_persistence() {
7702 assert_mixed_entity_recovery(MutationCommitInterruption::MarkerPersisted);
7703 }
7704
7705 #[test]
7706 fn mixed_entity_recovery_after_journal_publication() {
7707 assert_mixed_entity_recovery(MutationCommitInterruption::JournalPublished);
7708 }
7709
7710 #[test]
7711 fn mixed_entity_recovery_after_row_prefix_publication() {
7712 assert_mixed_entity_recovery(MutationCommitInterruption::RowPrefixPublished);
7713 }
7714
7715 #[test]
7716 fn mixed_entity_recovery_after_all_rows_publish() {
7717 assert_mixed_entity_recovery(MutationCommitInterruption::RowsPublished);
7718 }
7719
7720 #[test]
7721 fn mixed_entity_recovery_after_state_materialization() {
7722 assert_mixed_entity_recovery(MutationCommitInterruption::StateMaterialized);
7723 }
7724
7725 #[test]
7726 fn startup_recovery_initializes_missing_entity_revisions_from_the_store_revision() {
7727 let session = initialize_journaled();
7728 let catalog = session
7729 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
7730 .expect("journaled predecessor catalog should resolve");
7731 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
7732 .expect("journaled predecessor row layout should build");
7733 session
7734 .execute_accepted_structural_save_batch(
7735 &catalog,
7736 &descriptor,
7737 batch(&[901]),
7738 Timestamp::from_millis(21),
7739 Ok,
7740 )
7741 .expect("predecessor row should advance the store-wide revision");
7742 let baseline = JOURNALED_TAIL_STORE.with(|tail| {
7743 let mut tail = tail.borrow_mut();
7744 let baseline = tail
7745 .data_mutation_revision()
7746 .expect("predecessor store-wide revision should load");
7747 tail.clear_entity_mutation_revisions_for_tests();
7748 baseline
7749 });
7750
7751 forget_recovered_domain_for_tests(&session.db)
7752 .expect("upgrade should reset volatile recovery ownership");
7753 drive_journaled_recovery_to_completion(&session);
7754
7755 let recovered = JOURNALED_TAIL_STORE
7756 .with(|tail| tail.borrow().entity_mutation_revision(ENTITY_TAG))
7757 .expect("recovery should publish the current entity authority");
7758 assert_eq!(recovered, baseline);
7759 }
7760
7761 #[test]
7762 fn mutation_progress_neither_side_mismatch_blocks_recovery() {
7763 let session = initialize_journaled();
7764 let catalog = session
7765 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
7766 .expect("journaled corruption catalog should resolve");
7767 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
7768 .expect("journaled corruption row layout should build");
7769 let (before, _after, operation) = atomic_progress_fixture(41);
7770 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7771 match store.insert_mutation(&before)? {
7772 InsertMutationJobResult::Inserted => Ok(()),
7773 InsertMutationJobResult::Occupied(_) => {
7774 Err(crate::db::MutationJobError::IdentityConflict)
7775 }
7776 }
7777 })
7778 .expect("corruption predecessor should insert once");
7779
7780 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::MarkerPersisted);
7781 assert!(
7782 session
7783 .execute_accepted_structural_update_with_mutation_progress(
7784 &catalog,
7785 &descriptor,
7786 batch(&[811]),
7787 Timestamp::from_millis(19),
7788 operation,
7789 )
7790 .is_err(),
7791 "marker interruption should retain recovery authority",
7792 );
7793 let (unexpected, _) = before
7794 .apply_transition(
7795 &MutationJobAdvanceRequest::new(
7796 before.state().job_id,
7797 0,
7798 MutationJobIdempotencyKey::new("unexpected-third-state")
7799 .expect("unexpected replay key should admit"),
7800 ),
7801 MutationJobTransition::new(
7802 MutationJobStatus::Active,
7803 MutationJobPhase::Forward,
7804 vec![99],
7805 2,
7806 0,
7807 0,
7808 ),
7809 )
7810 .expect("unexpected but valid progress state should admit");
7811 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7812 store.replace_mutation(&unexpected)
7813 })
7814 .expect("test should install the neither-side state");
7815
7816 forget_recovered_domain_for_tests(&session.db)
7817 .expect("corrupt recovery ownership should reset");
7818 let error = session
7819 .db
7820 .drive_startup_recovery_page()
7821 .expect_err("neither-side progress must block recovery");
7822 assert_eq!(error.class(), ErrorClass::Corruption);
7823 assert_eq!(error.origin(), ErrorOrigin::Recovery);
7824 assert_eq!(
7825 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7826 store.load_mutation(before.state().job_id)
7827 })
7828 .expect("unexpected state should remain inspectable to the test"),
7829 unexpected,
7830 );
7831 assert!(
7832 session.db.drive_startup_recovery_page().is_err(),
7833 "a retained corrupt marker must continue blocking database access",
7834 );
7835 }
7836
7837 #[test]
7838 #[expect(
7839 clippy::too_many_lines,
7840 reason = "one ordered scenario exercises every durable interruption boundary, guarded recovery, derived rebuild, and both integrity tiers"
7841 )]
7842 fn journaled_identity_recovery_quiesces_every_publication_interruption_before_reallocation() {
7843 let session = initialize_journaled();
7844 let catalog = session
7845 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
7846 .expect("journaled identity catalog should resolve");
7847 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
7848 .expect("journaled identity row layout should build");
7849
7850 for (ordinal, interruption) in [
7851 MutationCommitInterruption::MarkerPersisted,
7852 MutationCommitInterruption::JournalPublished,
7853 MutationCommitInterruption::RowsPublished,
7854 MutationCommitInterruption::StateMaterialized,
7855 ]
7856 .into_iter()
7857 .enumerate()
7858 {
7859 interrupt_next_mutation_commit_for_tests(interruption);
7860 let interrupted = session.execute_accepted_structural_save_batch(
7861 &catalog,
7862 &descriptor,
7863 batch(&[u64::try_from(ordinal).expect("ordinal should fit")]),
7864 Timestamp::from_millis(8),
7865 Ok,
7866 );
7867 assert!(
7868 interrupted.is_err(),
7869 "the selected durable boundary should interrupt",
7870 );
7871
7872 let Err(pending) = session.execute_accepted_structural_save_batch(
7873 &catalog,
7874 &descriptor,
7875 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
7876 Timestamp::from_millis(9),
7877 Ok,
7878 ) else {
7879 panic!("ordinary mutation must not drive retained-marker recovery");
7880 };
7881 assert_eq!(
7882 pending.diagnostic().error_code(),
7883 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
7884 );
7885 drive_journaled_recovery_to_completion(&session);
7886
7887 let committed = session
7888 .execute_accepted_structural_save_batch(
7889 &catalog,
7890 &descriptor,
7891 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
7892 Timestamp::from_millis(9),
7893 Ok,
7894 )
7895 .expect("the next mutation must recover before allocating");
7896 let expected_high_water =
7897 u64::try_from((ordinal + 1) * 2).expect("small test high-water should fit");
7898 assert_eq!(
7899 committed
7900 .into_iter()
7901 .map(|row| row.values)
7902 .collect::<Vec<_>>(),
7903 vec![vec![
7904 Value::Nat64(expected_high_water),
7905 Value::Nat64(100 + u64::try_from(ordinal).expect("ordinal should fit")),
7906 ]],
7907 );
7908 assert_eq!(
7909 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
7910 expected_high_water,
7911 );
7912 JOURNALED_SCHEMA_STORE.with(|store| {
7913 let cursor = store
7914 .borrow()
7915 .identity_statement_cursor(
7916 database_incarnation_id()
7917 .expect("database incarnation should remain readable"),
7918 ENTITY_TAG,
7919 FieldId::new(1),
7920 &AcceptedFieldKind::Nat64,
7921 )
7922 .expect("guarded recovery must leave quiescent active state");
7923 assert_eq!(
7924 cursor.expected_high_water(),
7925 u128::from(expected_high_water),
7926 );
7927 assert!(!cursor.has_allocations());
7928 });
7929 }
7930
7931 for (ordinal, (interruption, deleted_key)) in [
7932 (MutationCommitInterruption::MarkerPersisted, 2),
7933 (MutationCommitInterruption::JournalPublished, 4),
7934 (MutationCommitInterruption::RowPrefixPublished, 6),
7935 (MutationCommitInterruption::RowsPublished, 8),
7936 (MutationCommitInterruption::StateMaterialized, 7),
7937 ]
7938 .into_iter()
7939 .enumerate()
7940 {
7941 let expected_payload =
7942 501 + u64::try_from(ordinal).expect("small interruption ordinal should fit");
7943 interrupt_next_mutation_commit_for_tests(interruption);
7944 let interrupted = session.execute_trusted_dynamic_mutation_batch(vec![
7945 DynamicMutation::Update {
7946 entity: ENTITY_NAME.to_string(),
7947 key: InputValue::nat64(1),
7948 patch: dynamic_payload_patch(expected_payload),
7949 },
7950 DynamicMutation::Delete {
7951 entity: ENTITY_NAME.to_string(),
7952 key: InputValue::nat64(deleted_key),
7953 },
7954 ]);
7955 assert!(
7956 interrupted.is_err(),
7957 "the selected caller-key mixed publication boundary should interrupt",
7958 );
7959 let pending = session
7960 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
7961 entity: ENTITY_NAME.to_string(),
7962 key: InputValue::nat64(1),
7963 patch: dynamic_payload_patch(expected_payload),
7964 })
7965 .expect_err("ordinary update must not drive retained-marker recovery");
7966 assert_eq!(
7967 pending.diagnostic().error_code(),
7968 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
7969 );
7970 drive_journaled_recovery_to_completion(&session);
7971 let recovered_update = session
7972 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
7973 entity: ENTITY_NAME.to_string(),
7974 key: InputValue::nat64(1),
7975 patch: dynamic_payload_patch(expected_payload),
7976 })
7977 .expect("guarded reentry should complete the marker-authorized mixed batch");
7978 assert_eq!(
7979 recovered_update.affected_rows, 0,
7980 "the recovered update must already expose its admitted final image",
7981 );
7982 let recovered_delete = session
7983 .execute_trusted_dynamic_mutation(&DynamicMutation::Delete {
7984 entity: ENTITY_NAME.to_string(),
7985 key: InputValue::nat64(deleted_key),
7986 })
7987 .expect_err("the recovered delete must already be materialized");
7988 assert_eq!(recovered_delete.class(), ErrorClass::NotFound);
7989 JOURNALED_SCHEMA_STORE.with(|store| {
7990 let cursor = store
7991 .borrow()
7992 .identity_statement_cursor(
7993 database_incarnation_id()
7994 .expect("database incarnation should remain readable"),
7995 ENTITY_TAG,
7996 FieldId::new(1),
7997 &AcceptedFieldKind::Nat64,
7998 )
7999 .expect("caller-key recovery must preserve active Identity state");
8000 assert_eq!(cursor.expected_high_water(), 8);
8001 assert!(!cursor.has_allocations());
8002 });
8003 }
8004
8005 forget_recovered_domain_for_tests(&session.db)
8006 .expect("the final journal tail should remain recoverable");
8007 session
8008 .db
8009 .drive_startup_recovery_page()
8010 .expect("derived rebuild must not allocate another identity");
8011
8012 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
8013 let index_generation = JOURNALED_INDEX_STORE.with(|store| store.borrow().generation());
8014 let data_len = JOURNALED_DATA_STORE.with(|store| store.borrow().len());
8015 let index_len = JOURNALED_INDEX_STORE.with(|store| store.borrow().len());
8016 forget_recovered_domain_for_tests(&session.db)
8017 .expect("an empty-tail upgrade should reset recovery ownership");
8018 session
8019 .db
8020 .drive_startup_recovery_page()
8021 .expect("an empty-tail upgrade should admit without rebuilding stored rows or indexes");
8022 assert_eq!(
8023 JOURNALED_DATA_STORE.with(|store| store.borrow().generation()),
8024 data_generation
8025 .checked_add(1)
8026 .expect("test generation should advance once"),
8027 "empty-tail recovery must reset the disposable row projection exactly once",
8028 );
8029 assert_eq!(
8030 JOURNALED_INDEX_STORE.with(|store| store.borrow().generation()),
8031 index_generation
8032 .checked_add(1)
8033 .expect("test generation should advance once"),
8034 "empty-tail recovery must reset the disposable index projection exactly once",
8035 );
8036 assert_eq!(
8037 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
8038 data_len,
8039 "empty-tail recovery must not rebuild or remove authoritative rows",
8040 );
8041 assert_eq!(
8042 JOURNALED_INDEX_STORE.with(|store| store.borrow().len()),
8043 index_len,
8044 "empty-tail recovery must not clear or rebuild canonical secondary indexes",
8045 );
8046
8047 let quick = execute_quick_integrity(
8048 &session.db,
8049 catalog.inspection_plan(),
8050 catalog.runtime_root_identity().database_incarnation(),
8051 )
8052 .expect("quiescent Identity control inventory should be inspectable");
8053 assert_eq!(quick.status(), &QuickIntegrityStatus::CompleteClean);
8054 let row_page = execute_row_integrity_page(
8055 &session.db,
8056 catalog.inspection_plan(),
8057 PhysicalUnitCheckpoint::BeforeFirst,
8058 RowInspectionLimits::standard(),
8059 )
8060 .expect("Identity rows should remain within committed high-water");
8061 assert!(row_page.exhausted());
8062 assert!(row_page.findings().is_empty());
8063
8064 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 3);
8065 assert!(
8066 JOURNALED_INDEX_STORE.with(|store| !store.borrow().is_empty()),
8067 "derived index rebuild should restore witnesses without allocating identities",
8068 );
8069 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
8070 JOURNALED_SCHEMA_STORE.with(|store| {
8071 let cursor = store
8072 .borrow()
8073 .identity_statement_cursor(
8074 database_incarnation_id().expect("database incarnation should remain readable"),
8075 ENTITY_TAG,
8076 FieldId::new(1),
8077 &AcceptedFieldKind::Nat64,
8078 )
8079 .expect("folded identity state should reopen without allocating");
8080 assert_eq!(cursor.expected_high_water(), 8);
8081 assert!(!cursor.has_allocations());
8082 });
8083 }
8084
8085 #[test]
8086 fn journaled_online_convergence_drains_the_full_backlog_in_complete_batch_callbacks_without_reallocating_ids()
8087 {
8088 const SUBMISSION: &str = "generated/8899aabbccddeeff";
8089 let session = initialize_journaled();
8090 let catalog = session
8091 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
8092 .expect("journaled identity catalog should resolve");
8093 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
8094 .expect("journaled identity row layout should build");
8095
8096 for payload in 0_u64..64 {
8097 session
8098 .execute_accepted_structural_save_batch(
8099 &catalog,
8100 &descriptor,
8101 batch(&[payload]),
8102 Timestamp::from_millis(8),
8103 Ok,
8104 )
8105 .unwrap_or_else(|error| {
8106 panic!("journaled identity fixture row {payload} should commit: {error:?}")
8107 });
8108 }
8109
8110 let before = JOURNALED_TAIL_STORE.with(|tail| {
8111 tail.borrow()
8112 .current_tail_control()
8113 .expect("online backlog control should remain valid")
8114 });
8115 assert_eq!(before.batch_count(), 64);
8116 let next_sequence = crate::db::commit::next_database_commit_sequence()
8117 .expect("database sequence preview should remain readable");
8118 let Err(pressure) = session.execute_accepted_structural_save_batch(
8119 &catalog,
8120 &descriptor,
8121 batch(&[64]),
8122 Timestamp::from_millis(8),
8123 Ok,
8124 ) else {
8125 panic!("the exact cumulative batch ceiling should reject one more batch")
8126 };
8127 assert_exact_batch_backlog_pressure(&pressure, before, next_sequence);
8128
8129 for folded_batches in 1..=64 {
8130 let complete = session
8131 .db
8132 .drive_startup_recovery_page()
8133 .expect("online complete-batch callback should commit");
8134 assert_eq!(complete, folded_batches == 64);
8135 }
8136
8137 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
8138 session
8139 .execute_accepted_structural_save_batch(
8140 &catalog,
8141 &descriptor,
8142 batch(&[64]),
8143 Timestamp::from_millis(8),
8144 Ok,
8145 )
8146 .expect("drain should make the rejected mutation retryable");
8147 assert!(
8148 session
8149 .db
8150 .drive_startup_recovery_page()
8151 .expect("the retry tail should converge"),
8152 );
8153
8154 assert_eq!(
8155 drive_generated_startup_recovery_page(&session, &JOURNALED_STORE_REGISTRY, SUBMISSION,)
8156 .expect("online convergence should commit"),
8157 GeneratedStartupDriverStep::ApplyGeneratedSchema,
8158 "journal convergence does not complete an unsubmitted generated schema",
8159 );
8160 assert!(
8161 session
8162 .db
8163 .drive_startup_recovery_page()
8164 .expect("the drained journal should remain quiescent"),
8165 );
8166
8167 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 65);
8168 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
8169 assert_dynamic_payload(&session, 1, 0);
8170 assert_dynamic_payload(&session, 65, 64);
8171 JOURNALED_SCHEMA_STORE.with(|store| {
8172 let cursor = store
8173 .borrow()
8174 .identity_statement_cursor(
8175 database_incarnation_id().expect("database incarnation should remain readable"),
8176 ENTITY_TAG,
8177 FieldId::new(1),
8178 &AcceptedFieldKind::Nat64,
8179 )
8180 .expect("online convergence must preserve active Identity state");
8181 assert_eq!(cursor.expected_high_water(), 65);
8182 assert!(!cursor.has_allocations());
8183 });
8184 }
8185
8186 #[test]
8187 fn journaled_online_convergence_reconstructs_same_key_batches_from_canonical_predecessors() {
8188 let session = initialize_journaled();
8189 session
8190 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
8191 entity: ENTITY_NAME.to_string(),
8192 patch: dynamic_payload_patch(10),
8193 })
8194 .expect("the initial positioned row should commit");
8195 for payload in [20, 30] {
8196 session
8197 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
8198 entity: ENTITY_NAME.to_string(),
8199 key: InputValue::nat64(1),
8200 patch: dynamic_payload_patch(payload),
8201 })
8202 .unwrap_or_else(|error| {
8203 panic!("the positioned same-key update should commit: {error:?}")
8204 });
8205 }
8206
8207 assert_dynamic_payload(&session, 1, 30);
8208 assert_eq!(
8209 JOURNALED_INDEX_STORE.with(|store| store.borrow().len()),
8210 1,
8211 "the newest live index effect should hide every predecessor",
8212 );
8213 for folded_batches in 1..=3 {
8214 let complete = session
8215 .db
8216 .drive_startup_recovery_page()
8217 .expect("the positioned same-key batch should converge");
8218 assert_eq!(complete, folded_batches == 3);
8219 }
8220
8221 assert_dynamic_payload(&session, 1, 30);
8222 assert_eq!(
8223 JOURNALED_INDEX_STORE.with(|store| store.borrow().len()),
8224 1,
8225 "canonical derived state must contain only the newest membership",
8226 );
8227 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
8228 }
8229
8230 #[test]
8231 fn ready_cardinality_combines_durable_base_with_exact_live_delta_and_fold_maintenance() {
8232 let session = initialize_journaled();
8233 session
8234 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
8235 entity: ENTITY_NAME.to_string(),
8236 patch: dynamic_payload_patch(10),
8237 })
8238 .expect("initial cardinality row should commit");
8239 assert!(
8240 session
8241 .db
8242 .drive_startup_recovery_page()
8243 .expect("initial cardinality row should fold"),
8244 );
8245 drive_journaled_cardinality_to_ready(&session);
8246 let handle = session
8247 .db
8248 .store_handle(JOURNALED_STORE_PATH)
8249 .expect("journaled cardinality store should resolve");
8250 let (index_id, prefix_components) = journaled_user_index_prefix();
8251 reset_journaled_cardinality_projections();
8252 assert_eq!(
8253 JOURNALED_DATA_STORE.with(|store| store.borrow().exact_entity_count(ENTITY_TAG)),
8254 None,
8255 "the reopened-style volatile full count must remain unavailable",
8256 );
8257 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 1);
8258
8259 session
8260 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
8261 entity: ENTITY_NAME.to_string(),
8262 patch: dynamic_payload_patch(10),
8263 })
8264 .expect("post-Ready row should commit into the live overlay");
8265 for payload in [20, 10] {
8266 session
8267 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
8268 entity: ENTITY_NAME.to_string(),
8269 key: InputValue::nat64(2),
8270 patch: dynamic_payload_patch(payload),
8271 })
8272 .expect("same-key post-Ready overlay should commit");
8273 }
8274 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 2);
8275 for folded in 1..=3 {
8276 let complete = session
8277 .db
8278 .drive_startup_recovery_page()
8279 .expect("post-Ready row should fold with exact maintenance");
8280 assert_eq!(complete, folded == 3);
8281 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 2);
8282 }
8283 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 2);
8284 session
8285 .execute_trusted_dynamic_mutation(&DynamicMutation::Delete {
8286 entity: ENTITY_NAME.to_string(),
8287 key: InputValue::nat64(2),
8288 })
8289 .expect("post-Ready delete should commit into the live overlay");
8290 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 1);
8291 assert!(
8292 session
8293 .db
8294 .drive_startup_recovery_page()
8295 .expect("post-Ready delete should fold with exact maintenance"),
8296 );
8297 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 1);
8298 mark_journaled_cardinality_building();
8299 assert_eq!(
8300 handle.exact_entity_count(ENTITY_TAG),
8301 None,
8302 "non-Ready evidence must select the conservative path",
8303 );
8304 #[cfg(feature = "sql")]
8305 {
8306 let data_reads_before = DataStore::current_get_call_count();
8307 let crate::db::SqlStatementResult::Projection { rows, .. } = session
8308 .execute_trusted_sql_query("SELECT COUNT(*) FROM IdentityRow")
8309 .expect("non-Ready entity cardinality should retain SQL fallback")
8310 else {
8311 panic!("fallback count should return one projection row")
8312 };
8313 assert_eq!(rows, vec![vec![OutputValue::nat64(1)]]);
8314 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
8315 }
8316 }
8317
8318 #[test]
8319 fn journaled_cardinality_rejects_volatile_counts_and_unfolded_accepted_root_drift() {
8320 let session = initialize_journaled();
8321 session
8322 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
8323 entity: ENTITY_NAME.to_string(),
8324 patch: dynamic_payload_patch(10),
8325 })
8326 .expect("cardinality fixture row should commit");
8327 assert!(
8328 session
8329 .db
8330 .drive_startup_recovery_page()
8331 .expect("cardinality fixture row should fold"),
8332 );
8333 drive_journaled_cardinality_to_ready(&session);
8334 let handle = session
8335 .db
8336 .store_handle(JOURNALED_STORE_PATH)
8337 .expect("journaled cardinality store should resolve");
8338 let (index_id, prefix_components) = journaled_user_index_prefix();
8339 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
8340
8341 assert_eq!(
8342 JOURNALED_DATA_STORE.with(|store| store.borrow().exact_entity_count(ENTITY_TAG)),
8343 Some(1),
8344 "the live full-count cache should be populated before accepted-root drift",
8345 );
8346 assert_eq!(
8347 JOURNALED_INDEX_STORE.with(|store| {
8348 store.borrow().exact_prefix_cardinality(
8349 data_generation,
8350 IndexKeyKind::User,
8351 index_id,
8352 prefix_components.as_slice(),
8353 )
8354 }),
8355 Some(1),
8356 "the live prefix-count cache should be populated before accepted-root drift",
8357 );
8358 assert_eq!(
8359 JOURNALED_INDEX_STORE.with(|store| {
8360 store.borrow().exact_child_prefixes_for_parent_set(
8361 data_generation,
8362 IndexKeyKind::User,
8363 index_id,
8364 [prefix_components.as_slice()],
8365 8,
8366 )
8367 }),
8368 Some(Vec::new()),
8369 "the volatile child-prefix cache should demonstrate the bypass fixture",
8370 );
8371 assert_eq!(
8372 handle.exact_user_index_child_prefixes_for_parent_set(
8373 data_generation,
8374 index_id,
8375 [prefix_components.as_slice()],
8376 8,
8377 ),
8378 None,
8379 "journaled child enumeration must use its conservative route instead of volatile authority",
8380 );
8381 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 1);
8382
8383 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
8384 JOURNALED_STORE_PATH,
8385 AcceptedSchemaRevision::new(2),
8386 BTreeMap::from([(ENTITY_TAG, identity_snapshot(JOURNALED_STORE_PATH, false))]),
8387 BTreeMap::from([
8388 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
8389 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
8390 ]),
8391 );
8392 crate::db::commit::publish_accepted_schema_candidate(
8393 JOURNALED_STORE_PATH,
8394 handle,
8395 AcceptedSchemaRevision::INITIAL,
8396 &candidate,
8397 )
8398 .expect("a successor accepted root should publish into the live overlay");
8399
8400 assert_eq!(
8401 handle.exact_entity_count(ENTITY_TAG),
8402 None,
8403 "an unfolded accepted root must invalidate durable evidence immediately",
8404 );
8405 assert_eq!(
8406 handle.exact_user_index_prefix_count(
8407 data_generation,
8408 IndexKeyKind::User,
8409 index_id,
8410 prefix_components.as_slice(),
8411 ),
8412 None,
8413 "journaled consumers must not fall back to a populated volatile prefix cache",
8414 );
8415 }
8416
8417 #[test]
8418 fn journaled_convergence_uses_final_batch_rows_for_unique_release() {
8419 let session = initialize_journaled_with_unique_payload();
8420 let inserted = session
8421 .execute_trusted_dynamic_insert_batch(
8422 ENTITY_NAME,
8423 vec![dynamic_payload_patch(10), dynamic_payload_patch(20)],
8424 )
8425 .expect("the unique journal fixture should commit");
8426 assert_eq!(
8427 inserted.rows,
8428 vec![expected_dynamic_row(1, 10), expected_dynamic_row(2, 20)],
8429 );
8430 assert!(
8431 session
8432 .db
8433 .drive_startup_recovery_page()
8434 .expect("the unique fixture should become canonical"),
8435 );
8436
8437 let swapped = session
8438 .execute_trusted_dynamic_mutation_batch(vec![
8439 DynamicMutation::Update {
8440 entity: ENTITY_NAME.to_string(),
8441 key: InputValue::nat64(1),
8442 patch: dynamic_payload_patch(20),
8443 },
8444 DynamicMutation::Update {
8445 entity: ENTITY_NAME.to_string(),
8446 key: InputValue::nat64(2),
8447 patch: dynamic_payload_patch(10),
8448 },
8449 ])
8450 .expect("one journal batch should admit a final-row unique swap");
8451 assert_eq!(
8452 batch_rows(&swapped),
8453 vec![expected_dynamic_row(1, 20), expected_dynamic_row(2, 10)],
8454 );
8455 assert!(
8456 session
8457 .db
8458 .drive_startup_recovery_page()
8459 .expect("the unique swap should converge in one complete batch"),
8460 );
8461
8462 let released = session
8463 .execute_trusted_dynamic_mutation_batch(vec![
8464 DynamicMutation::Delete {
8465 entity: ENTITY_NAME.to_string(),
8466 key: InputValue::nat64(1),
8467 },
8468 DynamicMutation::Insert {
8469 entity: ENTITY_NAME.to_string(),
8470 patch: dynamic_payload_patch(20),
8471 },
8472 ])
8473 .expect("a journaled delete should release its unique value to the final insert");
8474 assert_eq!(
8475 batch_rows(&released),
8476 vec![expected_dynamic_row(1, 20), expected_dynamic_row(3, 20)],
8477 );
8478 assert!(
8479 session
8480 .db
8481 .drive_startup_recovery_page()
8482 .expect("the delete and unique reuse should converge together"),
8483 );
8484
8485 assert_dynamic_payload(&session, 2, 10);
8486 assert_dynamic_payload(&session, 3, 20);
8487 assert_eq!(JOURNALED_INDEX_STORE.with(|store| store.borrow().len()), 2);
8488 assert!(
8489 session
8490 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(20)],)
8491 .is_err(),
8492 "the converged unique index must remain authoritative",
8493 );
8494 }
8495
8496 #[test]
8497 fn journaled_startup_recovery_completes_one_large_batch_atomically() {
8498 let session = initialize_journaled();
8499 let catalog = session
8500 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
8501 .expect("journaled identity catalog should resolve");
8502 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
8503 .expect("journaled identity row layout should build");
8504 let payloads = (0_u64..129).collect::<Vec<_>>();
8505 session
8506 .execute_accepted_structural_save_batch(
8507 &catalog,
8508 &descriptor,
8509 batch(&payloads),
8510 Timestamp::from_millis(9),
8511 Ok,
8512 )
8513 .expect("one large journal batch should commit");
8514
8515 forget_recovered_domain_for_tests(&session.db)
8516 .expect("upgrade should reset recovery ownership");
8517 assert!(
8518 !session
8519 .db
8520 .drive_startup_recovery_page()
8521 .expect("replay should precede folding")
8522 );
8523 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 0);
8524 assert!(
8525 !session
8526 .db
8527 .drive_startup_recovery_page()
8528 .expect("the complete batch recovery page should commit"),
8529 );
8530
8531 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 129);
8532 JOURNALED_TAIL_STORE.with(|tail| {
8533 let tail = tail.borrow();
8534 assert!(!tail.has_stored_batch());
8535 });
8536 assert!(session.db.ensure_recovered_state().is_err());
8537 assert!(
8538 session
8539 .db
8540 .drive_startup_recovery_page()
8541 .expect("verification should finish startup")
8542 );
8543 assert_dynamic_payload(&session, 1, 0);
8544 assert_dynamic_payload(&session, 129, 128);
8545 }
8546
8547 #[test]
8548 fn complete_batch_validation_rejects_a_late_record_before_canonical_writes() {
8549 let session = initialize_journaled();
8550 let catalog = session
8551 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
8552 .expect("journaled identity catalog should resolve");
8553 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
8554 .expect("journaled identity row layout should build");
8555 session
8556 .execute_accepted_structural_save_batch(
8557 &catalog,
8558 &descriptor,
8559 batch(&[7]),
8560 Timestamp::from_millis(9),
8561 Ok,
8562 )
8563 .expect("journal batch predecessor should commit");
8564
8565 JOURNALED_TAIL_STORE.with(|tail| {
8566 let mut tail = tail.borrow_mut();
8567 let original = tail
8568 .next_batch_after(JournalSequence::new(0))
8569 .expect("journal batch should decode")
8570 .expect("journal batch should exist");
8571 let mut records = original.records().to_vec();
8572 records.push(
8573 JournalRecord::schema_put(JOURNALED_STORE_PATH, vec![0xff; 8])
8574 .expect("bounded semantic corruption should build"),
8575 );
8576 let corrupted = JournalBatch::new_with_database_commit_sequence(
8577 original.batch_id(),
8578 original.commit_marker_id(),
8579 original.journal_sequence(),
8580 original.database_commit_sequence(),
8581 records,
8582 )
8583 .expect("current corrupt batch shape should build");
8584 let encoded = encode_journal_batch(&corrupted)
8585 .expect("current corrupt batch envelope should encode");
8586 tail.clear_batches_through(original.journal_sequence());
8587 tail.insert_raw_batch_for_tests(original.journal_sequence(), encoded)
8588 .expect("corrupt persisted batch should replace the predecessor");
8589 });
8590
8591 forget_recovered_domain_for_tests(&session.db)
8592 .expect("upgrade should reset recovery ownership");
8593 assert!(
8594 !session
8595 .db
8596 .drive_startup_recovery_page()
8597 .expect("replay should precede fold validation")
8598 );
8599 let error = session
8600 .db
8601 .drive_startup_recovery_page()
8602 .expect_err("late semantic corruption must fail before fold apply");
8603 assert_eq!(error.class(), ErrorClass::Corruption);
8604 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 0);
8605 JOURNALED_TAIL_STORE.with(|tail| {
8606 let tail = tail.borrow();
8607 assert_eq!(
8608 tail.fold_watermark()
8609 .expect("watermark should remain readable")
8610 .highest_folded_journal_sequence(),
8611 JournalSequence::new(0),
8612 );
8613 assert!(tail.has_stored_batch());
8614 });
8615 }
8616
8617 #[test]
8618 fn prepared_batch_row_evidence_rejects_a_late_malformed_row_before_canonical_writes() {
8619 let session = initialize_journaled();
8620 let catalog = session
8621 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
8622 .expect("journaled identity catalog should resolve");
8623 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
8624 .expect("journaled identity row layout should build");
8625 session
8626 .execute_accepted_structural_save_batch(
8627 &catalog,
8628 &descriptor,
8629 batch(&[7, 8]),
8630 Timestamp::from_millis(9),
8631 Ok,
8632 )
8633 .expect("two-row journal batch should commit");
8634
8635 JOURNALED_TAIL_STORE.with(|tail| {
8636 let mut tail = tail.borrow_mut();
8637 let original = tail
8638 .next_batch_after(JournalSequence::new(0))
8639 .expect("journal batch should decode")
8640 .expect("journal batch should exist");
8641 let mut records = original.records().to_vec();
8642 let mut row_ordinal = 0_u8;
8643 for record in &mut records {
8644 if let JournalRecord::RowPut { row_bytes, .. } = record {
8645 row_ordinal = row_ordinal.saturating_add(1);
8646 if row_ordinal == 2 {
8647 *row_bytes = vec![0xff; 8];
8648 break;
8649 }
8650 }
8651 }
8652 assert_eq!(row_ordinal, 2, "the late row record should be present");
8653 let corrupted = JournalBatch::new_with_database_commit_sequence(
8654 original.batch_id(),
8655 original.commit_marker_id(),
8656 original.journal_sequence(),
8657 original.database_commit_sequence(),
8658 records,
8659 )
8660 .expect("current corrupt batch shape should build");
8661 let encoded = encode_journal_batch(&corrupted)
8662 .expect("current corrupt batch envelope should encode");
8663 tail.clear_batches_through(original.journal_sequence());
8664 tail.insert_raw_batch_for_tests(original.journal_sequence(), encoded)
8665 .expect("corrupt persisted batch should replace the predecessor");
8666 });
8667
8668 forget_recovered_domain_for_tests(&session.db)
8669 .expect("upgrade should reset recovery ownership");
8670 assert!(
8671 !session
8672 .db
8673 .drive_startup_recovery_page()
8674 .expect("replay should precede row preparation")
8675 );
8676 let error = session
8677 .db
8678 .drive_startup_recovery_page()
8679 .expect_err("late malformed row must fail during complete batch preparation");
8680 assert_eq!(error.class(), ErrorClass::Corruption);
8681 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 0);
8682 JOURNALED_TAIL_STORE.with(|tail| {
8683 let tail = tail.borrow();
8684 assert_eq!(
8685 tail.fold_watermark()
8686 .expect("watermark should remain readable")
8687 .highest_folded_journal_sequence(),
8688 JournalSequence::new(0),
8689 );
8690 assert!(tail.has_stored_batch());
8691 });
8692 }
8693
8694 #[test]
8695 fn typed_mutation_batch_recovers_as_one_marker_atomic_transition() {
8696 let session = initialize_journaled();
8697 let binding = exact_key_binding(&session);
8698 session
8699 .execute_trusted_same_entity_typed_mutation_batch(
8700 &binding,
8701 vec![
8702 typed_payload_insert(&binding, 10),
8703 typed_payload_insert(&binding, 20),
8704 ],
8705 )
8706 .expect("typed recovery fixture should commit")
8707 .expect("typed recovery fixture binding should remain current");
8708 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::RowsPublished);
8709
8710 let interrupted = session.execute_trusted_same_entity_typed_mutation_batch(
8711 &binding,
8712 vec![typed_payload_delete(1), typed_payload_insert(&binding, 30)],
8713 );
8714 assert!(
8715 interrupted.is_err(),
8716 "typed batch should expose the selected durable interruption",
8717 );
8718 let pending = session
8719 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
8720 entity: ENTITY_NAME.to_string(),
8721 patch: dynamic_payload_patch(30),
8722 })
8723 .expect_err("ordinary writes must not bypass retained-marker recovery");
8724 assert_eq!(
8725 pending.diagnostic().error_code(),
8726 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
8727 );
8728
8729 drive_journaled_recovery_to_completion(&session);
8730 let recovered = session
8731 .execute_trusted_live_page(&crate::db::DynamicQuery::new(ENTITY_NAME), None)
8732 .expect("the recovered typed batch should be readable");
8733 assert_eq!(
8734 recovered.rows,
8735 vec![expected_dynamic_row(2, 20), expected_dynamic_row(3, 30)],
8736 );
8737 }
8738}
8739
8740#[cfg(test)]
8741mod targeted_rule_mutation_tests {
8742 use super::{
8743 DbSession, DynamicMutation, DynamicStructuralPatch, DynamicTypedMutation, DynamicWriteCell,
8744 TypedEntityDescriptor, TypedFieldType,
8745 };
8746 use crate::{
8747 db::{
8748 TypedFieldDescriptor,
8749 data::{DataStore, encode_input_value_for_candidate_field_contract},
8750 index::IndexStore,
8751 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
8752 schema::{
8753 AcceptedCheckLiteralV1, AcceptedCompositeCatalog, AcceptedFieldDecodeContract,
8754 AcceptedFieldKind, AcceptedNamedTypeIdentity, AcceptedRuleOperation,
8755 AcceptedRuleTarget, AcceptedSchemaRevision, AcceptedSourceBindingCatalog,
8756 ConstraintOrigin, FieldId, FieldStorageDecode, FieldWriteManagement, LeafCodec,
8757 PersistedFieldSnapshot, PersistedNestedLeafSnapshot, PersistedSchemaSnapshot,
8758 ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy, SchemaInsertDefault,
8759 SchemaRowLayout, SchemaStore, SchemaVersion,
8760 accepted_schema_candidate_with_catalogs_for_tests,
8761 build_record_newtype_composite_catalog_for_tests,
8762 empty_accepted_enum_catalog_for_tests, enum_catalog::ValueAdmissionBudget,
8763 },
8764 },
8765 error::InternalError,
8766 traits::{CanisterKind, Path},
8767 types::EntityTag,
8768 value::InputValue,
8769 };
8770 use icydb_schema::{
8771 ConstraintSourceKey, EntitySourceKey, FieldSourceKey, ScalarType, TypeSourceKey,
8772 };
8773 use std::{cell::RefCell, collections::BTreeMap};
8774
8775 const STORE_PATH: &str = "session::write::targeted_rule_mutation_tests::Store";
8776 const ENTITY_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity";
8777 const ID_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::id";
8778 const PROFILE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::profile";
8779 const UPDATED_AT_SOURCE: &str =
8780 "session::write::targeted_rule_mutation_tests::Entity::updated_at";
8781 const PROFILE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Profile";
8782 const DEGREE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Degree";
8783 const DEGREE_MEMBER_SOURCE: &str =
8784 "session::write::targeted_rule_mutation_tests::Profile::degree";
8785 const DEGREE_RULE_SOURCE: &str =
8786 "session::write::targeted_rule_mutation_tests::Profile::degree_multiple";
8787 const TYPED_DESCRIPTOR: TypedEntityDescriptor = TypedEntityDescriptor::new(
8788 ENTITY_SOURCE,
8789 &[ID_SOURCE],
8790 &[
8791 TypedFieldDescriptor::new(ID_SOURCE, TypedFieldType::Scalar(ScalarType::Nat64), false),
8792 TypedFieldDescriptor::new(
8793 PROFILE_SOURCE,
8794 TypedFieldType::Named(PROFILE_TYPE_SOURCE),
8795 false,
8796 ),
8797 TypedFieldDescriptor::new(
8798 UPDATED_AT_SOURCE,
8799 TypedFieldType::Scalar(ScalarType::Timestamp),
8800 false,
8801 ),
8802 ],
8803 );
8804
8805 struct TestCanister;
8806
8807 impl Path for TestCanister {
8808 const PATH: &'static str = "session::write::targeted_rule_mutation_tests::Canister";
8809 }
8810
8811 impl CanisterKind for TestCanister {
8812 const COMMIT_MEMORY_ID: u8 = 43;
8813 const COMMIT_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.commit.v1";
8814 const STARTUP_MEMORY_ID: u8 = 49;
8815 const STARTUP_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.startup.control.v1";
8816 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 44;
8817 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
8818 "icydb.targeted_mutation_tests.integrity.progress.v1";
8819 }
8820
8821 thread_local! {
8822 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
8823 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
8824 static SCHEMA_STORE: RefCell<SchemaStore> =
8825 const { RefCell::new(SchemaStore::init_heap()) };
8826 static STORE_REGISTRY: StoreRegistry = {
8827 let mut registry = StoreRegistry::new();
8828 registry.register_store(
8829 STORE_PATH,
8830 &DATA_STORE,
8831 &INDEX_STORE,
8832 &SCHEMA_STORE,
8833 StoreAllocationIdentities::absent(),
8834 StoreRuntimeStorageCapabilities::heap(),
8835 ).expect("targeted mutation test store should register");
8836 registry
8837 };
8838 }
8839
8840 fn source<T, E: std::fmt::Debug>(raw: &str, parse: impl FnOnce(String) -> Result<T, E>) -> T {
8841 parse(raw.to_string()).expect("test source identity should admit")
8842 }
8843
8844 fn profile_input(degree: u64) -> InputValue {
8845 InputValue::map(vec![(
8846 InputValue::from("degree"),
8847 InputValue::nat64(degree),
8848 )])
8849 }
8850
8851 fn structural_patch(id: u64, degree: u64) -> DynamicStructuralPatch {
8852 DynamicStructuralPatch::new(vec![
8853 (
8854 "id".to_string(),
8855 DynamicWriteCell::Value(InputValue::nat64(id)),
8856 ),
8857 (
8858 "profile".to_string(),
8859 DynamicWriteCell::Value(profile_input(degree)),
8860 ),
8861 ])
8862 }
8863
8864 fn encoded_value(
8865 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
8866 composite_catalog: &AcceptedCompositeCatalog,
8867 name: &str,
8868 kind: &AcceptedFieldKind,
8869 storage_decode: FieldStorageDecode,
8870 leaf_codec: LeafCodec,
8871 value: InputValue,
8872 ) -> Vec<u8> {
8873 let field = AcceptedFieldDecodeContract::new(name, kind, false, storage_decode, leaf_codec);
8874 encode_input_value_for_candidate_field_contract(
8875 enum_catalog,
8876 composite_catalog,
8877 field,
8878 value,
8879 &mut ValueAdmissionBudget::standard(),
8880 )
8881 .expect("test accepted value should encode")
8882 }
8883
8884 fn nat64_literal(
8885 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
8886 composite_catalog: &AcceptedCompositeCatalog,
8887 value: u64,
8888 ) -> AcceptedCheckLiteralV1 {
8889 let kind = AcceptedFieldKind::Nat64;
8890 AcceptedCheckLiteralV1::from_accepted_parts(
8891 kind.clone(),
8892 FieldStorageDecode::ByKind,
8893 LeafCodec::Scalar(ScalarCodec::Nat64),
8894 encoded_value(
8895 enum_catalog,
8896 composite_catalog,
8897 "degree_bound",
8898 &kind,
8899 FieldStorageDecode::ByKind,
8900 LeafCodec::Scalar(ScalarCodec::Nat64),
8901 InputValue::nat64(value),
8902 ),
8903 )
8904 }
8905
8906 fn targeted_constraint_id(error: &InternalError) -> u32 {
8907 let facts = error.diagnostic_facts();
8908 assert!(facts.contains(&(
8909 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
8910 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
8911 )));
8912 assert!(facts.contains(&(icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,)));
8913 assert!(facts.contains(&(
8914 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
8915 icydb_diagnostic_code::DiagnosticConstraintKind::TargetedRule.raw(),
8916 )));
8917 assert_eq!(
8918 facts
8919 .iter()
8920 .filter(|(tag, _)| matches!(
8921 tag,
8922 icydb_diagnostic_code::DiagnosticFactTag::RootField
8923 | icydb_diagnostic_code::DiagnosticFactTag::RecordMember
8924 ))
8925 .copied()
8926 .collect::<Vec<_>>(),
8927 vec![
8928 (icydb_diagnostic_code::DiagnosticFactTag::RootField, 2),
8929 (
8930 icydb_diagnostic_code::DiagnosticFactTag::RecordMember,
8931 icydb_diagnostic_code::pack_u32_pair(1, 1),
8932 ),
8933 ]
8934 );
8935 let value = facts
8936 .iter()
8937 .find_map(|(tag, value)| {
8938 (*tag == icydb_diagnostic_code::DiagnosticFactTag::ConstraintId).then_some(*value)
8939 })
8940 .expect("targeted mutation should retain its accepted constraint ID");
8941 u32::try_from(value).expect("accepted constraint ID fits u32")
8942 }
8943
8944 #[expect(
8945 clippy::too_many_lines,
8946 reason = "one end-to-end fixture proves every maintained write frontend converges on the same accepted targeted-rule schedule"
8947 )]
8948 #[test]
8949 fn targeted_rules_converge_across_dynamic_typed_sql_default_timestamp_and_batch_writes() {
8950 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
8951 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
8952 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
8953
8954 let entity_tag = EntityTag::new(93);
8955 let enum_catalog = empty_accepted_enum_catalog_for_tests();
8956 let (composite_catalog, profile_type, degree_type, degree_member) =
8957 build_record_newtype_composite_catalog_for_tests(
8958 "tests::TargetedProfile".to_string(),
8959 "degree".to_string(),
8960 "tests::TargetedDegree".to_string(),
8961 AcceptedFieldKind::Nat64,
8962 &enum_catalog,
8963 )
8964 .expect("targeted mutation composites should close");
8965 let profile_kind = AcceptedFieldKind::Composite {
8966 type_id: profile_type,
8967 };
8968 let profile_default = encoded_value(
8969 &enum_catalog,
8970 &composite_catalog,
8971 "profile",
8972 &profile_kind,
8973 FieldStorageDecode::CatalogValue,
8974 LeafCodec::Structural,
8975 profile_input(12),
8976 );
8977 let fields = vec![
8978 PersistedFieldSnapshot::new_initial(
8979 FieldId::new(1),
8980 "id".to_string(),
8981 SchemaFieldSlot::new(0),
8982 AcceptedFieldKind::Nat64,
8983 Vec::new(),
8984 false,
8985 SchemaInsertDefault::None,
8986 FieldStorageDecode::ByKind,
8987 LeafCodec::Scalar(ScalarCodec::Nat64),
8988 ),
8989 PersistedFieldSnapshot::new_initial(
8990 FieldId::new(2),
8991 "profile".to_string(),
8992 SchemaFieldSlot::new(1),
8993 profile_kind,
8994 vec![PersistedNestedLeafSnapshot::new(
8995 vec!["degree".to_string()],
8996 AcceptedFieldKind::Composite {
8997 type_id: degree_type,
8998 },
8999 false,
9000 )],
9001 false,
9002 SchemaInsertDefault::SlotPayload(profile_default),
9003 FieldStorageDecode::CatalogValue,
9004 LeafCodec::Structural,
9005 ),
9006 PersistedFieldSnapshot::new_initial_with_write_policy(
9007 FieldId::new(3),
9008 "updated_at".to_string(),
9009 SchemaFieldSlot::new(2),
9010 AcceptedFieldKind::Timestamp,
9011 Vec::new(),
9012 false,
9013 SchemaInsertDefault::None,
9014 SchemaFieldWritePolicy::from_model_policies(
9015 None,
9016 Some(FieldWriteManagement::UpdatedAt),
9017 ),
9018 FieldStorageDecode::ByKind,
9019 LeafCodec::Scalar(ScalarCodec::Timestamp),
9020 ),
9021 ];
9022 let mut snapshot = PersistedSchemaSnapshot::new(
9023 SchemaVersion::initial(),
9024 ENTITY_SOURCE.to_string(),
9025 "TargetedMutation".to_string(),
9026 FieldId::new(1),
9027 SchemaRowLayout::initial(
9028 fields
9029 .iter()
9030 .map(|field| (field.id(), field.slot()))
9031 .collect(),
9032 ),
9033 fields,
9034 );
9035 let constraint_catalog = snapshot
9036 .constraint_catalog()
9037 .clone()
9038 .with_added_targeted_rule(
9039 "profile_degree_multiple".to_string(),
9040 ConstraintOrigin::Generated,
9041 AcceptedRuleTarget::new(
9042 FieldId::new(2),
9043 AcceptedNamedTypeIdentity::Composite(degree_type),
9044 ),
9045 AcceptedRuleOperation::MultipleOf {
9046 divisor: nat64_literal(&enum_catalog, &composite_catalog, 5),
9047 },
9048 )
9049 .expect("targeted mutation rule should allocate");
9050 let targeted_rule_id = constraint_catalog
9051 .constraints()
9052 .last()
9053 .expect("targeted mutation rule should persist")
9054 .id();
9055 snapshot = snapshot.with_constraint_catalog(constraint_catalog);
9056
9057 let entity_source = source(ENTITY_SOURCE, EntitySourceKey::try_new);
9058 let id_source = source(ID_SOURCE, FieldSourceKey::try_new);
9059 let profile_source = source(PROFILE_SOURCE, FieldSourceKey::try_new);
9060 let updated_at_source = source(UPDATED_AT_SOURCE, FieldSourceKey::try_new);
9061 let profile_type_source = source(PROFILE_TYPE_SOURCE, TypeSourceKey::try_new);
9062 let degree_type_source = source(DEGREE_TYPE_SOURCE, TypeSourceKey::try_new);
9063 let degree_member_source = source(DEGREE_MEMBER_SOURCE, FieldSourceKey::try_new);
9064 let degree_rule_source = source(DEGREE_RULE_SOURCE, ConstraintSourceKey::try_new);
9065 let source_bindings = AcceptedSourceBindingCatalog::initial_for_tests(
9066 BTreeMap::from([(entity_source, entity_tag)]),
9067 BTreeMap::from([
9068 ((entity_tag, id_source), FieldId::new(1)),
9069 ((entity_tag, profile_source), FieldId::new(2)),
9070 ((entity_tag, updated_at_source), FieldId::new(3)),
9071 ]),
9072 BTreeMap::from([((entity_tag, degree_rule_source), targeted_rule_id)]),
9073 BTreeMap::new(),
9074 BTreeMap::new(),
9075 )
9076 .with_initial_named_types_for_tests(
9077 BTreeMap::from([
9078 (
9079 profile_type_source,
9080 AcceptedNamedTypeIdentity::Composite(profile_type),
9081 ),
9082 (
9083 degree_type_source,
9084 AcceptedNamedTypeIdentity::Composite(degree_type),
9085 ),
9086 ]),
9087 BTreeMap::new(),
9088 BTreeMap::from([((profile_type, degree_member_source), degree_member)]),
9089 );
9090 let candidate = accepted_schema_candidate_with_catalogs_for_tests(
9091 STORE_PATH,
9092 AcceptedSchemaRevision::INITIAL,
9093 enum_catalog,
9094 composite_catalog,
9095 source_bindings,
9096 BTreeMap::from([(entity_tag, snapshot)]),
9097 );
9098
9099 let session = DbSession::<TestCanister>::new(
9100 &STORE_REGISTRY,
9101 &crate::db::RequestExecutionRoot::__new_runtime_root(),
9102 );
9103 session
9104 .db
9105 .drive_startup_recovery_page()
9106 .expect("targeted mutation test database should initialize");
9107 let store = session
9108 .db
9109 .store_handle(STORE_PATH)
9110 .expect("targeted mutation test store should resolve");
9111 crate::db::commit::publish_accepted_schema_candidate(
9112 STORE_PATH,
9113 store,
9114 AcceptedSchemaRevision::NONE,
9115 &candidate,
9116 )
9117 .expect("targeted mutation candidate should publish");
9118
9119 let dynamic_error = session
9120 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
9121 entity: "TargetedMutation".to_string(),
9122 patch: structural_patch(1, 12),
9123 })
9124 .expect_err("dynamic write must enforce the targeted rule");
9125 assert_eq!(
9126 targeted_constraint_id(&dynamic_error),
9127 targeted_rule_id.get()
9128 );
9129
9130 let binding = session
9131 .issue_typed_entity_binding(&TYPED_DESCRIPTOR)
9132 .expect("targeted typed binding should issue");
9133 let typed_patch = binding
9134 .bind_write_ordinals(vec![
9135 (0, DynamicWriteCell::Value(InputValue::nat64(2))),
9136 (1, DynamicWriteCell::Value(profile_input(12))),
9137 ])
9138 .expect("targeted typed patch should bind");
9139 let typed_error = session
9140 .execute_trusted_typed_mutation(
9141 &binding,
9142 DynamicTypedMutation::Insert { patch: typed_patch },
9143 )
9144 .expect_err("typed write must enforce the targeted rule");
9145 assert_eq!(targeted_constraint_id(&typed_error), targeted_rule_id.get());
9146
9147 #[cfg(feature = "sql")]
9148 {
9149 let sql_error = session
9150 .execute_trusted_sql_mutation("INSERT INTO TargetedMutation (id) VALUES (3)")
9151 .expect_err("SQL default resolution must enforce the targeted rule");
9152 let crate::db::QueryError::Execute(execute) = sql_error else {
9153 panic!("targeted SQL write should fail at shared execution admission");
9154 };
9155 assert_eq!(
9156 targeted_constraint_id(execute.as_internal()),
9157 targeted_rule_id.get()
9158 );
9159 }
9160
9161 session
9162 .execute_trusted_dynamic_mutation_batch(vec![
9163 DynamicMutation::Insert {
9164 entity: "TargetedMutation".to_string(),
9165 patch: structural_patch(4, 5),
9166 },
9167 DynamicMutation::Insert {
9168 entity: "TargetedMutation".to_string(),
9169 patch: structural_patch(5, 12),
9170 },
9171 ])
9172 .expect_err("one invalid targeted value must reject the whole batch");
9173 assert_eq!(
9174 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
9175 Some(0),
9176 "no frontend or earlier valid batch row may escape targeted admission",
9177 );
9178
9179 let admitted = session
9180 .execute_trusted_dynamic_mutation_batch(vec![
9181 DynamicMutation::Insert {
9182 entity: "TargetedMutation".to_string(),
9183 patch: structural_patch(6, 5),
9184 },
9185 DynamicMutation::Insert {
9186 entity: "TargetedMutation".to_string(),
9187 patch: structural_patch(7, 10),
9188 },
9189 ])
9190 .expect("compliant targeted values should share one accepted batch");
9191 let admitted_rows = admitted
9192 .iter()
9193 .flat_map(|result| result.rows.iter())
9194 .collect::<Vec<_>>();
9195 let [first, second] = admitted_rows.as_slice() else {
9196 panic!("the mixed targeted batch should return two rows");
9197 };
9198 let first_timestamp = first
9199 .get(2)
9200 .expect("the first mixed row should contain its managed timestamp");
9201 assert!(matches!(
9202 first_timestamp.as_public(),
9203 crate::value::PublicValue::Timestamp(_)
9204 ));
9205 assert_eq!(
9206 second.get(2),
9207 Some(first_timestamp),
9208 "one accepted mixed batch must materialize one managed timestamp",
9209 );
9210 assert_eq!(
9211 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
9212 Some(2),
9213 );
9214 }
9215}