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::{
3027 JournalBatch, JournalRecord, JournalSequence, JournalTailControl, JournalTailStore,
3028 encode_journal_batch,
3029 },
3030 registry::{
3031 StoreAllocationIdentities, StoreAllocationIdentity, StoreRegistry,
3032 StoreRuntimeStorageCapabilities,
3033 },
3034 schema::{
3035 AcceptedFieldKind, AcceptedSchemaRevision, FieldId, FieldInsertGeneration,
3036 FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
3037 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
3038 PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy,
3039 SchemaIndexId, SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
3040 accepted_schema_candidate_with_field_bindings_for_tests,
3041 },
3042 write_context::MutationMode,
3043 },
3044 error::{ErrorClass, ErrorOrigin, InternalError},
3045 testing::test_memory,
3046 traits::{CanisterKind, Path},
3047 types::{EntityTag, Timestamp},
3048 value::{InputValue, OutputValue, Value},
3049 };
3050 use icydb_schema::{FieldSourceKey, ScalarType};
3051 use std::{
3052 cell::{Cell, RefCell},
3053 collections::BTreeMap,
3054 time::Instant,
3055 };
3056
3057 const STORE_PATH: &str = "session::write::identity_pre_key_tests::Store";
3058 const ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::Entity";
3059 const ID_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::id";
3060 const PAYLOAD_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::payload";
3061 const ENTITY_NAME: &str = "IdentityRow";
3062 const ENTITY_TAG: EntityTag = EntityTag::new(93);
3063 const JOURNALED_STORE_PATH: &str = "session::write::identity_pre_key_tests::JournaledStore";
3064 const UNRELATED_STORE_PATH: &str = "session::write::identity_pre_key_tests::UnrelatedStore";
3065
3066 struct TestCanister;
3067
3068 impl Path for TestCanister {
3069 const PATH: &'static str = "session::write::identity_pre_key_tests::Canister";
3070 }
3071
3072 impl CanisterKind for TestCanister {
3073 const COMMIT_MEMORY_ID: u8 = 45;
3074 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.commit.v1";
3075 const STARTUP_MEMORY_ID: u8 = 49;
3076 const STARTUP_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.startup.control.v1";
3077 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 46;
3078 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3079 "icydb.identity_pre_key_tests.integrity.progress.v1";
3080 }
3081
3082 thread_local! {
3083 static STARTUP_WAKEUPS: Cell<u32> = const { Cell::new(0) };
3084 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
3085 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
3086 static SCHEMA_STORE: RefCell<SchemaStore> =
3087 const { RefCell::new(SchemaStore::init_heap()) };
3088 static UNRELATED_DATA_STORE: RefCell<DataStore> =
3089 const { RefCell::new(DataStore::init_heap()) };
3090 static UNRELATED_INDEX_STORE: RefCell<IndexStore> =
3091 const { RefCell::new(IndexStore::init_heap()) };
3092 static UNRELATED_SCHEMA_STORE: RefCell<SchemaStore> =
3093 const { RefCell::new(SchemaStore::init_heap()) };
3094 static STORE_REGISTRY: StoreRegistry = {
3095 let mut registry = StoreRegistry::new();
3096 registry.register_store(
3097 STORE_PATH,
3098 &DATA_STORE,
3099 &INDEX_STORE,
3100 &SCHEMA_STORE,
3101 StoreAllocationIdentities::absent(),
3102 StoreRuntimeStorageCapabilities::heap(),
3103 ).expect("identity pre-key test store should register");
3104 registry.register_store(
3105 UNRELATED_STORE_PATH,
3106 &UNRELATED_DATA_STORE,
3107 &UNRELATED_INDEX_STORE,
3108 &UNRELATED_SCHEMA_STORE,
3109 StoreAllocationIdentities::absent(),
3110 StoreRuntimeStorageCapabilities::heap(),
3111 ).expect("unrelated identity test store should register");
3112 registry
3113 };
3114 static JOURNALED_DATA_STORE: RefCell<DataStore> =
3115 RefCell::new(DataStore::init_journaled(test_memory(186)));
3116 static JOURNALED_INDEX_STORE: RefCell<IndexStore> =
3117 RefCell::new(IndexStore::init_journaled(test_memory(187)));
3118 static JOURNALED_SCHEMA_STORE: RefCell<SchemaStore> =
3119 RefCell::new(SchemaStore::init_journaled(test_memory(188)));
3120 static JOURNALED_TAIL_STORE: RefCell<JournalTailStore> =
3121 RefCell::new(JournalTailStore::init(test_memory(189)));
3122 static JOURNALED_STORE_REGISTRY: StoreRegistry = {
3123 let mut registry = StoreRegistry::new();
3124 registry.register_journaled_store(
3125 JOURNALED_STORE_PATH,
3126 &JOURNALED_DATA_STORE,
3127 &JOURNALED_INDEX_STORE,
3128 &JOURNALED_SCHEMA_STORE,
3129 &JOURNALED_TAIL_STORE,
3130 StoreAllocationIdentities::new_journaled(
3131 StoreAllocationIdentity::new(186, "icydb.test.identity_range.data.v1"),
3132 StoreAllocationIdentity::new(187, "icydb.test.identity_range.index.v1"),
3133 StoreAllocationIdentity::new(188, "icydb.test.identity_range.schema.v1"),
3134 StoreAllocationIdentity::new(189, "icydb.test.identity_range.journal.v1"),
3135 ),
3136 StoreRuntimeStorageCapabilities::journaled(),
3137 ).expect("identity range journaled store should register");
3138 registry
3139 };
3140 }
3141
3142 fn record_startup_wakeup() {
3143 STARTUP_WAKEUPS.with(|wakeups| wakeups.set(wakeups.get().saturating_add(1)));
3144 }
3145
3146 struct JournaledTestCanister;
3147
3148 impl Path for JournaledTestCanister {
3149 const PATH: &'static str = "session::write::identity_pre_key_tests::JournaledCanister";
3150 }
3151
3152 impl CanisterKind for JournaledTestCanister {
3153 const COMMIT_MEMORY_ID: u8 = 190;
3154 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_range_tests.commit.v1";
3155 const STARTUP_MEMORY_ID: u8 = 192;
3156 const STARTUP_STABLE_KEY: &'static str = "icydb.identity_range_tests.startup.control.v1";
3157 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 191;
3158 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3159 "icydb.identity_range_tests.integrity.progress.v1";
3160 }
3161
3162 fn source_key(source: &str) -> FieldSourceKey {
3163 FieldSourceKey::try_new(source).expect("identity test field source should admit")
3164 }
3165
3166 fn identity_snapshot(store_path: &str, payload_unique: bool) -> PersistedSchemaSnapshot {
3167 let fields = vec![
3168 PersistedFieldSnapshot::new_initial_with_write_policy(
3169 FieldId::new(1),
3170 "id".to_string(),
3171 SchemaFieldSlot::new(0),
3172 AcceptedFieldKind::Nat64,
3173 Vec::new(),
3174 false,
3175 SchemaInsertDefault::None,
3176 SchemaFieldWritePolicy::from_model_policies(
3177 Some(FieldInsertGeneration::Identity),
3178 None,
3179 ),
3180 FieldStorageDecode::ByKind,
3181 LeafCodec::Scalar(ScalarCodec::Nat64),
3182 ),
3183 PersistedFieldSnapshot::new_initial(
3184 FieldId::new(2),
3185 "payload".to_string(),
3186 SchemaFieldSlot::new(1),
3187 AcceptedFieldKind::Nat64,
3188 Vec::new(),
3189 false,
3190 SchemaInsertDefault::None,
3191 FieldStorageDecode::ByKind,
3192 LeafCodec::Scalar(ScalarCodec::Nat64),
3193 ),
3194 ];
3195 PersistedSchemaSnapshot::new_with_indexes(
3196 SchemaVersion::initial(),
3197 ENTITY_SOURCE.to_string(),
3198 ENTITY_NAME.to_string(),
3199 FieldId::new(1),
3200 SchemaRowLayout::initial(
3201 fields
3202 .iter()
3203 .map(|field| (field.id(), field.slot()))
3204 .collect(),
3205 ),
3206 fields,
3207 vec![PersistedIndexSnapshot::new(
3208 SchemaIndexId::new(1).expect("identity test index ID should admit"),
3209 1,
3210 "by_payload".to_string(),
3211 store_path.to_string(),
3212 payload_unique,
3213 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
3214 FieldId::new(2),
3215 SchemaFieldSlot::new(1),
3216 vec!["payload".to_string()],
3217 AcceptedFieldKind::Nat64,
3218 false,
3219 )]),
3220 None,
3221 )],
3222 )
3223 }
3224
3225 fn initialize() -> DbSession<TestCanister> {
3226 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3227 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3228 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3229 UNRELATED_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3230 UNRELATED_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3231 UNRELATED_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3232 let session = DbSession::<TestCanister>::new(
3233 &STORE_REGISTRY,
3234 &crate::db::RequestExecutionRoot::__new_runtime_root(),
3235 );
3236 session
3237 .db
3238 .drive_startup_recovery_page()
3239 .expect("identity pre-key test database should initialize");
3240 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3241 STORE_PATH,
3242 AcceptedSchemaRevision::INITIAL,
3243 BTreeMap::from([(ENTITY_TAG, identity_snapshot(STORE_PATH, false))]),
3244 BTreeMap::from([
3245 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
3246 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
3247 ]),
3248 );
3249 let store = session
3250 .db
3251 .store_handle(STORE_PATH)
3252 .expect("identity pre-key test store should resolve");
3253 crate::db::commit::publish_accepted_schema_candidate(
3254 STORE_PATH,
3255 store,
3256 AcceptedSchemaRevision::NONE,
3257 &candidate,
3258 )
3259 .expect("identity candidate should publish with explicit zero state");
3260 session
3261 }
3262
3263 fn initialize_journaled_with_root_and_payload_uniqueness(
3264 payload_unique: bool,
3265 ) -> (
3266 DbSession<JournaledTestCanister>,
3267 crate::db::RequestExecutionRoot,
3268 ) {
3269 let root = crate::db::RequestExecutionRoot::__new_runtime_root();
3270 let session = DbSession::<JournaledTestCanister>::new(&JOURNALED_STORE_REGISTRY, &root);
3271 session
3272 .db
3273 .drive_startup_recovery_page()
3274 .expect("journaled identity database should initialize");
3275 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3276 JOURNALED_STORE_PATH,
3277 AcceptedSchemaRevision::INITIAL,
3278 BTreeMap::from([(
3279 ENTITY_TAG,
3280 identity_snapshot(JOURNALED_STORE_PATH, payload_unique),
3281 )]),
3282 BTreeMap::from([
3283 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
3284 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
3285 ]),
3286 );
3287 let store = session
3288 .db
3289 .store_handle(JOURNALED_STORE_PATH)
3290 .expect("journaled identity store should resolve");
3291 crate::db::commit::publish_accepted_schema_candidate(
3292 JOURNALED_STORE_PATH,
3293 store,
3294 AcceptedSchemaRevision::NONE,
3295 &candidate,
3296 )
3297 .expect("journaled identity candidate should publish");
3298 (session, root)
3299 }
3300
3301 fn initialize_journaled_with_root() -> (
3302 DbSession<JournaledTestCanister>,
3303 crate::db::RequestExecutionRoot,
3304 ) {
3305 initialize_journaled_with_root_and_payload_uniqueness(false)
3306 }
3307
3308 fn initialize_journaled() -> DbSession<JournaledTestCanister> {
3309 initialize_journaled_with_root().0
3310 }
3311
3312 fn initialize_journaled_with_unique_payload() -> DbSession<JournaledTestCanister> {
3313 initialize_journaled_with_root_and_payload_uniqueness(true).0
3314 }
3315
3316 fn drive_journaled_recovery_to_completion(session: &DbSession<JournaledTestCanister>) {
3317 for _ in 0..8 {
3318 if session
3319 .db
3320 .drive_startup_recovery_page()
3321 .expect("dedicated driver recovery should remain valid")
3322 {
3323 return;
3324 }
3325 }
3326 panic!("dedicated driver recovery should quiesce within eight complete batches");
3327 }
3328
3329 fn payload_patch(value: u64) -> AcceptedMutationIntentPatch {
3330 AcceptedMutationIntentPatch::new()
3331 .set_authored(FieldSlot::from_validated_index(1), InputValue::Nat64(value))
3332 }
3333
3334 fn dynamic_payload_patch(value: u64) -> DynamicStructuralPatch {
3335 DynamicStructuralPatch::new(vec![(
3336 "payload".to_string(),
3337 DynamicWriteCell::Value(InputValue::Nat64(value)),
3338 )])
3339 }
3340
3341 fn expected_dynamic_row(id: u64, payload: u64) -> Vec<OutputValue> {
3342 vec![OutputValue::Nat64(id), OutputValue::Nat64(payload)]
3343 }
3344
3345 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3346 fn exact_key_binding<C: CanisterKind>(session: &DbSession<C>) -> DynamicTypedEntityBinding {
3347 session
3348 .issue_typed_entity_binding(
3349 ENTITY_SOURCE,
3350 &[
3351 DynamicTypedFieldBindingRequest::new(
3352 ID_SOURCE.to_string(),
3353 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3354 false,
3355 ),
3356 DynamicTypedFieldBindingRequest::new(
3357 PAYLOAD_SOURCE.to_string(),
3358 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3359 false,
3360 ),
3361 ],
3362 )
3363 .expect("exact-key test binding should issue")
3364 }
3365
3366 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3367 fn insert_exact_key_fixture<C: CanisterKind>(session: &DbSession<C>, payload: u64) -> u64 {
3368 let output = session
3369 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
3370 entity: ENTITY_NAME.to_string(),
3371 patch: dynamic_payload_patch(payload),
3372 })
3373 .expect("exact-key fixture insert should commit");
3374 match output.rows.as_slice() {
3375 [row] => match row.as_slice() {
3376 [OutputValue::Nat64(id), OutputValue::Nat64(actual_payload)]
3377 if *actual_payload == payload =>
3378 {
3379 *id
3380 }
3381 _ => panic!("exact-key fixture should return its identity and payload"),
3382 },
3383 _ => panic!("exact-key fixture insert should return one row"),
3384 }
3385 }
3386
3387 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3388 fn identity_row_stored_bytes<C: CanisterKind>(
3389 session: &DbSession<C>,
3390 store_path: &'static str,
3391 key: u64,
3392 ) -> u64 {
3393 let data_key = DecodedDataStoreKey::try_from_structural_key(ENTITY_TAG, &Value::Nat64(key))
3394 .expect("identity row key should encode");
3395 let raw_key = data_key.to_raw().expect("identity raw key should encode");
3396 let store = session
3397 .db
3398 .recovered_store(store_path)
3399 .expect("identity store should resolve");
3400 store.with_data(|data_store| {
3401 u64::try_from(
3402 data_store
3403 .get(&raw_key)
3404 .expect("inserted identity row should exist")
3405 .len(),
3406 )
3407 .expect("bounded row length should fit u64")
3408 })
3409 }
3410
3411 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3412 fn with_stored_bytes_limit<T>(
3413 limit: u64,
3414 shape_fingerprint_prefix: u64,
3415 operation: impl FnOnce() -> Result<T, crate::db::query::intent::QueryError>,
3416 ) -> Result<T, crate::db::query::intent::QueryError> {
3417 let budget = HardExecutionBudget::uniform_for_tests(
3418 u64::MAX,
3419 HardExecutionFailureHeadroom::new(500, 256),
3420 )
3421 .with_limit_for_tests(
3422 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::StoredBytesRead,
3423 limit,
3424 );
3425 let context = HardExecutionContext::new(
3426 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
3427 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
3428 shape_fingerprint_prefix,
3429 );
3430
3431 with_query_execution_budget_for_tests(budget, context, operation)
3432 }
3433
3434 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3435 fn assert_exact_key_batch<C: CanisterKind>(session: &DbSession<C>) {
3436 let first = insert_exact_key_fixture(session, 41);
3437 let second = insert_exact_key_fixture(session, 42);
3438 let missing = u64::MAX;
3439 let binding = exact_key_binding(session);
3440 let gets_before = DataStore::current_get_call_count();
3441 let result = session
3442 .execute_public_exact_key_batch_for_typed_binding(
3443 &binding,
3444 &[second, missing, first, second],
3445 )
3446 .expect("exact-key batch should execute")
3447 .expect("exact-key binding should remain current");
3448
3449 assert_eq!(result.positions, vec![0, 1, 2, 0]);
3450 assert_eq!(
3451 result.distinct_rows,
3452 vec![
3453 Some(expected_dynamic_row(second, 42)),
3454 None,
3455 Some(expected_dynamic_row(first, 41)),
3456 ],
3457 );
3458 assert_eq!(
3459 DataStore::current_get_call_count().saturating_sub(gets_before),
3460 3,
3461 "four input positions with one duplicate must perform three physical reads",
3462 );
3463 }
3464
3465 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3466 #[test]
3467 fn exact_key_batches_preserve_semantics_across_heap_and_journaled_stores() {
3468 assert_exact_key_batch(&initialize());
3469 assert_exact_key_batch(&initialize_journaled());
3470 }
3471
3472 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3473 fn assert_primary_range_materialization_fetches_once<C: CanisterKind>(
3474 session: &DbSession<C>,
3475 store_path: &'static str,
3476 ) {
3477 let key = insert_exact_key_fixture(session, 41);
3478 let stored_bytes = identity_row_stored_bytes(session, store_path, key);
3479
3480 let scalar = DynamicQuery::new(ENTITY_NAME)
3481 .select(["id", "payload"])
3482 .order_by(asc("id"))
3483 .limit(1);
3484 let gets_before = DataStore::current_get_call_count();
3485 let scalar_page = with_stored_bytes_limit(stored_bytes, 0x7072_696d_6172_792d, || {
3486 session.execute_trusted_live_page(&scalar, None)
3487 })
3488 .expect("one scalar primary-range row should fit one payload-read allowance");
3489 assert_eq!(scalar_page.row_count, 1);
3490 assert_eq!(
3491 DataStore::current_get_call_count().saturating_sub(gets_before),
3492 1,
3493 "scalar primary traversal should fetch its emitted row exactly once",
3494 );
3495
3496 let grouped = DynamicQuery::new(ENTITY_NAME)
3497 .group_by("payload")
3498 .aggregate(crate::db::count())
3499 .grouped_limits(10, 16 * 1_024)
3500 .limit(1);
3501 let gets_before = DataStore::current_get_call_count();
3502 let grouped_page = with_stored_bytes_limit(stored_bytes, 0x6772_6f75_7065_642d, || {
3503 session.execute_trusted_dynamic_grouped_query(&grouped)
3504 })
3505 .expect("one grouped primary-range row should fit one payload-read allowance");
3506 assert_eq!(grouped_page.row_count, 1);
3507 assert_eq!(
3508 DataStore::current_get_call_count().saturating_sub(gets_before),
3509 1,
3510 "grouped primary traversal should fetch its source row exactly once",
3511 );
3512 }
3513
3514 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3515 #[test]
3516 fn row_materialization_fetches_each_required_payload_at_most_once() {
3517 assert_primary_range_materialization_fetches_once(&initialize(), STORE_PATH);
3518 assert_primary_range_materialization_fetches_once(
3519 &initialize_journaled(),
3520 JOURNALED_STORE_PATH,
3521 );
3522 }
3523
3524 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3525 #[test]
3526 fn ordered_grouped_pages_close_a_group_spanning_physical_refills_before_resume() {
3527 let session = initialize();
3528 let mut patches = Vec::new();
3529 for _ in 0..70 {
3530 patches.push(dynamic_payload_patch(10));
3531 }
3532 for _ in 0..3 {
3533 patches.push(dynamic_payload_patch(20));
3534 }
3535 patches.push(dynamic_payload_patch(30));
3536 let inserted = session
3537 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, patches)
3538 .expect("ordered grouped continuation rows should insert");
3539 assert_eq!(inserted.rows.len(), 74);
3540
3541 let query = DynamicQuery::new(ENTITY_NAME)
3542 .group_by("payload")
3543 .aggregate(crate::db::count())
3544 .aggregate(crate::db::sum("id"))
3545 .order_by(asc("payload"))
3546 .grouped_limits(4, 16 * 1_024)
3547 .limit(1);
3548 let expected = [
3549 (10_u64, 70_u64, crate::types::Decimal::new(2_485, 0)),
3550 (20, 3, crate::types::Decimal::new(216, 0)),
3551 (30, 1, crate::types::Decimal::new(74, 0)),
3552 ];
3553 let mut continuation: Option<String> = None;
3554 let mut seen_cursors = std::collections::BTreeSet::new();
3555
3556 for (page_index, (group_key, row_count, id_sum)) in expected.into_iter().enumerate() {
3557 let request = continuation.as_ref().map_or_else(
3558 || query.clone(),
3559 |cursor| query.clone().cursor(cursor.clone()),
3560 );
3561 let entries_before = IndexStore::current_entry_read_count();
3562 let rows_before = DataStore::current_get_call_count();
3563 let page = session
3564 .execute_trusted_dynamic_grouped_query(&request)
3565 .unwrap_or_else(|error| {
3566 panic!("ordered grouped page {page_index} should execute: {error:?}")
3567 });
3568 let entries_read =
3569 IndexStore::current_entry_read_count().saturating_sub(entries_before);
3570 let rows_read = DataStore::current_get_call_count().saturating_sub(rows_before);
3571
3572 assert_eq!(page.row_count, 1);
3573 let [row] = page.rows.as_slice() else {
3574 panic!("ordered grouped page must contain exactly one closed group")
3575 };
3576 assert_eq!(row.group_key(), &[OutputValue::Nat64(group_key)]);
3577 assert_eq!(
3578 row.aggregate_values(),
3579 &[OutputValue::Nat64(row_count), OutputValue::Decimal(id_sum),],
3580 );
3581 if page_index == 0 {
3582 assert!(
3583 entries_read.saturating_add(rows_read) >= 70,
3584 "the first closed group must span the maintained 64-entry physical refill",
3585 );
3586 }
3587
3588 continuation = page.next_cursor;
3589 if page_index + 1 < expected.len() {
3590 let cursor = continuation
3591 .as_ref()
3592 .expect("another closed group should retain continuation");
3593 assert!(
3594 seen_cursors.insert(cursor.clone()),
3595 "ordered grouped continuation must advance monotonically",
3596 );
3597 } else {
3598 assert_eq!(continuation, None);
3599 }
3600 }
3601 }
3602
3603 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3604 #[test]
3605 fn exhaustive_pages_require_and_recompare_the_complete_source_proof() {
3606 let session = initialize();
3607 let first = insert_exact_key_fixture(&session, 41);
3608 let second = insert_exact_key_fixture(&session, 42);
3609 let third = insert_exact_key_fixture(&session, 43);
3610 let query = DynamicQuery::new(ENTITY_NAME)
3611 .select(["id", "payload"])
3612 .order_by(asc("id"));
3613
3614 let page = session
3615 .execute_trusted_exhaustive_page(&query, None, None)
3616 .expect("initial exhaustive page should capture its source proof");
3617 assert_eq!(
3618 page.rows,
3619 vec![
3620 expected_dynamic_row(first, 41),
3621 expected_dynamic_row(second, 42),
3622 ],
3623 );
3624 let continuation = page
3625 .continuation
3626 .as_deref()
3627 .expect("unreturned row should retain exhaustive continuation");
3628 assert!(matches!(
3629 session.execute_trusted_exhaustive_page(&query, Some(continuation), None),
3630 Err(ExhaustiveReadError::Revision(
3631 ReadSetRevisionError::ResumeProofRequired
3632 )),
3633 ));
3634 let resumed = session
3635 .execute_trusted_exhaustive_page(&query, Some(continuation), Some(&page.proof))
3636 .expect("unchanged proof should resume exhaustive traversal");
3637 assert_eq!(resumed.rows, vec![expected_dynamic_row(third, 43)]);
3638 assert_eq!(resumed.continuation, None);
3639
3640 let stale_page = session
3641 .execute_trusted_exhaustive_page(&query, None, None)
3642 .expect("fresh exhaustive page should capture current revision");
3643 let stale_continuation = stale_page
3644 .continuation
3645 .as_deref()
3646 .expect("fresh three-row traversal should retain continuation");
3647 let _ = insert_exact_key_fixture(&session, 44);
3648 assert!(matches!(
3649 session.execute_trusted_exhaustive_page(
3650 &query,
3651 Some(stale_continuation),
3652 Some(&stale_page.proof),
3653 ),
3654 Err(ExhaustiveReadError::Revision(
3655 ReadSetRevisionError::StoreDataChanged { .. }
3656 )),
3657 ));
3658 }
3659
3660 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3661 #[test]
3662 fn heap_sources_cannot_back_durable_resumable_jobs() {
3663 let session = initialize();
3664 let proof = session
3665 .capture_read_set_revision_proof(&[ENTITY_NAME])
3666 .expect("heap source proof should capture for one-call exhaustive reads");
3667 let job_id = ResumableJobId::try_from_bytes([70; 32])
3668 .expect("nonzero heap test job identity should admit");
3669
3670 assert!(matches!(
3671 session.start_resumable_job(job_id, proof, Vec::new()),
3672 Err(ResumableJobError::SourceProof(
3673 ReadSetRevisionError::DurableStoreRequired { .. }
3674 )),
3675 ));
3676 }
3677
3678 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3679 #[test]
3680 fn proof_and_progress_controls_charge_one_shared_request_scope() {
3681 let (session, root) = initialize_journaled_with_root();
3682 let resource = icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions;
3683 let before = root.observed(resource);
3684 let proof = session
3685 .capture_read_set_revision_proof(&[ENTITY_NAME])
3686 .expect("proof capture should use the retained request scope");
3687 let job_id = ResumableJobId::try_from_bytes([75; 32])
3688 .expect("nonzero accounting job identity should admit");
3689 session
3690 .start_resumable_job(job_id, proof, Vec::new())
3691 .expect("job start should use the same retained request scope");
3692 let _ = session
3693 .resumable_job_state(job_id)
3694 .expect("job load should use the same retained request scope");
3695
3696 assert_eq!(root.observed(resource).saturating_sub(before), 3);
3697 }
3698
3699 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3700 #[test]
3701 fn source_proofs_ignore_unrelated_stores_but_bind_access_state_changes() {
3702 let session = initialize();
3703 let proof = session
3704 .capture_read_set_revision_proof(&[ENTITY_NAME])
3705 .expect("source proof should cover only the entity's physical store");
3706 let shared_store_proof = session
3707 .capture_read_set_revision_proof(&[ENTITY_NAME, ENTITY_NAME])
3708 .expect("entities sharing one physical source should deduplicate");
3709 assert_eq!(shared_store_proof, proof);
3710 assert_eq!(shared_store_proof.stores().len(), 1);
3711 let unrelated = session
3712 .db
3713 .store_handle(UNRELATED_STORE_PATH)
3714 .expect("unrelated registered store should resolve");
3715 unrelated.with_data_mut(|store| {
3716 let _ = store.remove(&RawDataStoreKey::from_persisted_bytes(vec![1]));
3717 });
3718 session
3719 .verify_read_set_revision_proof(&proof)
3720 .expect("a nonparticipating store mutation must not invalidate the proof");
3721
3722 let source = session
3723 .db
3724 .store_handle(STORE_PATH)
3725 .expect("participating source store should resolve");
3726 source
3727 .mark_index_building()
3728 .expect("source access-state transition should advance its revision");
3729 assert!(matches!(
3730 session.verify_read_set_revision_proof(&proof),
3731 Err(ExhaustiveReadError::Revision(
3732 ReadSetRevisionError::StoreAccessChanged { .. }
3733 )),
3734 ));
3735 }
3736
3737 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3738 #[expect(
3739 clippy::too_many_lines,
3740 reason = "one lifecycle test proves successful replay plus pre-page and post-page source invalidation without sharing progress state across tests"
3741 )]
3742 #[test]
3743 fn journaled_job_advance_is_idempotent_and_revision_checked_on_both_sides() {
3744 let session = initialize_journaled();
3745 let proof = session
3746 .capture_read_set_revision_proof(&[ENTITY_NAME])
3747 .expect("journaled source proof should capture");
3748 let job_id =
3749 ResumableJobId::try_from_bytes([71; 32]).expect("nonzero job identity should admit");
3750 session
3751 .start_resumable_job(job_id, proof, vec![0])
3752 .expect("journaled job should start outside its protected source revision");
3753 let request = ResumableJobAdvanceRequest::new(
3754 job_id,
3755 0,
3756 ResumableJobIdempotencyKey::new("page-0")
3757 .expect("bounded idempotency key should admit"),
3758 );
3759 let calls = Cell::new(0_u8);
3760 let receipt = session
3761 .compare_proof_and_advance(&request, |state| {
3762 calls.set(calls.get() + 1);
3763 assert_eq!(state.application_state, vec![0]);
3764 Ok::<_, ()>(
3765 ResumableJobAdvance::new(Some("cursor-1".to_string()), vec![1], vec![9])
3766 .expect("bounded application advance should admit"),
3767 )
3768 })
3769 .expect("unchanged source should advance exactly once");
3770 assert_eq!(calls.get(), 1);
3771 assert_eq!(receipt.status, ResumableJobAdvanceStatus::Advanced);
3772 assert_eq!(receipt.committed_sequence, 1);
3773
3774 let replay = session
3775 .compare_proof_and_advance::<()>(&request, |_| {
3776 panic!("lost-response replay must not execute application work")
3777 })
3778 .expect("same request identity should return its persisted receipt");
3779 assert_eq!(replay, receipt);
3780 let retained = session
3781 .resumable_job_state(job_id)
3782 .expect("advanced state should remain durable");
3783 assert_eq!(retained.sequence, 1);
3784 assert_eq!(retained.application_state, vec![1]);
3785
3786 let _ = insert_exact_key_fixture(&session, 51);
3787 let pre_change_request = ResumableJobAdvanceRequest::new(
3788 job_id,
3789 1,
3790 ResumableJobIdempotencyKey::new("page-1")
3791 .expect("bounded idempotency key should admit"),
3792 );
3793 let pre_change_calls = Cell::new(0_u8);
3794 let invalidated = session
3795 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
3796 pre_change_calls.set(pre_change_calls.get() + 1);
3797 unreachable!("pre-page proof failure must reject before application work")
3798 })
3799 .expect("source drift should persist one replayable invalidation receipt");
3800 assert_eq!(pre_change_calls.get(), 0);
3801 assert_eq!(invalidated.status, ResumableJobAdvanceStatus::Invalidated);
3802 let invalidated_state = session
3803 .resumable_job_state(job_id)
3804 .expect("invalidated job should remain inspectable");
3805 assert_eq!(invalidated_state.status, ResumableJobStatus::Invalidated);
3806 assert_eq!(invalidated_state.continuation, None);
3807 assert_eq!(invalidated_state.application_state, vec![1]);
3808 assert_eq!(
3809 session
3810 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
3811 panic!("invalidation replay must not execute application work")
3812 })
3813 .expect("lost invalidation reply should replay exactly"),
3814 invalidated,
3815 );
3816
3817 let post_proof = session
3818 .capture_read_set_revision_proof(&[ENTITY_NAME])
3819 .expect("post-change journaled proof should capture");
3820 let post_job_id = ResumableJobId::try_from_bytes([72; 32])
3821 .expect("nonzero post-change job identity should admit");
3822 session
3823 .start_resumable_job(post_job_id, post_proof, vec![7])
3824 .expect("post-change journaled job should start");
3825 let post_request = ResumableJobAdvanceRequest::new(
3826 post_job_id,
3827 0,
3828 ResumableJobIdempotencyKey::new("post-page-0")
3829 .expect("bounded idempotency key should admit"),
3830 );
3831 let post_receipt = session
3832 .compare_proof_and_advance::<()>(&post_request, |_| {
3833 let _ = insert_exact_key_fixture(&session, 52);
3834 Ok(ResumableJobAdvance::new(None, vec![8], vec![10])
3835 .expect("bounded post-change candidate should admit"))
3836 })
3837 .expect("post-page drift should discard the candidate and persist invalidation");
3838 assert_eq!(post_receipt.status, ResumableJobAdvanceStatus::Invalidated);
3839 let post_state = session
3840 .resumable_job_state(post_job_id)
3841 .expect("post-page invalidation should remain inspectable");
3842 assert_eq!(post_state.status, ResumableJobStatus::Invalidated);
3843 assert_eq!(post_state.application_state, vec![7]);
3844 session
3845 .acknowledge_resumable_job(post_job_id, post_state.sequence)
3846 .expect("terminal job acknowledgement should remove retained progress");
3847 session
3848 .acknowledge_resumable_job(post_job_id, post_state.sequence)
3849 .expect("lost acknowledgement reply should be safely replayable");
3850 assert_eq!(
3851 session.resumable_job_state(post_job_id),
3852 Err(ResumableJobError::NotFound),
3853 );
3854
3855 let completed_job_id = ResumableJobId::try_from_bytes([74; 32])
3856 .expect("nonzero completed job identity should admit");
3857 let completed_proof = session
3858 .capture_read_set_revision_proof(&[ENTITY_NAME])
3859 .expect("completed-job source proof should capture");
3860 session
3861 .start_resumable_job(completed_job_id, completed_proof, Vec::new())
3862 .expect("completed-job fixture should start");
3863 let completed_request = ResumableJobAdvanceRequest::new(
3864 completed_job_id,
3865 0,
3866 ResumableJobIdempotencyKey::new("complete")
3867 .expect("bounded completion key should admit"),
3868 );
3869 let completed_receipt = session
3870 .compare_proof_and_advance::<()>(&completed_request, |_| {
3871 Ok(ResumableJobAdvance::new(None, vec![99], vec![100])
3872 .expect("bounded terminal advance should admit"))
3873 })
3874 .expect("null continuation should commit terminal completion");
3875 let completed_state = session
3876 .resumable_job_state(completed_job_id)
3877 .expect("completed state should remain replayable before acknowledgement");
3878 assert_eq!(completed_state.status, ResumableJobStatus::Completed);
3879 assert_eq!(
3880 session
3881 .compare_proof_and_advance::<()>(&completed_request, |_| {
3882 panic!("completed request replay must not execute application work")
3883 })
3884 .expect("completed request should replay until acknowledgement"),
3885 completed_receipt,
3886 );
3887 let after_completion = ResumableJobAdvanceRequest::new(
3888 completed_job_id,
3889 1,
3890 ResumableJobIdempotencyKey::new("after-complete")
3891 .expect("bounded post-completion key should admit"),
3892 );
3893 assert!(matches!(
3894 session.compare_proof_and_advance::<()>(&after_completion, |_| {
3895 panic!("completed jobs cannot execute another page")
3896 }),
3897 Err(CompareProofAndAdvanceError::Protocol(
3898 ResumableJobError::Completed
3899 )),
3900 ));
3901 session
3902 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
3903 .expect("completed job should acknowledge and free capacity");
3904 session
3905 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
3906 .expect("completion acknowledgement should be idempotent");
3907
3908 let stale_job_id = ResumableJobId::try_from_bytes([73; 32])
3909 .expect("nonzero stale-sequence job identity should admit");
3910 let stale_proof = session
3911 .capture_read_set_revision_proof(&[ENTITY_NAME])
3912 .expect("stale-sequence source proof should capture");
3913 session
3914 .start_resumable_job(stale_job_id, stale_proof, Vec::new())
3915 .expect("stale-sequence job should start");
3916 let stale_request = ResumableJobAdvanceRequest::new(
3917 stale_job_id,
3918 4,
3919 ResumableJobIdempotencyKey::new("stale").expect("bounded idempotency key should admit"),
3920 );
3921 assert!(matches!(
3922 session.compare_proof_and_advance::<()>(&stale_request, |_| {
3923 panic!("stale sequence must reject before application work")
3924 }),
3925 Err(CompareProofAndAdvanceError::Protocol(
3926 ResumableJobError::StaleSequence {
3927 expected: 4,
3928 actual: 0,
3929 }
3930 )),
3931 ));
3932 assert_eq!(
3933 session.acknowledge_resumable_job(stale_job_id, 0),
3934 Err(ResumableJobError::NotTerminal),
3935 );
3936 }
3937
3938 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3939 #[test]
3940 fn exact_key_batch_uses_typed_hard_execution_budget() {
3941 let session = initialize();
3942 let binding = exact_key_binding(&session);
3943 let budget =
3944 HardExecutionBudget::uniform_for_tests(0, HardExecutionFailureHeadroom::new(500, 256));
3945 let error = session
3946 .execute_exact_key_batch_with_hard_budget_for_tests(&binding, &[u64::MAX], &budget)
3947 .expect_err("zero query budget should reject the exact-key route");
3948
3949 assert!(matches!(
3950 error.diagnostic().detail(),
3951 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3952 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
3953 })
3954 ));
3955 let facts = error.diagnostic_facts();
3956 assert_eq!(
3957 &facts[..5],
3958 &[
3959 (
3960 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
3961 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions.raw(),
3962 ),
3963 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 0),
3964 (icydb_diagnostic_code::DiagnosticFactTag::Actual, 1),
3965 (
3966 icydb_diagnostic_code::DiagnosticFactTag::ExecutionBudgetScope,
3967 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution.raw(),
3968 ),
3969 (
3970 icydb_diagnostic_code::DiagnosticFactTag::ExecutionLane,
3971 icydb_diagnostic_code::DiagnosticExecutionLane::PublicRead.raw(),
3972 ),
3973 ],
3974 );
3975 assert_eq!(
3976 facts[5].0,
3977 icydb_diagnostic_code::DiagnosticFactTag::QueryShapeFingerprintPrefix,
3978 );
3979 assert_ne!(facts[5].1, 0);
3980 }
3981
3982 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3983 fn assert_planned_query_exhausts(
3984 session: &DbSession<TestCanister>,
3985 query: &crate::db::DynamicQuery,
3986 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
3987 ) {
3988 let budget = HardExecutionBudget::uniform_for_tests(
3989 u64::MAX,
3990 HardExecutionFailureHeadroom::new(500, 256),
3991 )
3992 .with_limit_for_tests(resource, 0);
3993 let context = HardExecutionContext::new(
3994 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
3995 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
3996 0x7068_7973_6963_616c,
3997 );
3998 let error = with_query_execution_budget_for_tests(budget, context, || {
3999 session.execute_trusted_live_page(query, None)
4000 })
4001 .expect_err("the injected zero resource allowance should reject planned execution");
4002
4003 assert!(matches!(
4004 error.diagnostic().detail(),
4005 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4006 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
4007 })
4008 ));
4009 assert_eq!(
4010 error.diagnostic_facts()[0],
4011 (
4012 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
4013 resource.raw(),
4014 ),
4015 );
4016 }
4017
4018 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4019 fn assert_grouped_query_exhausts(
4020 session: &DbSession<TestCanister>,
4021 query: &crate::db::DynamicQuery,
4022 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
4023 ) {
4024 let budget = HardExecutionBudget::uniform_for_tests(
4025 u64::MAX,
4026 HardExecutionFailureHeadroom::new(500, 256),
4027 )
4028 .with_limit_for_tests(resource, 0);
4029 let context = HardExecutionContext::new(
4030 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
4031 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
4032 0x6772_6f75_7065_642d,
4033 );
4034 let error = with_query_execution_budget_for_tests(budget, context, || {
4035 session.execute_trusted_dynamic_grouped_query(query)
4036 })
4037 .expect_err("the injected zero resource allowance should reject grouped execution");
4038
4039 assert!(matches!(
4040 error.diagnostic().detail(),
4041 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4042 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
4043 })
4044 ));
4045 assert_eq!(
4046 error.diagnostic_facts()[0],
4047 (
4048 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
4049 resource.raw(),
4050 ),
4051 );
4052 }
4053
4054 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4055 fn assert_sql_query_exhausts(
4056 session: &DbSession<TestCanister>,
4057 sql: &str,
4058 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
4059 ) {
4060 let budget = HardExecutionBudget::uniform_for_tests(
4061 u64::MAX,
4062 HardExecutionFailureHeadroom::new(500, 256),
4063 )
4064 .with_limit_for_tests(resource, 0);
4065 let context = HardExecutionContext::new(
4066 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
4067 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
4068 0x7371_6c2d_736f_7274,
4069 );
4070 let error = with_query_execution_budget_for_tests(budget, context, || {
4071 session.execute_trusted_sql_query(sql)
4072 })
4073 .expect_err("the injected zero resource allowance should reject SQL execution");
4074
4075 assert!(matches!(
4076 error.diagnostic().detail(),
4077 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4078 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
4079 })
4080 ));
4081 assert_eq!(
4082 error.diagnostic_facts()[0],
4083 (
4084 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
4085 resource.raw(),
4086 ),
4087 );
4088 }
4089
4090 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4091 #[test]
4092 fn planned_read_routes_share_physical_resource_accounting() {
4093 let session = initialize();
4094 let first = insert_exact_key_fixture(&session, 41);
4095 insert_exact_key_fixture(&session, 42);
4096
4097 let fallback = crate::db::DynamicQuery::new(ENTITY_NAME)
4098 .filter(crate::db::FieldRef::new("id").eq(first))
4099 .select(["id", "payload"])
4100 .order_by(crate::db::asc("id"))
4101 .limit(1);
4102 assert_eq!(
4103 session
4104 .execute_trusted_live_page(&fallback, None)
4105 .expect("bounded fallback execution should preserve its result")
4106 .row_count,
4107 1,
4108 );
4109 assert_planned_query_exhausts(
4110 &session,
4111 &fallback,
4112 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::RowsVisited,
4113 );
4114
4115 let covering = crate::db::DynamicQuery::new(ENTITY_NAME)
4116 .filter(crate::db::FieldRef::new("payload").eq(41_u64))
4117 .select(["payload"])
4118 .order_by(crate::db::asc("payload"))
4119 .limit(1);
4120 assert_eq!(
4121 session
4122 .execute_trusted_live_page(&covering, None)
4123 .expect("bounded covering execution should preserve its result")
4124 .row_count,
4125 1,
4126 );
4127 assert_planned_query_exhausts(
4128 &session,
4129 &covering,
4130 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
4131 );
4132
4133 let residual = crate::db::DynamicQuery::new(ENTITY_NAME)
4134 .filter(crate::db::FieldRef::new("payload").eq_field("id"))
4135 .select(["id"])
4136 .order_by(crate::db::asc("id"))
4137 .limit(1);
4138 assert_eq!(
4139 session
4140 .execute_trusted_live_page(&residual, None)
4141 .expect("bounded residual execution should preserve its result")
4142 .row_count,
4143 0,
4144 );
4145 assert_planned_query_exhausts(
4146 &session,
4147 &residual,
4148 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::PredicateExpressionSteps,
4149 );
4150
4151 assert_planned_query_exhausts(
4152 &session,
4153 &fallback,
4154 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::ResultBytes,
4155 );
4156
4157 let grouped = crate::db::DynamicQuery::new(ENTITY_NAME)
4158 .group_by("payload")
4159 .aggregate(crate::db::count())
4160 .order_by(crate::db::asc("payload"))
4161 .grouped_limits(10, 16 * 1_024)
4162 .limit(1);
4163 let grouped_result = session
4164 .execute_trusted_dynamic_grouped_query(&grouped)
4165 .expect("bounded grouped execution should preserve its result");
4166 assert_eq!(grouped_result.row_count, 1);
4167 assert!(grouped_result.next_cursor.is_some());
4168 assert_grouped_query_exhausts(
4169 &session,
4170 &grouped,
4171 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::GroupDistinctEntries,
4172 );
4173 assert_grouped_query_exhausts(
4174 &session,
4175 &grouped,
4176 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::CursorSteps,
4177 );
4178
4179 assert_sql_query_exhausts(
4180 &session,
4181 "SELECT payload, COUNT(*) AS row_count FROM IdentityRow \
4182 GROUP BY payload ORDER BY row_count DESC, payload ASC LIMIT 1",
4183 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::SortEntries,
4184 );
4185 }
4186
4187 fn assert_dynamic_payload<C: CanisterKind>(
4188 session: &DbSession<C>,
4189 key: u64,
4190 expected_payload: u64,
4191 ) {
4192 let unchanged = session
4193 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
4194 entity: ENTITY_NAME.to_string(),
4195 key: InputValue::Nat64(key),
4196 patch: dynamic_payload_patch(expected_payload),
4197 })
4198 .expect("the expected row should remain readable through a no-op update");
4199 assert_eq!(unchanged.affected_rows, 0);
4200 assert_eq!(
4201 unchanged.rows,
4202 vec![expected_dynamic_row(key, expected_payload)],
4203 );
4204 }
4205
4206 fn assert_exact_batch_backlog_pressure(
4207 pressure: &InternalError,
4208 before: JournalTailControl,
4209 next_sequence: u64,
4210 ) {
4211 assert_eq!(
4212 pressure.diagnostic().error_code(),
4213 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_CONVERGENCE_BACKLOG_PRESSURE,
4214 );
4215 assert_eq!(
4216 pressure.diagnostic_facts(),
4217 vec![
4218 (
4219 icydb_diagnostic_code::DiagnosticFactTag::BacklogResource,
4220 icydb_diagnostic_code::DiagnosticBacklogResource::Batches.raw(),
4221 ),
4222 (icydb_diagnostic_code::DiagnosticFactTag::CurrentCount, 38),
4223 (icydb_diagnostic_code::DiagnosticFactTag::ProposedCount, 1),
4224 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 38),
4225 ],
4226 );
4227 assert_eq!(
4228 crate::db::commit::next_database_commit_sequence()
4229 .expect("pressure must leave the database sequence readable"),
4230 next_sequence,
4231 );
4232 assert!(matches!(
4233 crate::db::commit::observe_commit_control()
4234 .expect("pressure must leave commit control observable"),
4235 crate::db::commit::CommitControlObservation::Present {
4236 marker_present: false,
4237 ..
4238 },
4239 ));
4240 assert_eq!(
4241 JOURNALED_TAIL_STORE.with(|tail| {
4242 tail.borrow()
4243 .current_tail_control()
4244 .expect("pressure must preserve the exact tail control")
4245 }),
4246 before,
4247 );
4248 }
4249
4250 fn batch(values: &[u64]) -> Vec<AcceptedStructuralMutation> {
4251 values
4252 .iter()
4253 .map(|value| {
4254 AcceptedStructuralMutation::save(
4255 MutationMode::Insert,
4256 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
4257 payload_patch(*value),
4258 )
4259 })
4260 .collect()
4261 }
4262
4263 fn atomic_progress_fixture(
4264 identity_byte: u8,
4265 ) -> (
4266 MutationJobRecord,
4267 MutationJobRecord,
4268 MutationProgressRecordOp,
4269 ) {
4270 let job_id = MutationJobId::try_from_bytes([identity_byte; 32])
4271 .expect("nonzero atomic progress job id should admit");
4272 let before = MutationJobRecord::new(job_id, vec![1, identity_byte], vec![2])
4273 .expect("atomic progress predecessor should admit");
4274 let request = MutationJobAdvanceRequest::new(
4275 job_id,
4276 0,
4277 MutationJobIdempotencyKey::new(format!("atomic-{identity_byte}"))
4278 .expect("atomic progress replay key should admit"),
4279 );
4280 let (after, _) = before
4281 .apply_transition(
4282 &request,
4283 MutationJobTransition::new(
4284 MutationJobStatus::Active,
4285 MutationJobPhase::Forward,
4286 vec![3],
4287 1,
4288 1,
4289 0,
4290 ),
4291 )
4292 .expect("atomic progress successor should admit");
4293 let operation = MutationProgressRecordOp::replace(&before, &after)
4294 .expect("atomic progress replacement should admit");
4295 (before, after, operation)
4296 }
4297
4298 fn assert_identity_boundary(error: &InternalError) {
4299 assert_eq!(error.class(), ErrorClass::Unsupported);
4300 assert_eq!(error.origin(), ErrorOrigin::Identity);
4301 }
4302
4303 #[test]
4304 fn generated_candidate_collision_is_identity_corruption_before_generic_uniqueness() {
4305 let generated = insert_key_exists_after_generation(true);
4306 assert_eq!(generated.class(), ErrorClass::Corruption);
4307 assert_eq!(generated.origin(), ErrorOrigin::Identity);
4308
4309 let ordinary = insert_key_exists_after_generation(false);
4310 assert_ne!(ordinary.origin(), ErrorOrigin::Identity);
4311 }
4312
4313 #[cfg(target_pointer_width = "64")]
4314 #[test]
4315 fn pre_key_candidate_count_rejects_values_beyond_the_persisted_u32_bound() {
4316 let error = checked_pre_key_candidate_count(
4317 usize::try_from(u64::from(u32::MAX) + 1).expect("64-bit usize should hold u32 + 1"),
4318 )
4319 .expect_err("candidate counts beyond u32 must reject");
4320 assert_identity_boundary(&error);
4321 }
4322
4323 #[test]
4324 #[expect(
4325 clippy::too_many_lines,
4326 reason = "one holding lifecycle proves split, merge, transfer, late-failure neutrality, result order, and Identity state"
4327 )]
4328 fn mixed_structural_batch_preserves_holding_conservation_and_failure_atomicity() {
4329 let session = initialize();
4330 let seeded = session
4331 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
4332 .expect("seed rows should commit");
4333 assert_eq!(seeded.affected_rows, 1);
4334
4335 let split = session
4336 .execute_trusted_dynamic_mutation_batch(vec![
4337 DynamicMutation::Update {
4338 entity: ENTITY_NAME.to_string(),
4339 key: InputValue::Nat64(1),
4340 patch: dynamic_payload_patch(60),
4341 },
4342 DynamicMutation::Insert {
4343 entity: ENTITY_NAME.to_string(),
4344 patch: dynamic_payload_patch(40),
4345 },
4346 ])
4347 .expect("one holding should split atomically");
4348 assert_eq!(split.affected_rows, 2);
4349 assert_eq!(
4350 split.rows,
4351 vec![expected_dynamic_row(1, 60), expected_dynamic_row(2, 40),],
4352 "split after-images must retain input order and exact quantity",
4353 );
4354
4355 let rejected_split = session
4356 .execute_trusted_dynamic_mutation_batch(vec![
4357 DynamicMutation::Update {
4358 entity: ENTITY_NAME.to_string(),
4359 key: InputValue::Nat64(1),
4360 patch: dynamic_payload_patch(50),
4361 },
4362 DynamicMutation::Insert {
4363 entity: ENTITY_NAME.to_string(),
4364 patch: DynamicStructuralPatch::new(Vec::new()),
4365 },
4366 ])
4367 .expect_err("an invalid split output must reject the staged source update");
4368 assert_eq!(rejected_split.class(), ErrorClass::Unsupported);
4369 assert_eq!(rejected_split.origin(), ErrorOrigin::Executor);
4370 assert_eq!(
4371 rejected_split.diagnostic_facts(),
4372 vec![
4373 (
4374 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4375 ENTITY_TAG.value(),
4376 ),
4377 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 2),
4378 (
4379 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4380 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
4381 ),
4382 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1,),
4383 ],
4384 );
4385 assert_dynamic_payload(&session, 1, 60);
4386 assert_dynamic_payload(&session, 2, 40);
4387
4388 let transfer = session
4389 .execute_trusted_dynamic_mutation_batch(vec![
4390 DynamicMutation::Update {
4391 entity: ENTITY_NAME.to_string(),
4392 key: InputValue::Nat64(1),
4393 patch: dynamic_payload_patch(70),
4394 },
4395 DynamicMutation::Update {
4396 entity: ENTITY_NAME.to_string(),
4397 key: InputValue::Nat64(2),
4398 patch: dynamic_payload_patch(30),
4399 },
4400 ])
4401 .expect("distinct transfer patches should share one atomic batch");
4402 assert_eq!(
4403 transfer.rows,
4404 vec![expected_dynamic_row(1, 70), expected_dynamic_row(2, 30),],
4405 "the transfer must preserve the exact total quantity",
4406 );
4407
4408 let merge = session
4409 .execute_trusted_dynamic_mutation_batch(vec![
4410 DynamicMutation::Delete {
4411 entity: ENTITY_NAME.to_string(),
4412 key: InputValue::Nat64(2),
4413 },
4414 DynamicMutation::Update {
4415 entity: ENTITY_NAME.to_string(),
4416 key: InputValue::Nat64(1),
4417 patch: dynamic_payload_patch(100),
4418 },
4419 ])
4420 .expect("two holdings should merge atomically");
4421 assert_eq!(
4422 merge.rows,
4423 vec![expected_dynamic_row(2, 30), expected_dynamic_row(1, 100),],
4424 "delete before-images and update after-images must retain input order",
4425 );
4426
4427 let resplit = session
4428 .execute_trusted_dynamic_mutation_batch(vec![
4429 DynamicMutation::Update {
4430 entity: ENTITY_NAME.to_string(),
4431 key: InputValue::Nat64(1),
4432 patch: dynamic_payload_patch(60),
4433 },
4434 DynamicMutation::Insert {
4435 entity: ENTITY_NAME.to_string(),
4436 patch: dynamic_payload_patch(40),
4437 },
4438 ])
4439 .expect("the merged holding should split again");
4440 assert_eq!(
4441 resplit.rows,
4442 vec![expected_dynamic_row(1, 60), expected_dynamic_row(3, 40),],
4443 );
4444
4445 let rejected_merge = session
4446 .execute_trusted_dynamic_mutation_batch(vec![
4447 DynamicMutation::Delete {
4448 entity: ENTITY_NAME.to_string(),
4449 key: InputValue::Nat64(3),
4450 },
4451 DynamicMutation::Update {
4452 entity: ENTITY_NAME.to_string(),
4453 key: InputValue::Nat64(99),
4454 patch: dynamic_payload_patch(100),
4455 },
4456 ])
4457 .expect_err("a late missing merge target must preserve the earlier staged delete");
4458 assert_eq!(rejected_merge.class(), ErrorClass::NotFound);
4459 assert_dynamic_payload(&session, 1, 60);
4460 assert_dynamic_payload(&session, 3, 40);
4461
4462 SCHEMA_STORE.with(|store| {
4463 let cursor = store
4464 .borrow()
4465 .identity_statement_cursor(
4466 database_incarnation_id().expect("database incarnation should remain readable"),
4467 ENTITY_TAG,
4468 FieldId::new(1),
4469 &AcceptedFieldKind::Nat64,
4470 )
4471 .expect("mixed Identity state should remain readable");
4472 assert_eq!(cursor.expected_high_water(), 3);
4473 assert!(!cursor.has_allocations());
4474 });
4475 }
4476
4477 #[test]
4478 fn mixed_structural_batch_rejects_duplicate_holding_targets_without_mutation() {
4479 let session = initialize();
4480 session
4481 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
4482 .expect("the holding fixture should initialize");
4483
4484 let duplicate = session
4485 .execute_trusted_dynamic_mutation_batch(vec![
4486 DynamicMutation::Update {
4487 entity: ENTITY_NAME.to_string(),
4488 key: InputValue::Nat64(1),
4489 patch: dynamic_payload_patch(60),
4490 },
4491 DynamicMutation::Delete {
4492 entity: ENTITY_NAME.to_string(),
4493 key: InputValue::Nat64(1),
4494 },
4495 ])
4496 .expect_err("duplicate targets across operation kinds must reject");
4497 assert!(matches!(
4498 duplicate.diagnostic().detail(),
4499 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4500 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchDuplicateKey,
4501 }),
4502 ));
4503 assert_eq!(
4504 duplicate.diagnostic_facts(),
4505 vec![
4506 (
4507 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4508 ENTITY_TAG.value(),
4509 ),
4510 (
4511 icydb_diagnostic_code::DiagnosticFactTag::FirstBatchPosition,
4512 0,
4513 ),
4514 (
4515 icydb_diagnostic_code::DiagnosticFactTag::DuplicateBatchPosition,
4516 1,
4517 ),
4518 ],
4519 );
4520 assert_dynamic_payload(&session, 1, 100);
4521 }
4522
4523 #[test]
4524 fn mixed_structural_batch_rejects_empty_and_over_bound_before_resolution() {
4525 let session = initialize();
4526 let empty = session
4527 .execute_trusted_dynamic_mutation_batch(Vec::new())
4528 .expect_err("an empty public batch must reject");
4529 assert!(matches!(
4530 empty.diagnostic().detail(),
4531 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4532 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchEmpty,
4533 }),
4534 ));
4535 assert_eq!(
4536 empty.diagnostic_facts(),
4537 vec![(icydb_diagnostic_code::DiagnosticFactTag::ActualCount, 0,)],
4538 );
4539
4540 let requests = (0..=MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS)
4541 .map(|_| DynamicMutation::Delete {
4542 entity: ENTITY_NAME.to_string(),
4543 key: InputValue::Nat64(1),
4544 })
4545 .collect();
4546 let over_bound = session
4547 .execute_trusted_dynamic_mutation_batch(requests)
4548 .expect_err("operation cap plus one must reject before row resolution");
4549 assert!(matches!(
4550 over_bound.diagnostic().detail(),
4551 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4552 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchTooManyItems,
4553 }),
4554 ));
4555 assert_eq!(
4556 over_bound.diagnostic_facts(),
4557 vec![
4558 (
4559 icydb_diagnostic_code::DiagnosticFactTag::ActualCount,
4560 (MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS + 1) as u64,
4561 ),
4562 (
4563 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4564 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS as u64,
4565 ),
4566 ],
4567 );
4568 }
4569
4570 #[test]
4571 fn mixed_structural_batch_staged_byte_bound_uses_checked_exact_boundary() {
4572 let mut exact = 0;
4573 add_structural_mutation_staged_bytes(
4574 &mut exact,
4575 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES],
4576 )
4577 .expect("the exact staged-byte boundary should admit");
4578 assert_eq!(exact, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
4579
4580 let error = add_structural_mutation_staged_bytes(&mut exact, [1])
4581 .expect_err("one byte above the staged-byte boundary must reject");
4582 assert!(matches!(
4583 error.diagnostic().detail(),
4584 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4585 boundary:
4586 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchStagedBytesExceeded,
4587 }),
4588 ));
4589 assert_eq!(
4590 error.diagnostic_facts(),
4591 vec![
4592 (
4593 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
4594 (MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES + 1) as u64,
4595 ),
4596 (
4597 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4598 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES as u64,
4599 ),
4600 ],
4601 );
4602
4603 validate_structural_mutation_result_bytes(MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES)
4604 .expect("the exact result-byte boundary should admit");
4605 let error = validate_structural_mutation_result_bytes(
4606 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1,
4607 )
4608 .expect_err("one byte above the result-byte boundary must reject");
4609 assert!(matches!(
4610 error.diagnostic().detail(),
4611 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4612 boundary:
4613 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchResultBytesExceeded,
4614 }),
4615 ));
4616 assert_eq!(
4617 error.diagnostic_facts(),
4618 vec![
4619 (
4620 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
4621 (MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1) as u64,
4622 ),
4623 (
4624 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4625 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES as u64,
4626 ),
4627 ],
4628 );
4629 }
4630
4631 #[expect(
4632 clippy::too_many_lines,
4633 reason = "one lifecycle proves shared materialization and every maintained frontend against the same zero-state owner"
4634 )]
4635 #[test]
4636 fn identity_insert_frontends_share_one_committed_range_without_rejected_consumption() {
4637 let session = initialize();
4638 let catalog = session
4639 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
4640 .expect("identity catalog should resolve");
4641 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
4642 .expect("identity row layout should build");
4643 let initial_description = session
4644 .try_describe_entity_by_name(ENTITY_NAME)
4645 .expect("accepted Identity description should resolve");
4646 assert_eq!(
4647 initial_description.entity_tag(),
4648 catalog.identity().entity_tag().value()
4649 );
4650 assert_eq!(
4651 initial_description.accepted_schema_fingerprint_method(),
4652 catalog.fingerprint_method_version()
4653 );
4654 assert_eq!(
4655 initial_description.accepted_schema_fingerprint(),
4656 catalog.fingerprint()
4657 );
4658 let initial_identity = initial_description
4659 .identity()
4660 .expect("accepted Identity policy should be described");
4661 assert_eq!(initial_identity.field(), "id");
4662 assert_eq!(initial_identity.generator(), "Identity::next");
4663 assert_eq!(initial_identity.accepted_kind(), "nat64");
4664 assert_eq!(initial_identity.minimum(), 1);
4665 assert_eq!(initial_identity.maximum(), u128::from(u64::MAX));
4666 assert_eq!(initial_identity.high_water(), 0);
4667 assert_eq!(initial_identity.remaining(), u128::from(u64::MAX));
4668 assert!(!initial_identity.exhausted());
4669
4670 let rejected = session
4671 .execute_accepted_structural_save_batch(
4672 &catalog,
4673 &descriptor,
4674 batch(&[1_000, 2_000]),
4675 Timestamp::from_millis(6),
4676 |_| Err::<(), _>(InternalError::executor_unsupported()),
4677 )
4678 .expect_err("a rejected precommit result must not publish its tentative range");
4679 assert_eq!(rejected.class(), ErrorClass::Unsupported);
4680 assert_eq!(DATA_STORE.with(|store| store.borrow().len()), 0);
4681
4682 let rows = session
4683 .execute_accepted_structural_save_batch(
4684 &catalog,
4685 &descriptor,
4686 batch(&[10, 20, 30]),
4687 Timestamp::from_millis(7),
4688 Ok,
4689 )
4690 .expect("one accepted batch should commit rows and one identity range");
4691 assert_eq!(
4692 rows.into_iter().map(|row| row.values).collect::<Vec<_>>(),
4693 vec![
4694 vec![Value::Nat64(1), Value::Nat64(10)],
4695 vec![Value::Nat64(2), Value::Nat64(20)],
4696 vec![Value::Nat64(3), Value::Nat64(30)],
4697 ],
4698 );
4699
4700 let dynamic = session
4701 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4702 entity: ENTITY_NAME.to_string(),
4703 patch: DynamicStructuralPatch::new(vec![(
4704 "payload".to_string(),
4705 DynamicWriteCell::Value(InputValue::Nat64(40)),
4706 )]),
4707 })
4708 .expect("dynamic omission should commit through shared Identity generation");
4709 assert_eq!(dynamic.affected_rows, 1);
4710
4711 for (request, operation) in [
4712 (
4713 DynamicMutation::Insert {
4714 entity: ENTITY_NAME.to_string(),
4715 patch: DynamicStructuralPatch::new(vec![
4716 (
4717 "id".to_string(),
4718 DynamicWriteCell::Value(InputValue::Nat64(41)),
4719 ),
4720 (
4721 "payload".to_string(),
4722 DynamicWriteCell::Value(InputValue::Nat64(42)),
4723 ),
4724 ]),
4725 },
4726 icydb_diagnostic_code::DiagnosticMutationOperation::Insert,
4727 ),
4728 (
4729 DynamicMutation::Update {
4730 entity: ENTITY_NAME.to_string(),
4731 key: InputValue::Nat64(1),
4732 patch: DynamicStructuralPatch::new(vec![(
4733 "id".to_string(),
4734 DynamicWriteCell::Default,
4735 )]),
4736 },
4737 icydb_diagnostic_code::DiagnosticMutationOperation::Update,
4738 ),
4739 ] {
4740 let error = session
4741 .execute_trusted_dynamic_mutation(&request)
4742 .expect_err("structural Identity authorship and regeneration must reject");
4743 assert_eq!(error.class(), ErrorClass::Unsupported);
4744 assert_eq!(error.origin(), ErrorOrigin::Executor);
4745 assert_eq!(
4746 error.diagnostic_facts(),
4747 vec![
4748 (
4749 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4750 ENTITY_TAG.value(),
4751 ),
4752 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
4753 (
4754 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4755 operation.raw(),
4756 ),
4757 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,),
4758 ],
4759 );
4760 }
4761
4762 let binding = session
4763 .issue_typed_entity_binding(
4764 ENTITY_SOURCE,
4765 &[
4766 DynamicTypedFieldBindingRequest::new(
4767 ID_SOURCE.to_string(),
4768 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
4769 false,
4770 ),
4771 DynamicTypedFieldBindingRequest::new(
4772 PAYLOAD_SOURCE.to_string(),
4773 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
4774 false,
4775 ),
4776 ],
4777 )
4778 .expect("typed output should bind the Identity field");
4779 let typed_patch = binding
4780 .bind_write_fields(vec![(
4781 PAYLOAD_SOURCE.to_string(),
4782 DynamicWriteCell::Value(InputValue::Nat64(50)),
4783 )])
4784 .expect("typed payload should lower");
4785 let typed = session
4786 .execute_trusted_typed_mutation(
4787 &binding,
4788 &DynamicTypedMutation::Insert { patch: typed_patch },
4789 )
4790 .expect("typed omission should commit through shared Identity generation");
4791 assert_eq!(
4792 typed
4793 .expect("typed insert should return one mutation result")
4794 .affected_rows,
4795 1,
4796 );
4797 let explicit_typed_patch = binding
4798 .bind_write_fields(vec![
4799 (
4800 ID_SOURCE.to_string(),
4801 DynamicWriteCell::Value(InputValue::Nat64(51)),
4802 ),
4803 (
4804 PAYLOAD_SOURCE.to_string(),
4805 DynamicWriteCell::Value(InputValue::Nat64(52)),
4806 ),
4807 ])
4808 .expect("the low-level binding should retain exact authored intent");
4809 let explicit_typed_error = session
4810 .execute_trusted_typed_mutation(
4811 &binding,
4812 &DynamicTypedMutation::Insert {
4813 patch: explicit_typed_patch,
4814 },
4815 )
4816 .expect_err("typed Identity authorship must reject before allocation");
4817 assert_eq!(explicit_typed_error.class(), ErrorClass::Unsupported);
4818 assert_eq!(explicit_typed_error.origin(), ErrorOrigin::Executor);
4819 assert_eq!(
4820 explicit_typed_error.diagnostic_facts(),
4821 vec![
4822 (
4823 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4824 ENTITY_TAG.value(),
4825 ),
4826 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
4827 (
4828 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4829 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
4830 ),
4831 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,),
4832 ],
4833 );
4834
4835 let replace_error = session
4836 .execute_trusted_dynamic_mutation(&DynamicMutation::Replace {
4837 entity: ENTITY_NAME.to_string(),
4838 key: InputValue::Nat64(99),
4839 patch: DynamicStructuralPatch::new(vec![(
4840 "payload".to_string(),
4841 DynamicWriteCell::Value(InputValue::Nat64(60)),
4842 )]),
4843 })
4844 .expect_err("save-as-insert with a chosen Identity must reject");
4845 assert_eq!(replace_error.class(), ErrorClass::Unsupported);
4846 assert_eq!(replace_error.origin(), ErrorOrigin::Executor);
4847
4848 #[cfg(feature = "sql")]
4849 {
4850 for sql in [
4851 "INSERT INTO IdentityRow (payload) VALUES (70) RETURNING id, payload",
4852 "INSERT INTO IdentityRow (id, payload) VALUES (DEFAULT, 80) RETURNING id",
4853 ] {
4854 let _result = session
4855 .execute_trusted_sql_mutation(sql)
4856 .expect("SQL omission and DEFAULT should commit Identity generation");
4857 }
4858
4859 let error = session
4860 .execute_trusted_sql_mutation(
4861 "INSERT INTO IdentityRow (id, payload) VALUES (42, 90)",
4862 )
4863 .expect_err("an explicit SQL Identity value must reject before allocation");
4864 let diagnostic = error.diagnostic();
4865 assert_eq!(
4866 diagnostic.code(),
4867 icydb_diagnostic_code::DiagnosticCode::QuerySqlWriteBoundary,
4868 );
4869 assert!(matches!(
4870 diagnostic.detail(),
4871 Some(icydb_diagnostic_code::DiagnosticDetail::SqlWriteBoundary {
4872 boundary: icydb_diagnostic_code::SqlWriteBoundaryCode::ExplicitGeneratedField,
4873 }),
4874 ));
4875 }
4876
4877 let expected_committed = if cfg!(feature = "sql") { 7 } else { 5 };
4878 assert_eq!(
4879 DATA_STORE.with(|store| store.borrow().len()),
4880 expected_committed
4881 );
4882 SCHEMA_STORE.with(|store| {
4883 let cursor = store
4884 .borrow()
4885 .identity_statement_cursor(
4886 database_incarnation_id().expect("database incarnation should remain readable"),
4887 ENTITY_TAG,
4888 FieldId::new(1),
4889 &AcceptedFieldKind::Nat64,
4890 )
4891 .expect("committed writes must leave active state readable");
4892 assert_eq!(cursor.expected_high_water(), u128::from(expected_committed),);
4893 assert!(!cursor.has_allocations());
4894 });
4895 let committed_description = session
4896 .try_describe_entity_by_name(ENTITY_NAME)
4897 .expect("committed Identity description should resolve");
4898 let committed_identity = committed_description
4899 .identity()
4900 .expect("accepted Identity policy should remain described");
4901 assert_eq!(
4902 committed_identity.high_water(),
4903 u128::from(expected_committed),
4904 );
4905 assert_eq!(
4906 committed_identity.remaining(),
4907 u128::from(u64::MAX - expected_committed),
4908 );
4909 assert!(!committed_identity.exhausted());
4910 }
4911
4912 #[test]
4913 #[expect(
4914 clippy::too_many_lines,
4915 reason = "one ordered scenario proves target/progress atomicity, every interruption wake-up, state-only admission, and successful no-op wake-up behavior"
4916 )]
4917 fn mutation_progress_and_target_rows_recover_as_one_marker_transition() {
4918 let session = initialize_journaled();
4919 install_startup_recovery_wakeup(record_startup_wakeup);
4920 let catalog = session
4921 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
4922 .expect("journaled atomic-progress catalog should resolve");
4923 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
4924 .expect("journaled atomic-progress row layout should build");
4925
4926 for (ordinal, interruption) in [
4927 MutationCommitInterruption::MarkerPersisted,
4928 MutationCommitInterruption::JournalPublished,
4929 MutationCommitInterruption::RowsPublished,
4930 MutationCommitInterruption::ProgressReplaced,
4931 ]
4932 .into_iter()
4933 .enumerate()
4934 {
4935 let identity_byte = 31 + u8::try_from(ordinal).expect("small ordinal should fit");
4936 let (before, after, operation) = atomic_progress_fixture(identity_byte);
4937 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4938 match store.insert_mutation(&before)? {
4939 InsertMutationJobResult::Inserted => Ok(()),
4940 InsertMutationJobResult::Occupied(_) => {
4941 Err(crate::db::MutationJobError::IdentityConflict)
4942 }
4943 }
4944 })
4945 .expect("atomic predecessor should insert once");
4946
4947 let wakeups_before = STARTUP_WAKEUPS.with(Cell::get);
4948 interrupt_next_mutation_commit_for_tests(interruption);
4949 let interrupted = session.execute_accepted_structural_update_with_mutation_progress(
4950 &catalog,
4951 &descriptor,
4952 batch(&[700 + u64::try_from(ordinal).expect("small ordinal should fit")]),
4953 Timestamp::from_millis(17),
4954 operation,
4955 );
4956 assert!(
4957 interrupted.is_err(),
4958 "selected atomic boundary should interrupt"
4959 );
4960 assert_eq!(
4961 STARTUP_WAKEUPS.with(Cell::get),
4962 wakeups_before.saturating_add(1),
4963 "a normally returned retained-marker error must register its wake-up",
4964 );
4965
4966 forget_recovered_domain_for_tests(&session.db)
4967 .expect("interruption should reset volatile recovery ownership");
4968 let retained_before =
4969 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4970 store.load_mutation(before.state().job_id)
4971 })
4972 .expect("pre-driver progress should load");
4973 let row_count_before = JOURNALED_DATA_STORE.with(|store| store.borrow().len());
4974 let pending = session
4975 .db
4976 .ensure_recovered_state()
4977 .expect_err("ordinary admission must not drive retained-marker recovery");
4978 assert_eq!(
4979 pending.diagnostic().error_code(),
4980 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
4981 );
4982 assert_eq!(
4983 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4984 store.load_mutation(before.state().job_id)
4985 })
4986 .expect("post-admission progress should load"),
4987 retained_before,
4988 );
4989 assert_eq!(
4990 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
4991 row_count_before,
4992 "state-only admission must not mutate target rows",
4993 );
4994 assert!(
4995 session
4996 .db
4997 .drive_startup_recovery_page()
4998 .expect("dedicated driver should finish target and progress together"),
4999 );
5000 let retained = with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5001 store.load_mutation(before.state().job_id)
5002 })
5003 .expect("recovered successor should load");
5004 assert_eq!(retained, after);
5005 assert_eq!(
5006 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
5007 u64::try_from(ordinal + 1).expect("small row count should fit"),
5008 );
5009 }
5010
5011 let (before, after, operation) = atomic_progress_fixture(39);
5012 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5013 match store.insert_mutation(&before)? {
5014 InsertMutationJobResult::Inserted => Ok(()),
5015 InsertMutationJobResult::Occupied(_) => {
5016 Err(crate::db::MutationJobError::IdentityConflict)
5017 }
5018 }
5019 })
5020 .expect("final predecessor should insert once");
5021 let wakeups_before_success = STARTUP_WAKEUPS.with(Cell::get);
5022 session
5023 .execute_accepted_structural_update_with_mutation_progress(
5024 &catalog,
5025 &descriptor,
5026 batch(&[799]),
5027 Timestamp::from_millis(18),
5028 operation,
5029 )
5030 .expect("uninterrupted atomic transition should clear its marker");
5031 assert_eq!(
5032 STARTUP_WAKEUPS.with(Cell::get),
5033 wakeups_before_success.saturating_add(1),
5034 "a successful retained commit must request online convergence",
5035 );
5036 let retained = with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5037 store.load_mutation(before.state().job_id)
5038 })
5039 .expect("final successor should load");
5040 assert_eq!(retained, after);
5041 forget_recovered_domain_for_tests(&session.db)
5042 .expect("post-clear recovery ownership should reset");
5043 let pending = session
5044 .db
5045 .ensure_recovered_state()
5046 .expect_err("an upgrade epoch must remain gated until its driver runs");
5047 assert_eq!(
5048 pending.diagnostic().error_code(),
5049 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
5050 );
5051 assert!(
5052 session
5053 .db
5054 .drive_startup_recovery_page()
5055 .expect("post-clear driver recovery should fold the retained batch"),
5056 );
5057 }
5058
5059 #[test]
5060 fn mutation_progress_neither_side_mismatch_blocks_recovery() {
5061 let session = initialize_journaled();
5062 let catalog = session
5063 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5064 .expect("journaled corruption catalog should resolve");
5065 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5066 .expect("journaled corruption row layout should build");
5067 let (before, _after, operation) = atomic_progress_fixture(41);
5068 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5069 match store.insert_mutation(&before)? {
5070 InsertMutationJobResult::Inserted => Ok(()),
5071 InsertMutationJobResult::Occupied(_) => {
5072 Err(crate::db::MutationJobError::IdentityConflict)
5073 }
5074 }
5075 })
5076 .expect("corruption predecessor should insert once");
5077
5078 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::MarkerPersisted);
5079 assert!(
5080 session
5081 .execute_accepted_structural_update_with_mutation_progress(
5082 &catalog,
5083 &descriptor,
5084 batch(&[811]),
5085 Timestamp::from_millis(19),
5086 operation,
5087 )
5088 .is_err(),
5089 "marker interruption should retain recovery authority",
5090 );
5091 let (unexpected, _) = before
5092 .apply_transition(
5093 &MutationJobAdvanceRequest::new(
5094 before.state().job_id,
5095 0,
5096 MutationJobIdempotencyKey::new("unexpected-third-state")
5097 .expect("unexpected replay key should admit"),
5098 ),
5099 MutationJobTransition::new(
5100 MutationJobStatus::Active,
5101 MutationJobPhase::Forward,
5102 vec![99],
5103 2,
5104 0,
5105 0,
5106 ),
5107 )
5108 .expect("unexpected but valid progress state should admit");
5109 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5110 store.replace_mutation(&unexpected)
5111 })
5112 .expect("test should install the neither-side state");
5113
5114 forget_recovered_domain_for_tests(&session.db)
5115 .expect("corrupt recovery ownership should reset");
5116 let error = session
5117 .db
5118 .drive_startup_recovery_page()
5119 .expect_err("neither-side progress must block recovery");
5120 assert_eq!(error.class(), ErrorClass::Corruption);
5121 assert_eq!(error.origin(), ErrorOrigin::Recovery);
5122 assert_eq!(
5123 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5124 store.load_mutation(before.state().job_id)
5125 })
5126 .expect("unexpected state should remain inspectable to the test"),
5127 unexpected,
5128 );
5129 assert!(
5130 session.db.drive_startup_recovery_page().is_err(),
5131 "a retained corrupt marker must continue blocking database access",
5132 );
5133 }
5134
5135 #[test]
5136 #[expect(
5137 clippy::too_many_lines,
5138 reason = "one ordered scenario exercises every durable interruption boundary, guarded recovery, derived rebuild, and both integrity tiers"
5139 )]
5140 fn journaled_identity_recovery_quiesces_every_publication_interruption_before_reallocation() {
5141 let session = initialize_journaled();
5142 let catalog = session
5143 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5144 .expect("journaled identity catalog should resolve");
5145 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5146 .expect("journaled identity row layout should build");
5147
5148 for (ordinal, interruption) in [
5149 MutationCommitInterruption::MarkerPersisted,
5150 MutationCommitInterruption::JournalPublished,
5151 MutationCommitInterruption::RowsPublished,
5152 MutationCommitInterruption::StateMaterialized,
5153 ]
5154 .into_iter()
5155 .enumerate()
5156 {
5157 interrupt_next_mutation_commit_for_tests(interruption);
5158 let interrupted = session.execute_accepted_structural_save_batch(
5159 &catalog,
5160 &descriptor,
5161 batch(&[u64::try_from(ordinal).expect("ordinal should fit")]),
5162 Timestamp::from_millis(8),
5163 Ok,
5164 );
5165 assert!(
5166 interrupted.is_err(),
5167 "the selected durable boundary should interrupt",
5168 );
5169
5170 let Err(pending) = session.execute_accepted_structural_save_batch(
5171 &catalog,
5172 &descriptor,
5173 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
5174 Timestamp::from_millis(9),
5175 Ok,
5176 ) else {
5177 panic!("ordinary mutation must not drive retained-marker recovery");
5178 };
5179 assert_eq!(
5180 pending.diagnostic().error_code(),
5181 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
5182 );
5183 drive_journaled_recovery_to_completion(&session);
5184
5185 let committed = session
5186 .execute_accepted_structural_save_batch(
5187 &catalog,
5188 &descriptor,
5189 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
5190 Timestamp::from_millis(9),
5191 Ok,
5192 )
5193 .expect("the next mutation must recover before allocating");
5194 let expected_high_water =
5195 u64::try_from((ordinal + 1) * 2).expect("small test high-water should fit");
5196 assert_eq!(
5197 committed
5198 .into_iter()
5199 .map(|row| row.values)
5200 .collect::<Vec<_>>(),
5201 vec![vec![
5202 Value::Nat64(expected_high_water),
5203 Value::Nat64(100 + u64::try_from(ordinal).expect("ordinal should fit")),
5204 ]],
5205 );
5206 assert_eq!(
5207 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
5208 expected_high_water,
5209 );
5210 JOURNALED_SCHEMA_STORE.with(|store| {
5211 let cursor = store
5212 .borrow()
5213 .identity_statement_cursor(
5214 database_incarnation_id()
5215 .expect("database incarnation should remain readable"),
5216 ENTITY_TAG,
5217 FieldId::new(1),
5218 &AcceptedFieldKind::Nat64,
5219 )
5220 .expect("guarded recovery must leave quiescent active state");
5221 assert_eq!(
5222 cursor.expected_high_water(),
5223 u128::from(expected_high_water),
5224 );
5225 assert!(!cursor.has_allocations());
5226 });
5227 }
5228
5229 for (ordinal, (interruption, deleted_key)) in [
5230 (MutationCommitInterruption::MarkerPersisted, 2),
5231 (MutationCommitInterruption::JournalPublished, 4),
5232 (MutationCommitInterruption::RowPrefixPublished, 6),
5233 (MutationCommitInterruption::RowsPublished, 8),
5234 (MutationCommitInterruption::StateMaterialized, 7),
5235 ]
5236 .into_iter()
5237 .enumerate()
5238 {
5239 let expected_payload =
5240 501 + u64::try_from(ordinal).expect("small interruption ordinal should fit");
5241 interrupt_next_mutation_commit_for_tests(interruption);
5242 let interrupted = session.execute_trusted_dynamic_mutation_batch(vec![
5243 DynamicMutation::Update {
5244 entity: ENTITY_NAME.to_string(),
5245 key: InputValue::Nat64(1),
5246 patch: dynamic_payload_patch(expected_payload),
5247 },
5248 DynamicMutation::Delete {
5249 entity: ENTITY_NAME.to_string(),
5250 key: InputValue::Nat64(deleted_key),
5251 },
5252 ]);
5253 assert!(
5254 interrupted.is_err(),
5255 "the selected caller-key mixed publication boundary should interrupt",
5256 );
5257 let pending = session
5258 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
5259 entity: ENTITY_NAME.to_string(),
5260 key: InputValue::Nat64(1),
5261 patch: dynamic_payload_patch(expected_payload),
5262 })
5263 .expect_err("ordinary update must not drive retained-marker recovery");
5264 assert_eq!(
5265 pending.diagnostic().error_code(),
5266 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
5267 );
5268 drive_journaled_recovery_to_completion(&session);
5269 let recovered_update = session
5270 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
5271 entity: ENTITY_NAME.to_string(),
5272 key: InputValue::Nat64(1),
5273 patch: dynamic_payload_patch(expected_payload),
5274 })
5275 .expect("guarded reentry should complete the marker-authorized mixed batch");
5276 assert_eq!(
5277 recovered_update.affected_rows, 0,
5278 "the recovered update must already expose its admitted final image",
5279 );
5280 let recovered_delete = session
5281 .execute_trusted_dynamic_mutation(&DynamicMutation::Delete {
5282 entity: ENTITY_NAME.to_string(),
5283 key: InputValue::Nat64(deleted_key),
5284 })
5285 .expect_err("the recovered delete must already be materialized");
5286 assert_eq!(recovered_delete.class(), ErrorClass::NotFound);
5287 JOURNALED_SCHEMA_STORE.with(|store| {
5288 let cursor = store
5289 .borrow()
5290 .identity_statement_cursor(
5291 database_incarnation_id()
5292 .expect("database incarnation should remain readable"),
5293 ENTITY_TAG,
5294 FieldId::new(1),
5295 &AcceptedFieldKind::Nat64,
5296 )
5297 .expect("caller-key recovery must preserve active Identity state");
5298 assert_eq!(cursor.expected_high_water(), 8);
5299 assert!(!cursor.has_allocations());
5300 });
5301 }
5302
5303 forget_recovered_domain_for_tests(&session.db)
5304 .expect("the final journal tail should remain recoverable");
5305 session
5306 .db
5307 .drive_startup_recovery_page()
5308 .expect("derived rebuild must not allocate another identity");
5309
5310 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
5311 let index_generation = JOURNALED_INDEX_STORE.with(|store| store.borrow().generation());
5312 forget_recovered_domain_for_tests(&session.db)
5313 .expect("an empty-tail upgrade should reset recovery ownership");
5314 session
5315 .db
5316 .drive_startup_recovery_page()
5317 .expect("an empty-tail upgrade should admit without rebuilding stored rows or indexes");
5318 assert_eq!(
5319 JOURNALED_DATA_STORE.with(|store| store.borrow().generation()),
5320 data_generation,
5321 "empty-tail recovery must not traverse or rewrite authoritative rows",
5322 );
5323 assert_eq!(
5324 JOURNALED_INDEX_STORE.with(|store| store.borrow().generation()),
5325 index_generation,
5326 "empty-tail recovery must not clear or rebuild secondary indexes",
5327 );
5328
5329 let quick = execute_quick_integrity(
5330 &session.db,
5331 catalog.inspection_plan(),
5332 catalog.runtime_root_identity().database_incarnation(),
5333 )
5334 .expect("quiescent Identity control inventory should be inspectable");
5335 assert_eq!(quick.status(), &QuickIntegrityStatus::CompleteClean);
5336 let row_page = execute_row_integrity_page(
5337 &session.db,
5338 catalog.inspection_plan(),
5339 PhysicalUnitCheckpoint::BeforeFirst,
5340 RowInspectionLimits::standard(),
5341 )
5342 .expect("Identity rows should remain within committed high-water");
5343 assert!(row_page.exhausted());
5344 assert!(row_page.findings().is_empty());
5345
5346 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 3);
5347 assert!(
5348 JOURNALED_INDEX_STORE.with(|store| !store.borrow().is_empty()),
5349 "derived index rebuild should restore witnesses without allocating identities",
5350 );
5351 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5352 JOURNALED_SCHEMA_STORE.with(|store| {
5353 let cursor = store
5354 .borrow()
5355 .identity_statement_cursor(
5356 database_incarnation_id().expect("database incarnation should remain readable"),
5357 ENTITY_TAG,
5358 FieldId::new(1),
5359 &AcceptedFieldKind::Nat64,
5360 )
5361 .expect("folded identity state should reopen without allocating");
5362 assert_eq!(cursor.expected_high_water(), 8);
5363 assert!(!cursor.has_allocations());
5364 });
5365 }
5366
5367 #[test]
5368 fn journaled_online_convergence_drains_the_full_backlog_in_complete_batch_callbacks_without_reallocating_ids()
5369 {
5370 const SUBMISSION: &str = "generated/8899aabbccddeeff";
5371 let session = initialize_journaled();
5372 let catalog = session
5373 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5374 .expect("journaled identity catalog should resolve");
5375 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5376 .expect("journaled identity row layout should build");
5377
5378 for payload in 0_u64..38 {
5379 session
5380 .execute_accepted_structural_save_batch(
5381 &catalog,
5382 &descriptor,
5383 batch(&[payload]),
5384 Timestamp::from_millis(8),
5385 Ok,
5386 )
5387 .unwrap_or_else(|error| {
5388 panic!("journaled identity fixture row {payload} should commit: {error:?}")
5389 });
5390 }
5391
5392 let before = JOURNALED_TAIL_STORE.with(|tail| {
5393 tail.borrow()
5394 .current_tail_control()
5395 .expect("online backlog control should remain valid")
5396 });
5397 assert_eq!(before.batch_count(), 38);
5398 let next_sequence = crate::db::commit::next_database_commit_sequence()
5399 .expect("database sequence preview should remain readable");
5400 let Err(pressure) = session.execute_accepted_structural_save_batch(
5401 &catalog,
5402 &descriptor,
5403 batch(&[38]),
5404 Timestamp::from_millis(8),
5405 Ok,
5406 ) else {
5407 panic!("the exact cumulative batch ceiling should reject one more batch")
5408 };
5409 assert_exact_batch_backlog_pressure(&pressure, before, next_sequence);
5410
5411 for folded_batches in 1..=38 {
5412 let complete = session
5413 .db
5414 .drive_startup_recovery_page()
5415 .expect("online complete-batch callback should commit");
5416 assert_eq!(complete, folded_batches == 38);
5417 }
5418
5419 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5420 session
5421 .execute_accepted_structural_save_batch(
5422 &catalog,
5423 &descriptor,
5424 batch(&[38]),
5425 Timestamp::from_millis(8),
5426 Ok,
5427 )
5428 .expect("drain should make the rejected mutation retryable");
5429 assert!(
5430 session
5431 .db
5432 .drive_startup_recovery_page()
5433 .expect("the retry tail should converge"),
5434 );
5435
5436 assert_eq!(
5437 drive_generated_startup_recovery_page(&session, &JOURNALED_STORE_REGISTRY, SUBMISSION,)
5438 .expect("online convergence should commit"),
5439 GeneratedStartupDriverStep::Terminal,
5440 "the quiescent generated driver should stop",
5441 );
5442
5443 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 39);
5444 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5445 assert_dynamic_payload(&session, 1, 0);
5446 assert_dynamic_payload(&session, 39, 38);
5447 JOURNALED_SCHEMA_STORE.with(|store| {
5448 let cursor = store
5449 .borrow()
5450 .identity_statement_cursor(
5451 database_incarnation_id().expect("database incarnation should remain readable"),
5452 ENTITY_TAG,
5453 FieldId::new(1),
5454 &AcceptedFieldKind::Nat64,
5455 )
5456 .expect("online convergence must preserve active Identity state");
5457 assert_eq!(cursor.expected_high_water(), 39);
5458 assert!(!cursor.has_allocations());
5459 });
5460 }
5461
5462 #[test]
5463 fn journaled_online_convergence_reconstructs_same_key_batches_from_canonical_predecessors() {
5464 let session = initialize_journaled();
5465 session
5466 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5467 entity: ENTITY_NAME.to_string(),
5468 patch: dynamic_payload_patch(10),
5469 })
5470 .expect("the initial positioned row should commit");
5471 for payload in [20, 30] {
5472 session
5473 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
5474 entity: ENTITY_NAME.to_string(),
5475 key: InputValue::Nat64(1),
5476 patch: dynamic_payload_patch(payload),
5477 })
5478 .unwrap_or_else(|error| {
5479 panic!("the positioned same-key update should commit: {error:?}")
5480 });
5481 }
5482
5483 assert_dynamic_payload(&session, 1, 30);
5484 assert_eq!(
5485 JOURNALED_INDEX_STORE.with(|store| store.borrow().len()),
5486 1,
5487 "the newest live index effect should hide every predecessor",
5488 );
5489 for folded_batches in 1..=3 {
5490 let complete = session
5491 .db
5492 .drive_startup_recovery_page()
5493 .expect("the positioned same-key batch should converge");
5494 assert_eq!(complete, folded_batches == 3);
5495 }
5496
5497 assert_dynamic_payload(&session, 1, 30);
5498 assert_eq!(
5499 JOURNALED_INDEX_STORE.with(|store| store.borrow().len()),
5500 1,
5501 "canonical derived state must contain only the newest membership",
5502 );
5503 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5504 }
5505
5506 #[test]
5507 fn journaled_convergence_uses_final_batch_rows_for_unique_release() {
5508 let session = initialize_journaled_with_unique_payload();
5509 let inserted = session
5510 .execute_trusted_dynamic_insert_batch(
5511 ENTITY_NAME,
5512 vec![dynamic_payload_patch(10), dynamic_payload_patch(20)],
5513 )
5514 .expect("the unique journal fixture should commit");
5515 assert_eq!(
5516 inserted.rows,
5517 vec![expected_dynamic_row(1, 10), expected_dynamic_row(2, 20)],
5518 );
5519 assert!(
5520 session
5521 .db
5522 .drive_startup_recovery_page()
5523 .expect("the unique fixture should become canonical"),
5524 );
5525
5526 let swapped = session
5527 .execute_trusted_dynamic_mutation_batch(vec![
5528 DynamicMutation::Update {
5529 entity: ENTITY_NAME.to_string(),
5530 key: InputValue::Nat64(1),
5531 patch: dynamic_payload_patch(20),
5532 },
5533 DynamicMutation::Update {
5534 entity: ENTITY_NAME.to_string(),
5535 key: InputValue::Nat64(2),
5536 patch: dynamic_payload_patch(10),
5537 },
5538 ])
5539 .expect("one journal batch should admit a final-row unique swap");
5540 assert_eq!(
5541 swapped.rows,
5542 vec![expected_dynamic_row(1, 20), expected_dynamic_row(2, 10)],
5543 );
5544 assert!(
5545 session
5546 .db
5547 .drive_startup_recovery_page()
5548 .expect("the unique swap should converge in one complete batch"),
5549 );
5550
5551 let released = session
5552 .execute_trusted_dynamic_mutation_batch(vec![
5553 DynamicMutation::Delete {
5554 entity: ENTITY_NAME.to_string(),
5555 key: InputValue::Nat64(1),
5556 },
5557 DynamicMutation::Insert {
5558 entity: ENTITY_NAME.to_string(),
5559 patch: dynamic_payload_patch(20),
5560 },
5561 ])
5562 .expect("a journaled delete should release its unique value to the final insert");
5563 assert_eq!(
5564 released.rows,
5565 vec![expected_dynamic_row(1, 20), expected_dynamic_row(3, 20)],
5566 );
5567 assert!(
5568 session
5569 .db
5570 .drive_startup_recovery_page()
5571 .expect("the delete and unique reuse should converge together"),
5572 );
5573
5574 assert_dynamic_payload(&session, 2, 10);
5575 assert_dynamic_payload(&session, 3, 20);
5576 assert_eq!(JOURNALED_INDEX_STORE.with(|store| store.borrow().len()), 2);
5577 assert!(
5578 session
5579 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(20)],)
5580 .is_err(),
5581 "the converged unique index must remain authoritative",
5582 );
5583 }
5584
5585 #[test]
5586 fn journaled_startup_recovery_completes_one_large_batch_atomically() {
5587 let session = initialize_journaled();
5588 let catalog = session
5589 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5590 .expect("journaled identity catalog should resolve");
5591 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5592 .expect("journaled identity row layout should build");
5593 let payloads = (0_u64..129).collect::<Vec<_>>();
5594 session
5595 .execute_accepted_structural_save_batch(
5596 &catalog,
5597 &descriptor,
5598 batch(&payloads),
5599 Timestamp::from_millis(9),
5600 Ok,
5601 )
5602 .expect("one large journal batch should commit");
5603
5604 forget_recovered_domain_for_tests(&session.db)
5605 .expect("upgrade should reset recovery ownership");
5606 assert!(
5607 session
5608 .db
5609 .drive_startup_recovery_page()
5610 .expect("the complete batch recovery page should commit"),
5611 );
5612
5613 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 129);
5614 JOURNALED_TAIL_STORE.with(|tail| {
5615 let tail = tail.borrow();
5616 assert!(!tail.has_stored_batch());
5617 });
5618 assert_dynamic_payload(&session, 1, 0);
5619 assert_dynamic_payload(&session, 129, 128);
5620 }
5621
5622 #[test]
5623 fn complete_batch_validation_rejects_a_late_record_before_canonical_writes() {
5624 let session = initialize_journaled();
5625 let catalog = session
5626 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5627 .expect("journaled identity catalog should resolve");
5628 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5629 .expect("journaled identity row layout should build");
5630 session
5631 .execute_accepted_structural_save_batch(
5632 &catalog,
5633 &descriptor,
5634 batch(&[7]),
5635 Timestamp::from_millis(9),
5636 Ok,
5637 )
5638 .expect("journal batch predecessor should commit");
5639
5640 JOURNALED_TAIL_STORE.with(|tail| {
5641 let mut tail = tail.borrow_mut();
5642 let original = tail
5643 .next_batch_after(JournalSequence::new(0))
5644 .expect("journal batch should decode")
5645 .expect("journal batch should exist");
5646 let mut records = original.records().to_vec();
5647 records.push(
5648 JournalRecord::schema_put(JOURNALED_STORE_PATH, vec![0xff; 8])
5649 .expect("bounded semantic corruption should build"),
5650 );
5651 let corrupted = JournalBatch::new_with_database_commit_sequence(
5652 original.batch_id(),
5653 original.commit_marker_id(),
5654 original.journal_sequence(),
5655 original.database_commit_sequence(),
5656 records,
5657 )
5658 .expect("current corrupt batch shape should build");
5659 let encoded = encode_journal_batch(&corrupted)
5660 .expect("current corrupt batch envelope should encode");
5661 tail.clear_batches_through(original.journal_sequence());
5662 tail.insert_raw_batch_for_tests(original.journal_sequence(), encoded)
5663 .expect("corrupt persisted batch should replace the predecessor");
5664 });
5665
5666 forget_recovered_domain_for_tests(&session.db)
5667 .expect("upgrade should reset recovery ownership");
5668 let error = session
5669 .db
5670 .drive_startup_recovery_page()
5671 .expect_err("late semantic corruption must fail before fold apply");
5672 assert_eq!(error.class(), ErrorClass::Corruption);
5673 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 0);
5674 JOURNALED_TAIL_STORE.with(|tail| {
5675 let tail = tail.borrow();
5676 assert_eq!(
5677 tail.fold_watermark()
5678 .expect("watermark should remain readable")
5679 .highest_folded_journal_sequence(),
5680 JournalSequence::new(0),
5681 );
5682 assert!(tail.has_stored_batch());
5683 });
5684 }
5685
5686 #[test]
5687 fn prepared_batch_row_evidence_rejects_a_late_malformed_row_before_canonical_writes() {
5688 let session = initialize_journaled();
5689 let catalog = session
5690 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5691 .expect("journaled identity catalog should resolve");
5692 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5693 .expect("journaled identity row layout should build");
5694 session
5695 .execute_accepted_structural_save_batch(
5696 &catalog,
5697 &descriptor,
5698 batch(&[7, 8]),
5699 Timestamp::from_millis(9),
5700 Ok,
5701 )
5702 .expect("two-row journal batch should commit");
5703
5704 JOURNALED_TAIL_STORE.with(|tail| {
5705 let mut tail = tail.borrow_mut();
5706 let original = tail
5707 .next_batch_after(JournalSequence::new(0))
5708 .expect("journal batch should decode")
5709 .expect("journal batch should exist");
5710 let mut records = original.records().to_vec();
5711 let mut row_ordinal = 0_u8;
5712 for record in &mut records {
5713 if let JournalRecord::RowPut { row_bytes, .. } = record {
5714 row_ordinal = row_ordinal.saturating_add(1);
5715 if row_ordinal == 2 {
5716 *row_bytes = vec![0xff; 8];
5717 break;
5718 }
5719 }
5720 }
5721 assert_eq!(row_ordinal, 2, "the late row record should be present");
5722 let corrupted = JournalBatch::new_with_database_commit_sequence(
5723 original.batch_id(),
5724 original.commit_marker_id(),
5725 original.journal_sequence(),
5726 original.database_commit_sequence(),
5727 records,
5728 )
5729 .expect("current corrupt batch shape should build");
5730 let encoded = encode_journal_batch(&corrupted)
5731 .expect("current corrupt batch envelope should encode");
5732 tail.clear_batches_through(original.journal_sequence());
5733 tail.insert_raw_batch_for_tests(original.journal_sequence(), encoded)
5734 .expect("corrupt persisted batch should replace the predecessor");
5735 });
5736
5737 forget_recovered_domain_for_tests(&session.db)
5738 .expect("upgrade should reset recovery ownership");
5739 let error = session
5740 .db
5741 .drive_startup_recovery_page()
5742 .expect_err("late malformed row must fail during complete batch preparation");
5743 assert_eq!(error.class(), ErrorClass::Corruption);
5744 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 0);
5745 JOURNALED_TAIL_STORE.with(|tail| {
5746 let tail = tail.borrow();
5747 assert_eq!(
5748 tail.fold_watermark()
5749 .expect("watermark should remain readable")
5750 .highest_folded_journal_sequence(),
5751 JournalSequence::new(0),
5752 );
5753 assert!(tail.has_stored_batch());
5754 });
5755 }
5756
5757 #[test]
5758 #[ignore = "release-closeout native timing probe for one marker-authorized driver recovery"]
5759 fn identity_recovery_closeout_reports_driver_time() {
5760 let session = initialize_journaled();
5761 let catalog = session
5762 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5763 .expect("journaled identity catalog should resolve");
5764 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5765 .expect("journaled identity row layout should build");
5766
5767 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::RowsPublished);
5768 let interrupted = session.execute_accepted_structural_save_batch(
5769 &catalog,
5770 &descriptor,
5771 batch(&[1]),
5772 Timestamp::from_millis(10),
5773 Ok,
5774 );
5775 assert!(
5776 interrupted.is_err(),
5777 "the selected publication boundary should interrupt",
5778 );
5779
5780 let start = Instant::now();
5781 assert!(
5782 session
5783 .db
5784 .drive_startup_recovery_page()
5785 .expect("dedicated driver should recover before allocation"),
5786 );
5787 let committed = session
5788 .execute_accepted_structural_save_batch(
5789 &catalog,
5790 &descriptor,
5791 batch(&[2]),
5792 Timestamp::from_millis(11),
5793 Ok,
5794 )
5795 .expect("post-recovery allocation should commit");
5796 let elapsed = start.elapsed();
5797 assert_eq!(
5798 committed
5799 .into_iter()
5800 .map(|row| row.values)
5801 .collect::<Vec<_>>(),
5802 vec![vec![Value::Nat64(2), Value::Nat64(2)]],
5803 );
5804
5805 println!(
5806 "identity recovery closeout: driver_nanos={}",
5807 elapsed.as_nanos(),
5808 );
5809 }
5810}
5811
5812#[cfg(test)]
5813mod targeted_rule_mutation_tests {
5814 use super::{
5815 DbSession, DynamicMutation, DynamicStructuralPatch, DynamicTypedFieldBindingRequest,
5816 DynamicTypedFieldType, DynamicTypedMutation, DynamicWriteCell,
5817 };
5818 use crate::{
5819 db::{
5820 data::{DataStore, encode_input_value_for_candidate_field_contract},
5821 index::IndexStore,
5822 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
5823 schema::{
5824 AcceptedCheckLiteralV1, AcceptedCompositeCatalog, AcceptedFieldDecodeContract,
5825 AcceptedFieldKind, AcceptedNamedTypeIdentity, AcceptedRuleOperation,
5826 AcceptedRuleTarget, AcceptedSchemaRevision, AcceptedSourceBindingCatalog,
5827 ConstraintOrigin, FieldId, FieldStorageDecode, FieldWriteManagement, LeafCodec,
5828 PersistedFieldSnapshot, PersistedNestedLeafSnapshot, PersistedSchemaSnapshot,
5829 ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy, SchemaInsertDefault,
5830 SchemaRowLayout, SchemaStore, SchemaVersion,
5831 accepted_schema_candidate_with_catalogs_for_tests,
5832 build_record_newtype_composite_catalog_for_tests,
5833 empty_accepted_enum_catalog_for_tests, enum_catalog::ValueAdmissionBudget,
5834 },
5835 },
5836 error::InternalError,
5837 traits::{CanisterKind, Path},
5838 types::EntityTag,
5839 value::InputValue,
5840 };
5841 use icydb_schema::{
5842 ConstraintSourceKey, EntitySourceKey, FieldSourceKey, ScalarType, TypeSourceKey,
5843 };
5844 use std::{cell::RefCell, collections::BTreeMap};
5845
5846 const STORE_PATH: &str = "session::write::targeted_rule_mutation_tests::Store";
5847 const ENTITY_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity";
5848 const ID_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::id";
5849 const PROFILE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::profile";
5850 const UPDATED_AT_SOURCE: &str =
5851 "session::write::targeted_rule_mutation_tests::Entity::updated_at";
5852 const PROFILE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Profile";
5853 const DEGREE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Degree";
5854 const DEGREE_MEMBER_SOURCE: &str =
5855 "session::write::targeted_rule_mutation_tests::Profile::degree";
5856 const DEGREE_RULE_SOURCE: &str =
5857 "session::write::targeted_rule_mutation_tests::Profile::degree_multiple";
5858
5859 struct TestCanister;
5860
5861 impl Path for TestCanister {
5862 const PATH: &'static str = "session::write::targeted_rule_mutation_tests::Canister";
5863 }
5864
5865 impl CanisterKind for TestCanister {
5866 const COMMIT_MEMORY_ID: u8 = 43;
5867 const COMMIT_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.commit.v1";
5868 const STARTUP_MEMORY_ID: u8 = 49;
5869 const STARTUP_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.startup.control.v1";
5870 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 44;
5871 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
5872 "icydb.targeted_mutation_tests.integrity.progress.v1";
5873 }
5874
5875 thread_local! {
5876 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
5877 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
5878 static SCHEMA_STORE: RefCell<SchemaStore> =
5879 const { RefCell::new(SchemaStore::init_heap()) };
5880 static STORE_REGISTRY: StoreRegistry = {
5881 let mut registry = StoreRegistry::new();
5882 registry.register_store(
5883 STORE_PATH,
5884 &DATA_STORE,
5885 &INDEX_STORE,
5886 &SCHEMA_STORE,
5887 StoreAllocationIdentities::absent(),
5888 StoreRuntimeStorageCapabilities::heap(),
5889 ).expect("targeted mutation test store should register");
5890 registry
5891 };
5892 }
5893
5894 fn source<T, E: std::fmt::Debug>(raw: &str, parse: impl FnOnce(String) -> Result<T, E>) -> T {
5895 parse(raw.to_string()).expect("test source identity should admit")
5896 }
5897
5898 fn profile_input(degree: u64) -> InputValue {
5899 InputValue::Map(vec![(
5900 InputValue::Text("degree".to_string()),
5901 InputValue::Nat64(degree),
5902 )])
5903 }
5904
5905 fn structural_patch(id: u64, degree: u64) -> DynamicStructuralPatch {
5906 DynamicStructuralPatch::new(vec![
5907 (
5908 "id".to_string(),
5909 DynamicWriteCell::Value(InputValue::Nat64(id)),
5910 ),
5911 (
5912 "profile".to_string(),
5913 DynamicWriteCell::Value(profile_input(degree)),
5914 ),
5915 ])
5916 }
5917
5918 fn encoded_value(
5919 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
5920 composite_catalog: &AcceptedCompositeCatalog,
5921 name: &str,
5922 kind: &AcceptedFieldKind,
5923 storage_decode: FieldStorageDecode,
5924 leaf_codec: LeafCodec,
5925 value: InputValue,
5926 ) -> Vec<u8> {
5927 let field = AcceptedFieldDecodeContract::new(name, kind, false, storage_decode, leaf_codec);
5928 encode_input_value_for_candidate_field_contract(
5929 enum_catalog,
5930 composite_catalog,
5931 field,
5932 value,
5933 &mut ValueAdmissionBudget::standard(),
5934 )
5935 .expect("test accepted value should encode")
5936 }
5937
5938 fn nat64_literal(
5939 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
5940 composite_catalog: &AcceptedCompositeCatalog,
5941 value: u64,
5942 ) -> AcceptedCheckLiteralV1 {
5943 let kind = AcceptedFieldKind::Nat64;
5944 AcceptedCheckLiteralV1::from_accepted_parts(
5945 kind.clone(),
5946 FieldStorageDecode::ByKind,
5947 LeafCodec::Scalar(ScalarCodec::Nat64),
5948 encoded_value(
5949 enum_catalog,
5950 composite_catalog,
5951 "degree_bound",
5952 &kind,
5953 FieldStorageDecode::ByKind,
5954 LeafCodec::Scalar(ScalarCodec::Nat64),
5955 InputValue::Nat64(value),
5956 ),
5957 )
5958 }
5959
5960 fn targeted_constraint_id(error: &InternalError) -> u32 {
5961 let facts = error.diagnostic_facts();
5962 assert!(facts.contains(&(
5963 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
5964 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
5965 )));
5966 assert!(facts.contains(&(icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,)));
5967 assert!(facts.contains(&(
5968 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
5969 icydb_diagnostic_code::DiagnosticConstraintKind::TargetedRule.raw(),
5970 )));
5971 assert_eq!(
5972 facts
5973 .iter()
5974 .filter(|(tag, _)| matches!(
5975 tag,
5976 icydb_diagnostic_code::DiagnosticFactTag::RootField
5977 | icydb_diagnostic_code::DiagnosticFactTag::RecordMember
5978 ))
5979 .copied()
5980 .collect::<Vec<_>>(),
5981 vec![
5982 (icydb_diagnostic_code::DiagnosticFactTag::RootField, 2),
5983 (
5984 icydb_diagnostic_code::DiagnosticFactTag::RecordMember,
5985 icydb_diagnostic_code::pack_u32_pair(1, 1),
5986 ),
5987 ]
5988 );
5989 let value = facts
5990 .iter()
5991 .find_map(|(tag, value)| {
5992 (*tag == icydb_diagnostic_code::DiagnosticFactTag::ConstraintId).then_some(*value)
5993 })
5994 .expect("targeted mutation should retain its accepted constraint ID");
5995 u32::try_from(value).expect("accepted constraint ID fits u32")
5996 }
5997
5998 #[expect(
5999 clippy::too_many_lines,
6000 reason = "one end-to-end fixture proves every maintained write frontend converges on the same accepted targeted-rule schedule"
6001 )]
6002 #[test]
6003 fn targeted_rules_converge_across_dynamic_typed_sql_default_timestamp_and_batch_writes() {
6004 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
6005 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
6006 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
6007
6008 let entity_tag = EntityTag::new(93);
6009 let enum_catalog = empty_accepted_enum_catalog_for_tests();
6010 let (composite_catalog, profile_type, degree_type, degree_member) =
6011 build_record_newtype_composite_catalog_for_tests(
6012 "tests::TargetedProfile".to_string(),
6013 "degree".to_string(),
6014 "tests::TargetedDegree".to_string(),
6015 AcceptedFieldKind::Nat64,
6016 &enum_catalog,
6017 )
6018 .expect("targeted mutation composites should close");
6019 let profile_kind = AcceptedFieldKind::Composite {
6020 type_id: profile_type,
6021 };
6022 let profile_default = encoded_value(
6023 &enum_catalog,
6024 &composite_catalog,
6025 "profile",
6026 &profile_kind,
6027 FieldStorageDecode::CatalogValue,
6028 LeafCodec::Structural,
6029 profile_input(12),
6030 );
6031 let fields = vec![
6032 PersistedFieldSnapshot::new_initial(
6033 FieldId::new(1),
6034 "id".to_string(),
6035 SchemaFieldSlot::new(0),
6036 AcceptedFieldKind::Nat64,
6037 Vec::new(),
6038 false,
6039 SchemaInsertDefault::None,
6040 FieldStorageDecode::ByKind,
6041 LeafCodec::Scalar(ScalarCodec::Nat64),
6042 ),
6043 PersistedFieldSnapshot::new_initial(
6044 FieldId::new(2),
6045 "profile".to_string(),
6046 SchemaFieldSlot::new(1),
6047 profile_kind,
6048 vec![PersistedNestedLeafSnapshot::new(
6049 vec!["degree".to_string()],
6050 AcceptedFieldKind::Composite {
6051 type_id: degree_type,
6052 },
6053 false,
6054 )],
6055 false,
6056 SchemaInsertDefault::SlotPayload(profile_default),
6057 FieldStorageDecode::CatalogValue,
6058 LeafCodec::Structural,
6059 ),
6060 PersistedFieldSnapshot::new_initial_with_write_policy(
6061 FieldId::new(3),
6062 "updated_at".to_string(),
6063 SchemaFieldSlot::new(2),
6064 AcceptedFieldKind::Timestamp,
6065 Vec::new(),
6066 false,
6067 SchemaInsertDefault::None,
6068 SchemaFieldWritePolicy::from_model_policies(
6069 None,
6070 Some(FieldWriteManagement::UpdatedAt),
6071 ),
6072 FieldStorageDecode::ByKind,
6073 LeafCodec::Scalar(ScalarCodec::Timestamp),
6074 ),
6075 ];
6076 let mut snapshot = PersistedSchemaSnapshot::new(
6077 SchemaVersion::initial(),
6078 ENTITY_SOURCE.to_string(),
6079 "TargetedMutation".to_string(),
6080 FieldId::new(1),
6081 SchemaRowLayout::initial(
6082 fields
6083 .iter()
6084 .map(|field| (field.id(), field.slot()))
6085 .collect(),
6086 ),
6087 fields,
6088 );
6089 let constraint_catalog = snapshot
6090 .constraint_catalog()
6091 .clone()
6092 .with_added_targeted_rule(
6093 "profile_degree_multiple".to_string(),
6094 ConstraintOrigin::Generated,
6095 AcceptedRuleTarget::new(
6096 FieldId::new(2),
6097 AcceptedNamedTypeIdentity::Composite(degree_type),
6098 ),
6099 AcceptedRuleOperation::MultipleOf {
6100 divisor: nat64_literal(&enum_catalog, &composite_catalog, 5),
6101 },
6102 )
6103 .expect("targeted mutation rule should allocate");
6104 let targeted_rule_id = constraint_catalog
6105 .constraints()
6106 .last()
6107 .expect("targeted mutation rule should persist")
6108 .id();
6109 snapshot = snapshot.with_constraint_catalog(constraint_catalog);
6110
6111 let entity_source = source(ENTITY_SOURCE, EntitySourceKey::try_new);
6112 let id_source = source(ID_SOURCE, FieldSourceKey::try_new);
6113 let profile_source = source(PROFILE_SOURCE, FieldSourceKey::try_new);
6114 let updated_at_source = source(UPDATED_AT_SOURCE, FieldSourceKey::try_new);
6115 let profile_type_source = source(PROFILE_TYPE_SOURCE, TypeSourceKey::try_new);
6116 let degree_type_source = source(DEGREE_TYPE_SOURCE, TypeSourceKey::try_new);
6117 let degree_member_source = source(DEGREE_MEMBER_SOURCE, FieldSourceKey::try_new);
6118 let degree_rule_source = source(DEGREE_RULE_SOURCE, ConstraintSourceKey::try_new);
6119 let source_bindings = AcceptedSourceBindingCatalog::initial_for_tests(
6120 BTreeMap::from([(entity_source, entity_tag)]),
6121 BTreeMap::from([
6122 ((entity_tag, id_source), FieldId::new(1)),
6123 ((entity_tag, profile_source), FieldId::new(2)),
6124 ((entity_tag, updated_at_source), FieldId::new(3)),
6125 ]),
6126 BTreeMap::from([((entity_tag, degree_rule_source), targeted_rule_id)]),
6127 BTreeMap::new(),
6128 BTreeMap::new(),
6129 )
6130 .with_initial_named_types_for_tests(
6131 BTreeMap::from([
6132 (
6133 profile_type_source,
6134 AcceptedNamedTypeIdentity::Composite(profile_type),
6135 ),
6136 (
6137 degree_type_source,
6138 AcceptedNamedTypeIdentity::Composite(degree_type),
6139 ),
6140 ]),
6141 BTreeMap::new(),
6142 BTreeMap::from([((profile_type, degree_member_source), degree_member)]),
6143 );
6144 let candidate = accepted_schema_candidate_with_catalogs_for_tests(
6145 STORE_PATH,
6146 AcceptedSchemaRevision::INITIAL,
6147 enum_catalog,
6148 composite_catalog,
6149 source_bindings,
6150 BTreeMap::from([(entity_tag, snapshot)]),
6151 );
6152
6153 let session = DbSession::<TestCanister>::new(
6154 &STORE_REGISTRY,
6155 &crate::db::RequestExecutionRoot::__new_runtime_root(),
6156 );
6157 session
6158 .db
6159 .drive_startup_recovery_page()
6160 .expect("targeted mutation test database should initialize");
6161 let store = session
6162 .db
6163 .store_handle(STORE_PATH)
6164 .expect("targeted mutation test store should resolve");
6165 crate::db::commit::publish_accepted_schema_candidate(
6166 STORE_PATH,
6167 store,
6168 AcceptedSchemaRevision::NONE,
6169 &candidate,
6170 )
6171 .expect("targeted mutation candidate should publish");
6172
6173 let dynamic_error = session
6174 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
6175 entity: "TargetedMutation".to_string(),
6176 patch: structural_patch(1, 12),
6177 })
6178 .expect_err("dynamic write must enforce the targeted rule");
6179 assert_eq!(
6180 targeted_constraint_id(&dynamic_error),
6181 targeted_rule_id.get()
6182 );
6183
6184 let binding = session
6185 .issue_typed_entity_binding(
6186 ENTITY_SOURCE,
6187 &[
6188 DynamicTypedFieldBindingRequest::new(
6189 ID_SOURCE.to_string(),
6190 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
6191 false,
6192 ),
6193 DynamicTypedFieldBindingRequest::new(
6194 PROFILE_SOURCE.to_string(),
6195 DynamicTypedFieldType::Named(PROFILE_TYPE_SOURCE.to_string()),
6196 false,
6197 ),
6198 DynamicTypedFieldBindingRequest::new(
6199 UPDATED_AT_SOURCE.to_string(),
6200 DynamicTypedFieldType::Scalar(ScalarType::Timestamp),
6201 false,
6202 ),
6203 ],
6204 )
6205 .expect("targeted typed binding should issue");
6206 let typed_patch = binding
6207 .bind_write_fields(vec![
6208 (
6209 ID_SOURCE.to_string(),
6210 DynamicWriteCell::Value(InputValue::Nat64(2)),
6211 ),
6212 (
6213 PROFILE_SOURCE.to_string(),
6214 DynamicWriteCell::Value(profile_input(12)),
6215 ),
6216 ])
6217 .expect("targeted typed patch should bind");
6218 let typed_error = session
6219 .execute_trusted_typed_mutation(
6220 &binding,
6221 &DynamicTypedMutation::Insert { patch: typed_patch },
6222 )
6223 .expect_err("typed write must enforce the targeted rule");
6224 assert_eq!(targeted_constraint_id(&typed_error), targeted_rule_id.get());
6225
6226 #[cfg(feature = "sql")]
6227 {
6228 let sql_error = session
6229 .execute_trusted_sql_mutation("INSERT INTO TargetedMutation (id) VALUES (3)")
6230 .expect_err("SQL default resolution must enforce the targeted rule");
6231 let crate::db::QueryError::Execute(execute) = sql_error else {
6232 panic!("targeted SQL write should fail at shared execution admission");
6233 };
6234 assert_eq!(
6235 targeted_constraint_id(execute.as_internal()),
6236 targeted_rule_id.get()
6237 );
6238 }
6239
6240 session
6241 .execute_trusted_dynamic_mutation_batch(vec![
6242 DynamicMutation::Insert {
6243 entity: "TargetedMutation".to_string(),
6244 patch: structural_patch(4, 5),
6245 },
6246 DynamicMutation::Insert {
6247 entity: "TargetedMutation".to_string(),
6248 patch: structural_patch(5, 12),
6249 },
6250 ])
6251 .expect_err("one invalid targeted value must reject the whole batch");
6252 assert_eq!(
6253 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
6254 Some(0),
6255 "no frontend or earlier valid batch row may escape targeted admission",
6256 );
6257
6258 let admitted = session
6259 .execute_trusted_dynamic_mutation_batch(vec![
6260 DynamicMutation::Insert {
6261 entity: "TargetedMutation".to_string(),
6262 patch: structural_patch(6, 5),
6263 },
6264 DynamicMutation::Insert {
6265 entity: "TargetedMutation".to_string(),
6266 patch: structural_patch(7, 10),
6267 },
6268 ])
6269 .expect("compliant targeted values should share one accepted batch");
6270 let [first, second] = admitted.rows.as_slice() else {
6271 panic!("the mixed targeted batch should return two rows");
6272 };
6273 let first_timestamp = first
6274 .get(2)
6275 .expect("the first mixed row should contain its managed timestamp");
6276 assert!(matches!(
6277 first_timestamp,
6278 crate::value::OutputValue::Timestamp(_)
6279 ));
6280 assert_eq!(
6281 second.get(2),
6282 Some(first_timestamp),
6283 "one accepted mixed batch must materialize one managed timestamp",
6284 );
6285 assert_eq!(
6286 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
6287 Some(2),
6288 );
6289 }
6290}