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