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