1use crate::{
7 db::{
8 Db,
9 codec::{
10 finalize_hash_sha256, new_hash_sha256_prefixed, write_hash_len_u32, write_hash_str_u32,
11 write_hash_tag_u8, write_hash_u64,
12 },
13 commit::{
14 AcceptedSchemaPublication, database_incarnation_id, ensure_recovered,
15 publish_accepted_schema_candidates_with_application_record,
16 publish_generated_row_local_abort_with_application_record,
17 },
18 data::DataStore,
19 index::{IndexState, IndexStore},
20 registry::{
21 StoreAllocationIdentity, StoreAllocationIdentityCapability, StoreCommitParticipation,
22 StoreDurability, StoreHandle, StoreRecoveryCapability, StoreRelationSourceCapability,
23 StoreRelationTargetCapability, StoreRuntimeStorageMode, StoreSchemaMetadataCapability,
24 },
25 relation::prove_empty_reverse_relation_domain,
26 schema::{
27 AcceptedSchemaRevision, AcceptedSchemaRevisionBundle, CandidateSchemaRevision,
28 ConstraintActivationKind, ConstraintActivationState, ConstraintId, ConstraintOrigin,
29 ConstraintValidationPhase, ConstraintValidationProgress, ExistingProposalStore,
30 MAX_IDENTITY_STATE_RECORDS_PER_DATABASE, ProposalStoreTarget, SchemaApplicationRecord,
31 SchemaApplicationRecordOp, SchemaChangeActivation, SchemaChangeJob, SchemaChangeJobId,
32 SchemaChangeOutcome, SchemaChangeProgress, SchemaChangeProgressStatus,
33 SchemaChangeReceipt, SchemaChangeValidationPhase, StagedUserIndexDomainError,
34 UnpublishedRowLocalValidation, advance_accepted_row_local_constraint_activation,
35 constraint_validation_finding_diagnostic, derive_schema_change_job_id,
36 lower_existing_schema_proposal, lower_initial_schema_proposal,
37 prove_empty_user_index_domain, validate_unpublished_row_local_candidate_bounded,
38 with_schema_application_store,
39 },
40 },
41 error::InternalError,
42 traits::CanisterKind,
43 types::EntityTag,
44};
45use candid::CandidType;
46use icydb_schema::{
47 ExpectedAcceptedHead, ExpectedSchemaFingerprint, SchemaProposal, SchemaProposalDigest,
48 SchemaSubmissionKey, TargetDatabaseIdentity, TargetStoreIdentity,
49};
50use serde::Deserialize;
51use sha2::Digest;
52
53const DATABASE_TARGET_FINGERPRINT_PROFILE: &[u8] = b"icydb.schema-target.database.v1";
54const STORE_TARGET_FINGERPRINT_PROFILE: &[u8] = b"icydb.schema-target.store.v1";
55const ACCEPTED_DATABASE_HEAD_FINGERPRINT_PROFILE: &[u8] = b"icydb.accepted-schema.database-head.v1";
56
57#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
65pub struct SchemaApplicationStore {
66 path: String,
67 identity: TargetStoreIdentity,
68}
69
70impl SchemaApplicationStore {
71 #[must_use]
73 pub const fn path(&self) -> &str {
74 self.path.as_str()
75 }
76
77 #[must_use]
79 pub const fn identity(&self) -> TargetStoreIdentity {
80 self.identity
81 }
82}
83
84#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
93pub struct SchemaApplicationTarget {
94 database_identity: TargetDatabaseIdentity,
95 accepted_head: ExpectedAcceptedHead,
96 stores: Vec<SchemaApplicationStore>,
97}
98
99impl SchemaApplicationTarget {
100 #[must_use]
102 pub const fn database_identity(&self) -> TargetDatabaseIdentity {
103 self.database_identity
104 }
105
106 #[must_use]
108 pub const fn accepted_head(&self) -> &ExpectedAcceptedHead {
109 &self.accepted_head
110 }
111
112 #[must_use]
114 pub const fn stores(&self) -> &[SchemaApplicationStore] {
115 self.stores.as_slice()
116 }
117}
118
119#[derive(Clone, Copy)]
127struct StoreApplicationAuthority {
128 path: &'static str,
129 handle: StoreHandle,
130}
131
132struct PendingApplicationAbort {
134 authority: StoreApplicationAuthority,
135 current: AcceptedSchemaRevisionBundle,
136 entity_tag: EntityTag,
137 constraint_id: ConstraintId,
138 remove_validation_job: bool,
139}
140
141#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149struct AcceptedStoreHead {
150 revision: u64,
151 fingerprint: [u8; 32],
152}
153
154#[derive(Clone)]
156struct DirectGeneratedRowLocalProof {
157 candidate_index: usize,
158 store: StoreHandle,
159 store_path: &'static str,
160 entity_tag: crate::types::EntityTag,
161 entity_path: String,
162 constraint_id: ConstraintId,
163 historical_rows: u64,
164}
165
166#[derive(Clone)]
168struct PendingGeneratedRowLocalConstraint {
169 proof: DirectGeneratedRowLocalProof,
170}
171
172struct LoweredApplication {
174 current_bundles: Vec<Option<AcceptedSchemaRevisionBundle>>,
175 candidates: Vec<CandidateSchemaRevision>,
176 pending: Option<PendingGeneratedRowLocalConstraint>,
177}
178
179pub(in crate::db) fn schema_application_target<C: CanisterKind>(
181 db: &Db<C>,
182) -> Result<SchemaApplicationTarget, InternalError> {
183 ensure_recovered(db)?;
184 let incarnation = database_incarnation_id()?;
185 let mut stores = db.with_store_registry(|registry| {
186 registry
187 .iter()
188 .map(|(path, handle)| StoreApplicationAuthority { path, handle })
189 .collect::<Vec<_>>()
190 });
191 stores.sort_by(|left, right| left.path.cmp(right.path));
192
193 let database_identity = derive_database_identity(incarnation.to_bytes(), stores.as_slice());
194 let mut accepted_heads = Vec::with_capacity(stores.len());
195 let mut application_stores = Vec::with_capacity(stores.len());
196 for store in &stores {
197 let root = store
198 .handle
199 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
200 .map(|selection| AcceptedStoreHead {
201 revision: selection.root().revision().get(),
202 fingerprint: selection.root().fingerprint().as_bytes(),
203 });
204 accepted_heads.push((store.path, root));
205 application_stores.push(SchemaApplicationStore {
206 path: store.path.to_string(),
207 identity: derive_store_identity(database_identity, store),
208 });
209 }
210
211 Ok(SchemaApplicationTarget {
212 database_identity,
213 accepted_head: derive_accepted_head(accepted_heads.as_slice()),
214 stores: application_stores,
215 })
216}
217
218pub(in crate::db) fn schema_application_receipt<C: CanisterKind>(
221 db: &Db<C>,
222 database_identity: TargetDatabaseIdentity,
223 submission_key: &SchemaSubmissionKey,
224) -> Result<Option<SchemaChangeReceipt>, InternalError> {
225 ensure_recovered(db)?;
226 with_schema_application_store(|store| {
227 store
228 .load(database_identity, submission_key)
229 .map(|record| record.map(|record| record.receipt().clone()))
230 })
231}
232
233fn exact_schema_application_receipt(
234 proposal: &SchemaProposal,
235 proposal_digest: SchemaProposalDigest,
236) -> Result<Option<SchemaChangeReceipt>, InternalError> {
237 let Some(record) = with_schema_application_store(|store| {
238 store.load(proposal.target_database(), proposal.submission_key())
239 })?
240 else {
241 return Ok(None);
242 };
243 let receipt = record.receipt();
244 if !receipt.is_exact_submission(
245 proposal.target_database(),
246 proposal.submission_key(),
247 proposal_digest,
248 proposal.expected_head(),
249 ) {
250 return Err(InternalError::schema_application_conflict());
251 }
252 Ok(Some(receipt.clone()))
253}
254
255pub(in crate::db) fn continue_schema_application<C: CanisterKind>(
258 db: &Db<C>,
259 job_id: SchemaChangeJobId,
260 acknowledged_receipt: Option<u64>,
261) -> Result<SchemaChangeProgress, InternalError> {
262 ensure_recovered(db)?;
263 let record = with_schema_application_store(|store| store.load_job(job_id))?
264 .ok_or_else(InternalError::schema_application_conflict)?;
265 let target = schema_application_target(db)?;
266 if target.database_identity() != record.receipt().database_identity() {
267 return Err(InternalError::schema_application_conflict());
268 }
269 let candidate_head = match record.receipt().outcome() {
270 SchemaChangeOutcome::Pending {
271 job,
272 candidate_head,
273 } if job.id() == job_id => candidate_head,
274 SchemaChangeOutcome::Applied { .. } => {
275 return Ok(SchemaChangeProgress::new(
276 record.receipt().clone(),
277 SchemaChangeProgressStatus::Applied,
278 ));
279 }
280 SchemaChangeOutcome::Aborted { .. } => {
281 return Ok(SchemaChangeProgress::new(
282 record.receipt().clone(),
283 SchemaChangeProgressStatus::Aborted,
284 ));
285 }
286 _ => return Err(InternalError::store_corruption()),
287 };
288 let [activation] = record.activations() else {
289 return Err(InternalError::store_corruption());
290 };
291 let authorities = application_authorities(db);
292 let authority = authorities
293 .iter()
294 .find(|authority| {
295 derive_store_identity(target.database_identity(), authority) == activation.store()
296 })
297 .ok_or_else(InternalError::store_corruption)?;
298 let entity_tag = EntityTag::new(activation.entity_tag());
299 let constraint_id = ConstraintId::new(activation.constraint_id())
300 .ok_or_else(InternalError::store_corruption)?;
301 let bundle = authority
302 .handle
303 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
304 .ok_or_else(InternalError::store_corruption)?;
305 if bundle.store_path() != authority.path {
306 return Err(InternalError::store_corruption());
307 }
308 let snapshot = bundle
309 .entity_snapshots()
310 .get(&entity_tag)
311 .ok_or_else(InternalError::store_corruption)?;
312
313 let accepted = snapshot
314 .constraint_catalog()
315 .constraints()
316 .iter()
317 .any(|constraint| {
318 constraint.id() == constraint_id
319 && constraint.origin() == ConstraintOrigin::Generated
320 && matches!(
321 constraint.kind(),
322 crate::db::schema::AcceptedConstraintKind::Check { .. }
323 | crate::db::schema::AcceptedConstraintKind::TargetedRule { .. }
324 )
325 });
326 let pending = snapshot.constraint_catalog().activation(constraint_id);
327 if accepted && pending.is_none() {
328 return finalize_schema_application(
329 db,
330 &record,
331 candidate_head,
332 SchemaChangeProgressStatus::Applied,
333 );
334 }
335 let pending = pending.ok_or_else(InternalError::store_corruption)?;
336 if pending.origin() != ConstraintOrigin::Generated
337 || !matches!(
338 pending.kind(),
339 ConstraintActivationKind::Check { .. } | ConstraintActivationKind::TargetedRule { .. }
340 )
341 {
342 return Err(InternalError::store_corruption());
343 }
344 let entity_path = snapshot.entity_path().to_string();
345 let progress = advance_accepted_row_local_constraint_activation(
346 authority.handle,
347 authority.path,
348 entity_tag,
349 entity_path.as_str(),
350 constraint_id,
351 acknowledged_receipt,
352 )?;
353 let status = schema_change_progress_status(snapshot, constraint_id, progress)?;
354 if status == SchemaChangeProgressStatus::Applied {
355 finalize_schema_application(db, &record, candidate_head, status)
356 } else {
357 Ok(SchemaChangeProgress::new(record.receipt().clone(), status))
358 }
359}
360
361pub(in crate::db) fn abort_schema_application<C: CanisterKind>(
367 db: &Db<C>,
368 job_id: SchemaChangeJobId,
369 acknowledged_receipt: Option<u64>,
370) -> Result<SchemaChangeProgress, InternalError> {
371 ensure_recovered(db)?;
372 let record = with_schema_application_store(|store| store.load_job(job_id))?
373 .ok_or_else(InternalError::schema_application_conflict)?;
374 let target = schema_application_target(db)?;
375 if target.database_identity() != record.receipt().database_identity() {
376 return Err(InternalError::schema_application_conflict());
377 }
378 match record.receipt().outcome() {
379 SchemaChangeOutcome::Applied { .. } => {
380 return Ok(SchemaChangeProgress::new(
381 record.receipt().clone(),
382 SchemaChangeProgressStatus::Applied,
383 ));
384 }
385 SchemaChangeOutcome::Aborted { .. } => {
386 return Ok(SchemaChangeProgress::new(
387 record.receipt().clone(),
388 SchemaChangeProgressStatus::Aborted,
389 ));
390 }
391 SchemaChangeOutcome::Pending { job, .. } if job.id() == job_id => {}
392 SchemaChangeOutcome::NoOp { .. } | SchemaChangeOutcome::Pending { .. } => {
393 return Err(InternalError::store_corruption());
394 }
395 }
396
397 let authorities = application_authorities(db);
398 let abort = prepare_pending_application_abort(
399 target.database_identity(),
400 &record,
401 authorities.as_slice(),
402 acknowledged_receipt,
403 )?;
404 let candidate = aborted_generated_row_local_candidate(
405 &abort.current,
406 abort.entity_tag,
407 abort.constraint_id,
408 )?;
409 let accepted_head =
410 accepted_head_after_candidates(authorities.as_slice(), std::slice::from_ref(&candidate))?;
411 let receipt = SchemaChangeReceipt::new(
412 record.receipt().database_identity(),
413 record.receipt().submission_key().clone(),
414 record.receipt().proposal_digest(),
415 record.receipt().prior_head().clone(),
416 SchemaChangeOutcome::Aborted { accepted_head },
417 )?;
418 let terminal = SchemaApplicationRecord::new(receipt.clone(), Vec::new())?;
419 let operation = SchemaApplicationRecordOp::replace(&record, &terminal)?;
420 if abort.remove_validation_job {
421 publish_generated_row_local_abort_with_application_record(
422 abort.authority.path,
423 abort.authority.handle,
424 abort.current.revision(),
425 &candidate,
426 abort.entity_tag,
427 abort.constraint_id,
428 operation,
429 )?;
430 } else {
431 publish_accepted_schema_candidates_with_application_record(
432 vec![AcceptedSchemaPublication::new(
433 abort.authority.path,
434 abort.authority.handle,
435 abort.current.revision(),
436 &candidate,
437 )],
438 operation,
439 )?;
440 }
441 Ok(SchemaChangeProgress::new(
442 receipt,
443 SchemaChangeProgressStatus::Aborted,
444 ))
445}
446
447fn prepare_pending_application_abort(
448 database_identity: TargetDatabaseIdentity,
449 record: &SchemaApplicationRecord,
450 authorities: &[StoreApplicationAuthority],
451 acknowledged_receipt: Option<u64>,
452) -> Result<PendingApplicationAbort, InternalError> {
453 let [activation] = record.activations() else {
454 return Err(InternalError::store_corruption());
455 };
456 let authority = authorities
457 .iter()
458 .copied()
459 .find(|authority| derive_store_identity(database_identity, authority) == activation.store())
460 .ok_or_else(InternalError::store_corruption)?;
461 let entity_tag = EntityTag::new(activation.entity_tag());
462 let constraint_id = ConstraintId::new(activation.constraint_id())
463 .ok_or_else(InternalError::store_corruption)?;
464 let current = authority
465 .handle
466 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
467 .ok_or_else(InternalError::store_corruption)?;
468 if current.store_path() != authority.path {
469 return Err(InternalError::store_corruption());
470 }
471 let pending = current
472 .entity_snapshots()
473 .get(&entity_tag)
474 .and_then(|snapshot| snapshot.constraint_catalog().activation(constraint_id))
475 .filter(|pending| {
476 pending.origin() == ConstraintOrigin::Generated
477 && matches!(
478 pending.kind(),
479 ConstraintActivationKind::Check { .. }
480 | ConstraintActivationKind::TargetedRule { .. }
481 )
482 })
483 .ok_or_else(InternalError::store_corruption)?;
484 let remove_validation_job = pending_generated_row_local_job_retirement(
485 authority,
486 entity_tag,
487 constraint_id,
488 pending.state(),
489 acknowledged_receipt,
490 )?;
491 Ok(PendingApplicationAbort {
492 authority,
493 current,
494 entity_tag,
495 constraint_id,
496 remove_validation_job,
497 })
498}
499
500fn pending_generated_row_local_job_retirement(
501 authority: StoreApplicationAuthority,
502 entity_tag: EntityTag,
503 constraint_id: ConstraintId,
504 state: ConstraintActivationState,
505 acknowledged_receipt: Option<u64>,
506) -> Result<bool, InternalError> {
507 let job = authority
508 .handle
509 .with_schema(|store| store.constraint_validation_job(entity_tag, constraint_id))?;
510 match state {
511 ConstraintActivationState::EnforcingNewWrites => {
512 if acknowledged_receipt.is_some() || job.is_some() {
513 return Err(InternalError::schema_application_conflict());
514 }
515 Ok(false)
516 }
517 ConstraintActivationState::Validating => {
518 let mut job = job.ok_or_else(InternalError::store_corruption)?;
519 if !job.acknowledge_receipt(acknowledged_receipt) {
520 return Err(InternalError::schema_application_conflict());
521 }
522 Ok(true)
523 }
524 }
525}
526
527fn aborted_generated_row_local_candidate(
528 current: &AcceptedSchemaRevisionBundle,
529 entity_tag: EntityTag,
530 constraint_id: ConstraintId,
531) -> Result<CandidateSchemaRevision, InternalError> {
532 let snapshot = current
533 .entity_snapshots()
534 .get(&entity_tag)
535 .cloned()
536 .ok_or_else(InternalError::store_corruption)?;
537 let _activation = snapshot
538 .constraint_catalog()
539 .activation(constraint_id)
540 .filter(|activation| {
541 activation.origin() == ConstraintOrigin::Generated
542 && matches!(
543 activation.kind(),
544 ConstraintActivationKind::Check { .. }
545 | ConstraintActivationKind::TargetedRule { .. }
546 )
547 })
548 .ok_or_else(InternalError::store_corruption)?;
549 let catalog = snapshot
550 .constraint_catalog()
551 .clone()
552 .with_aborted_activation(constraint_id)
553 .map_err(|_| InternalError::store_invariant())?;
554 let accepted_identity_remains = catalog
555 .constraints()
556 .iter()
557 .any(|constraint| constraint.id() == constraint_id);
558 let mut snapshots = current.entity_snapshots().clone();
559 snapshots.insert(entity_tag, snapshot.with_constraint_catalog(catalog));
560 let mut source_bindings = current.source_bindings().clone();
561 if !accepted_identity_remains {
562 source_bindings.remove_constraint_identity(entity_tag, constraint_id)?;
563 }
564 let revision = current
565 .revision()
566 .checked_next()
567 .ok_or_else(InternalError::store_unsupported)?;
568 let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
569 revision,
570 current.store_path(),
571 current.enum_catalog().clone(),
572 current.composite_catalog().clone(),
573 source_bindings,
574 snapshots,
575 )?;
576 CandidateSchemaRevision::new(bundle)
577}
578
579pub(in crate::db) fn apply_schema<C: CanisterKind>(
582 db: &Db<C>,
583 proposal: &SchemaProposal,
584) -> Result<SchemaChangeReceipt, InternalError> {
585 ensure_recovered(db)?;
586 let proposal_digest = proposal
587 .digest()
588 .map_err(|_| InternalError::store_unsupported())?;
589 if let Some(receipt) = exact_schema_application_receipt(proposal, proposal_digest)? {
590 return Ok(receipt);
591 }
592
593 let target = schema_application_target(db)?;
594 if target.database_identity() != proposal.target_database()
595 || target.accepted_head() != proposal.expected_head()
596 {
597 return Err(InternalError::schema_application_conflict());
598 }
599
600 let authorities = application_authorities(db);
601 let LoweredApplication {
602 current_bundles,
603 candidates,
604 pending,
605 } = lower_application_candidates(&target, proposal, authorities.as_slice())?;
606 validate_database_identity_state_capacity(
607 authorities.as_slice(),
608 candidates.as_slice(),
609 database_incarnation_id()?,
610 )?;
611 let accepted_head = if let Some(pending) = pending.as_ref() {
612 let final_candidates =
613 final_candidates_for_pending_row_local_constraint(&candidates, pending)?;
614 accepted_head_after_candidates(authorities.as_slice(), final_candidates.as_slice())?
615 } else if candidates.is_empty() {
616 target.accepted_head().clone()
617 } else {
618 accepted_head_after_candidates(authorities.as_slice(), candidates.as_slice())?
619 };
620 let outcome = if pending.is_some() {
621 let job_id = derive_schema_change_job_id(
622 target.database_identity(),
623 proposal.submission_key(),
624 proposal_digest,
625 target.accepted_head(),
626 )?;
627 SchemaChangeOutcome::Pending {
628 job: SchemaChangeJob::new(job_id),
629 candidate_head: accepted_head,
630 }
631 } else if candidates.is_empty() {
632 SchemaChangeOutcome::NoOp { accepted_head }
633 } else {
634 SchemaChangeOutcome::Applied { accepted_head }
635 };
636 let receipt = SchemaChangeReceipt::new(
637 target.database_identity(),
638 proposal.submission_key().clone(),
639 proposal_digest,
640 target.accepted_head().clone(),
641 outcome,
642 )?;
643 let activations = match pending {
644 Some(pending) => {
645 let authority = authorities
646 .iter()
647 .find(|authority| authority.path == pending.proof.store_path)
648 .ok_or_else(InternalError::store_invariant)?;
649 vec![SchemaChangeActivation::new(
650 derive_store_identity(target.database_identity(), authority),
651 pending.proof.entity_tag.value(),
652 pending.proof.constraint_id.get(),
653 )?]
654 }
655 None => Vec::new(),
656 };
657 let record = SchemaApplicationRecord::new(receipt.clone(), activations)?;
658 let operation = SchemaApplicationRecordOp::insert(&record)?;
659 let publications =
660 application_publications(authorities.as_slice(), ¤t_bundles, &candidates)?;
661 publish_accepted_schema_candidates_with_application_record(publications, operation)?;
662 Ok(receipt)
663}
664
665fn lower_application_candidates(
666 target: &SchemaApplicationTarget,
667 proposal: &SchemaProposal,
668 authorities: &[StoreApplicationAuthority],
669) -> Result<LoweredApplication, InternalError> {
670 let current_bundles = authorities
671 .iter()
672 .map(|authority| {
673 authority
674 .handle
675 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
676 })
677 .collect::<Result<Vec<_>, InternalError>>()?;
678 let initial_application = matches!(target.accepted_head(), ExpectedAcceptedHead::Empty);
679 let mut candidates = match target.accepted_head() {
680 ExpectedAcceptedHead::Empty => {
681 let stores = authorities
682 .iter()
683 .map(|authority| ProposalStoreTarget {
684 path: authority.path,
685 identity: derive_store_identity(target.database_identity(), authority),
686 })
687 .collect::<Vec<_>>();
688 lower_initial_schema_proposal(proposal, stores.as_slice())?
689 }
690 ExpectedAcceptedHead::Exact { .. }
691 if proposal.fragments().is_empty() && proposal.removals().is_empty() =>
692 {
693 Vec::new()
694 }
695 ExpectedAcceptedHead::Exact { .. } => {
696 let stores = authorities
697 .iter()
698 .zip(¤t_bundles)
699 .filter_map(|(authority, bundle)| {
700 bundle.as_ref().map(|bundle| ExistingProposalStore {
701 path: authority.path,
702 identity: derive_store_identity(target.database_identity(), authority),
703 bundle,
704 })
705 })
706 .collect::<Vec<_>>();
707 lower_existing_schema_proposal(proposal, stores.as_slice())?
708 }
709 };
710 let pending = if initial_application {
711 preflight_initial_application(authorities, &candidates)?;
712 None
713 } else {
714 preflight_existing_application(authorities, ¤t_bundles, &mut candidates)?
715 };
716 Ok(LoweredApplication {
717 current_bundles,
718 candidates,
719 pending,
720 })
721}
722
723fn validate_database_identity_state_capacity(
724 authorities: &[StoreApplicationAuthority],
725 candidates: &[CandidateSchemaRevision],
726 incarnation: crate::db::integrity::DatabaseIncarnationId,
727) -> Result<(), InternalError> {
728 let mut total = 0usize;
729 for authority in authorities {
730 let count = match candidates
731 .iter()
732 .find(|candidate| candidate.store_path() == authority.path)
733 {
734 Some(candidate) => authority.handle.with_schema(|store| {
735 store.projected_identity_state_count(incarnation, candidate)
736 })?,
737 None => authority
738 .handle
739 .with_schema(|store| store.identity_state_inventory_for_integrity(incarnation))?
740 .len(),
741 };
742 total = include_identity_state_count(total, count)?;
743 }
744 Ok(())
745}
746
747fn include_identity_state_count(total: usize, count: usize) -> Result<usize, InternalError> {
748 let total = total
749 .checked_add(count)
750 .ok_or_else(InternalError::identity_state_capacity_exhausted)?;
751 if total > MAX_IDENTITY_STATE_RECORDS_PER_DATABASE {
752 return Err(InternalError::identity_state_capacity_exhausted());
753 }
754 Ok(total)
755}
756
757fn preflight_initial_application(
758 authorities: &[StoreApplicationAuthority],
759 candidates: &[crate::db::schema::CandidateSchemaRevision],
760) -> Result<(), InternalError> {
761 for candidate in candidates {
762 let authority = authorities
763 .iter()
764 .find(|authority| authority.path == candidate.store_path())
765 .ok_or_else(InternalError::store_invariant)?;
766 if authority.handle.with_data(DataStore::len) != 0
767 || authority.handle.index_state() != IndexState::Ready
768 || !authority.handle.with_index(IndexStore::is_empty)
769 {
770 return Err(InternalError::store_unsupported());
771 }
772 }
773 Ok(())
774}
775
776fn preflight_existing_application(
783 authorities: &[StoreApplicationAuthority],
784 current_bundles: &[Option<crate::db::schema::AcceptedSchemaRevisionBundle>],
785 candidates: &mut [CandidateSchemaRevision],
786) -> Result<Option<PendingGeneratedRowLocalConstraint>, InternalError> {
787 require_empty_physical_entity_removal(authorities, current_bundles, candidates)?;
788 require_empty_physical_field_removals(authorities, current_bundles, candidates)?;
789 require_empty_physical_index_removals(authorities, current_bundles, candidates)?;
790 require_empty_physical_relation_removals(authorities, current_bundles, candidates)?;
791 let proofs = generated_row_local_constraint_proofs(authorities, current_bundles, candidates)?;
792 if proofs
793 .iter()
794 .filter(|proof| proof.historical_rows != 0)
795 .count()
796 > 1
797 {
798 return Err(InternalError::store_unsupported());
799 }
800
801 let mut pending = None;
802 for candidate_index in 0..candidates.len() {
803 let candidate = candidates
804 .get(candidate_index)
805 .cloned()
806 .ok_or_else(InternalError::store_invariant)?;
807 let candidate_proofs = proofs
808 .iter()
809 .filter(|proof| proof.candidate_index == candidate_index)
810 .collect::<Vec<_>>();
811 if candidate_proofs.is_empty() {
812 continue;
813 }
814
815 let mut snapshots = candidate.bundle().entity_snapshots().clone();
816 for proof in candidate_proofs {
817 let mut promote = true;
818 if proof.historical_rows != 0 {
819 match validate_unpublished_row_local_candidate_bounded(
820 proof.store,
821 proof.store_path,
822 proof.entity_tag,
823 proof.entity_path.as_str(),
824 &candidate,
825 proof.constraint_id,
826 )? {
827 UnpublishedRowLocalValidation::Complete { .. } => {}
828 UnpublishedRowLocalValidation::Incomplete => {
829 if proof.store.storage_capabilities().recovery()
830 != StoreRecoveryCapability::StableBasePlusJournalReplay
831 || pending.is_some()
832 {
833 return Err(InternalError::store_unsupported());
834 }
835 pending = Some(PendingGeneratedRowLocalConstraint {
836 proof: (*proof).clone(),
837 });
838 promote = false;
839 }
840 }
841 }
842 if !promote {
843 continue;
844 }
845 let snapshot = snapshots
846 .get(&proof.entity_tag)
847 .cloned()
848 .ok_or_else(InternalError::store_invariant)?;
849 let catalog = snapshot
850 .constraint_catalog()
851 .clone()
852 .with_directly_validated_activation(proof.constraint_id)
853 .map_err(|_| InternalError::store_invariant())?;
854 snapshots.insert(proof.entity_tag, snapshot.with_constraint_catalog(catalog));
855 }
856 let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
857 candidate.revision(),
858 candidate.bundle().store_path(),
859 candidate.bundle().enum_catalog().clone(),
860 candidate.bundle().composite_catalog().clone(),
861 candidate.bundle().source_bindings().clone(),
862 snapshots,
863 )?;
864 candidates[candidate_index] = CandidateSchemaRevision::new(bundle)?;
865 }
866 Ok(pending)
867}
868
869fn require_empty_physical_entity_removal(
876 authorities: &[StoreApplicationAuthority],
877 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
878 candidates: &[CandidateSchemaRevision],
879) -> Result<(), InternalError> {
880 let mut removed_entity = None;
881 for candidate in candidates {
882 let (position, source_authority) = authorities
883 .iter()
884 .enumerate()
885 .find(|(_, authority)| authority.path == candidate.store_path())
886 .ok_or_else(InternalError::store_invariant)?;
887 let current = current_bundles
888 .get(position)
889 .and_then(Option::as_ref)
890 .ok_or_else(InternalError::store_invariant)?;
891 let removed = current
892 .entity_snapshots()
893 .iter()
894 .filter(|(entity_tag, _)| {
895 !candidate
896 .bundle()
897 .entity_snapshots()
898 .contains_key(entity_tag)
899 })
900 .collect::<Vec<_>>();
901 if removed.is_empty() {
902 continue;
903 }
904 let [(entity_tag, snapshot)] = removed.as_slice() else {
905 return Err(InternalError::store_unsupported());
906 };
907 let entity_tag = **entity_tag;
908 let snapshot = *snapshot;
909 if removed_entity.is_some()
910 || current.entity_snapshots().len()
911 != candidate
912 .bundle()
913 .entity_snapshots()
914 .len()
915 .saturating_add(1)
916 {
917 return Err(InternalError::store_unsupported());
918 }
919 require_exact_empty_entity(source_authority.handle, entity_tag)?;
920 source_authority
921 .handle
922 .with_index(|store| prove_empty_user_index_domain(store, entity_tag))
923 .map_err(StagedUserIndexDomainError::into_internal_error)?;
924 for relation in snapshot.relations() {
925 let target_store = accepted_entity_store_for_path(
926 authorities,
927 current_bundles,
928 relation.target_path(),
929 )?;
930 target_store.with_index(|store| {
931 prove_empty_reverse_relation_domain(store, entity_tag, snapshot, relation)
932 })?;
933 }
934 removed_entity = Some(snapshot.entity_path());
935 }
936
937 let Some(removed_path) = removed_entity else {
938 return Ok(());
939 };
940 for (position, authority) in authorities.iter().enumerate() {
941 let after = candidates
942 .iter()
943 .find(|candidate| candidate.store_path() == authority.path)
944 .map(CandidateSchemaRevision::bundle)
945 .or_else(|| current_bundles.get(position).and_then(Option::as_ref));
946 let Some(after) = after else {
947 continue;
948 };
949 if after
950 .entity_snapshots()
951 .values()
952 .flat_map(crate::db::schema::PersistedSchemaSnapshot::relations)
953 .any(|relation| relation.target_path() == removed_path)
954 {
955 return Err(InternalError::store_unsupported());
956 }
957 }
958 Ok(())
959}
960
961fn require_empty_physical_relation_removals(
964 authorities: &[StoreApplicationAuthority],
965 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
966 candidates: &[CandidateSchemaRevision],
967) -> Result<(), InternalError> {
968 for candidate in candidates {
969 let (position, source_authority) = authorities
970 .iter()
971 .enumerate()
972 .find(|(_, authority)| authority.path == candidate.store_path())
973 .ok_or_else(InternalError::store_invariant)?;
974 let current = current_bundles
975 .get(position)
976 .and_then(Option::as_ref)
977 .ok_or_else(InternalError::store_invariant)?;
978 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
979 let before = current
980 .entity_snapshots()
981 .get(entity_tag)
982 .ok_or_else(InternalError::store_invariant)?;
983 let removed = before
984 .relations()
985 .iter()
986 .filter(|relation| {
987 !after
988 .relations()
989 .iter()
990 .any(|candidate| candidate.id() == relation.id())
991 })
992 .collect::<Vec<_>>();
993 if removed.is_empty() {
994 continue;
995 }
996 let added = after.relations().iter().any(|relation| {
997 !before
998 .relations()
999 .iter()
1000 .any(|accepted| accepted.id() == relation.id())
1001 });
1002 let [removed] = removed.as_slice() else {
1003 return Err(InternalError::store_unsupported());
1004 };
1005 if added || before.relations().len() != after.relations().len().saturating_add(1) {
1006 return Err(InternalError::store_unsupported());
1007 }
1008 require_exact_empty_entity(source_authority.handle, *entity_tag)?;
1009 let target_store = accepted_entity_store_for_path(
1010 authorities,
1011 current_bundles,
1012 removed.target_path(),
1013 )?;
1014 target_store.with_index(|store| {
1015 prove_empty_reverse_relation_domain(store, *entity_tag, before, removed)
1016 })?;
1017 }
1018 }
1019 Ok(())
1020}
1021
1022fn accepted_entity_store_for_path(
1023 authorities: &[StoreApplicationAuthority],
1024 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
1025 entity_path: &str,
1026) -> Result<StoreHandle, InternalError> {
1027 let mut resolved = None;
1028 for (position, bundle) in current_bundles.iter().enumerate() {
1029 let Some(bundle) = bundle else {
1030 continue;
1031 };
1032 if !bundle
1033 .entity_snapshots()
1034 .values()
1035 .any(|snapshot| snapshot.entity_path() == entity_path)
1036 {
1037 continue;
1038 }
1039 if resolved.is_some() {
1040 return Err(InternalError::store_invariant());
1041 }
1042 resolved = authorities.get(position).map(|authority| authority.handle);
1043 }
1044 resolved.ok_or_else(InternalError::store_unsupported)
1045}
1046
1047fn require_empty_physical_index_removals(
1051 authorities: &[StoreApplicationAuthority],
1052 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
1053 candidates: &[CandidateSchemaRevision],
1054) -> Result<(), InternalError> {
1055 for candidate in candidates {
1056 let (position, authority) = authorities
1057 .iter()
1058 .enumerate()
1059 .find(|(_, authority)| authority.path == candidate.store_path())
1060 .ok_or_else(InternalError::store_invariant)?;
1061 let current = current_bundles
1062 .get(position)
1063 .and_then(Option::as_ref)
1064 .ok_or_else(InternalError::store_invariant)?;
1065 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
1066 let before = current
1067 .entity_snapshots()
1068 .get(entity_tag)
1069 .ok_or_else(InternalError::store_invariant)?;
1070 if before.indexes().len() == after.indexes().len() {
1071 continue;
1072 }
1073 if before.indexes().len() != after.indexes().len().saturating_add(1) {
1074 return Err(InternalError::store_unsupported());
1075 }
1076 require_exact_empty_entity(authority.handle, *entity_tag)?;
1077 authority
1078 .handle
1079 .with_index(|store| prove_empty_user_index_domain(store, *entity_tag))
1080 .map_err(StagedUserIndexDomainError::into_internal_error)?;
1081 }
1082 }
1083 Ok(())
1084}
1085
1086fn require_empty_physical_field_removals(
1089 authorities: &[StoreApplicationAuthority],
1090 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
1091 candidates: &[CandidateSchemaRevision],
1092) -> Result<(), InternalError> {
1093 for candidate in candidates {
1094 let (position, authority) = authorities
1095 .iter()
1096 .enumerate()
1097 .find(|(_, authority)| authority.path == candidate.store_path())
1098 .ok_or_else(InternalError::store_invariant)?;
1099 let current = current_bundles
1100 .get(position)
1101 .and_then(Option::as_ref)
1102 .ok_or_else(InternalError::store_invariant)?;
1103 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
1104 let before = current
1105 .entity_snapshots()
1106 .get(entity_tag)
1107 .ok_or_else(InternalError::store_invariant)?;
1108 if before.row_layout() == after.row_layout() {
1109 continue;
1110 }
1111 if before.fields().len() != after.fields().len().saturating_add(1) {
1112 return Err(InternalError::store_unsupported());
1113 }
1114 require_exact_empty_entity(authority.handle, *entity_tag)?;
1115 }
1116 }
1117 Ok(())
1118}
1119
1120fn require_exact_empty_entity(
1124 store: StoreHandle,
1125 entity_tag: EntityTag,
1126) -> Result<(), InternalError> {
1127 require_exact_empty_entity_count(store.with_data(|data| data.exact_entity_count(entity_tag)))
1128}
1129
1130fn require_exact_empty_entity_count(count: Option<u64>) -> Result<(), InternalError> {
1131 let count = count.ok_or_else(InternalError::store_corruption)?;
1132 if count != 0 {
1133 return Err(InternalError::store_unsupported());
1134 }
1135
1136 Ok(())
1137}
1138
1139fn generated_row_local_constraint_proofs(
1140 authorities: &[StoreApplicationAuthority],
1141 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
1142 candidates: &[CandidateSchemaRevision],
1143) -> Result<Vec<DirectGeneratedRowLocalProof>, InternalError> {
1144 let mut proofs = Vec::new();
1145 for (candidate_index, candidate) in candidates.iter().enumerate() {
1146 let (position, authority) = authorities
1147 .iter()
1148 .enumerate()
1149 .find(|(_, authority)| authority.path == candidate.store_path())
1150 .ok_or_else(InternalError::store_invariant)?;
1151 let current = current_bundles
1152 .get(position)
1153 .and_then(Option::as_ref)
1154 .ok_or_else(InternalError::store_invariant)?;
1155 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
1156 let before = current
1157 .entity_snapshots()
1158 .get(entity_tag)
1159 .ok_or_else(InternalError::store_invariant)?;
1160 for constraint_id in added_generated_row_local_activations(before, after) {
1161 let historical_rows = authority
1162 .handle
1163 .with_data(|store| store.exact_entity_count(*entity_tag))
1164 .ok_or_else(InternalError::store_corruption)?;
1165 proofs.push(DirectGeneratedRowLocalProof {
1166 candidate_index,
1167 store: authority.handle,
1168 store_path: authority.path,
1169 entity_tag: *entity_tag,
1170 entity_path: after.entity_path().to_string(),
1171 constraint_id,
1172 historical_rows,
1173 });
1174 }
1175 }
1176 }
1177 Ok(proofs)
1178}
1179
1180fn added_generated_row_local_activations(
1181 before: &crate::db::schema::PersistedSchemaSnapshot,
1182 after: &crate::db::schema::PersistedSchemaSnapshot,
1183) -> Vec<ConstraintId> {
1184 after
1185 .constraint_activations()
1186 .iter()
1187 .filter(|candidate| {
1188 candidate.origin() == ConstraintOrigin::Generated
1189 && matches!(
1190 candidate.kind(),
1191 ConstraintActivationKind::Check { .. }
1192 | ConstraintActivationKind::TargetedRule { .. }
1193 )
1194 && !before
1195 .constraint_activations()
1196 .iter()
1197 .any(|accepted| accepted.id() == candidate.id())
1198 })
1199 .map(crate::db::schema::ConstraintActivationSnapshot::id)
1200 .collect()
1201}
1202
1203fn final_candidates_for_pending_row_local_constraint(
1204 candidates: &[CandidateSchemaRevision],
1205 pending: &PendingGeneratedRowLocalConstraint,
1206) -> Result<Vec<CandidateSchemaRevision>, InternalError> {
1207 let mut final_candidates = candidates.to_vec();
1208 let candidate = final_candidates
1209 .get(pending.proof.candidate_index)
1210 .cloned()
1211 .ok_or_else(InternalError::store_invariant)?;
1212 if candidate.store_path() != pending.proof.store_path {
1213 return Err(InternalError::store_invariant());
1214 }
1215 let mut snapshots = candidate.bundle().entity_snapshots().clone();
1216 let snapshot = snapshots
1217 .get(&pending.proof.entity_tag)
1218 .cloned()
1219 .ok_or_else(InternalError::store_invariant)?;
1220 let catalog = snapshot
1221 .constraint_catalog()
1222 .clone()
1223 .with_directly_validated_activation(pending.proof.constraint_id)
1224 .map_err(|_| InternalError::store_invariant())?;
1225 snapshots.insert(
1226 pending.proof.entity_tag,
1227 snapshot.with_constraint_catalog(catalog),
1228 );
1229 let final_revision = candidate
1230 .revision()
1231 .checked_next()
1232 .and_then(AcceptedSchemaRevision::checked_next)
1233 .ok_or_else(InternalError::store_unsupported)?;
1234 let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
1235 final_revision,
1236 candidate.bundle().store_path(),
1237 candidate.bundle().enum_catalog().clone(),
1238 candidate.bundle().composite_catalog().clone(),
1239 candidate.bundle().source_bindings().clone(),
1240 snapshots,
1241 )?;
1242 final_candidates[pending.proof.candidate_index] = CandidateSchemaRevision::new(bundle)?;
1243 Ok(final_candidates)
1244}
1245
1246fn schema_change_progress_status(
1247 snapshot: &crate::db::schema::PersistedSchemaSnapshot,
1248 constraint_id: ConstraintId,
1249 progress: ConstraintValidationProgress,
1250) -> Result<SchemaChangeProgressStatus, InternalError> {
1251 match progress {
1252 ConstraintValidationProgress::Started => Ok(SchemaChangeProgressStatus::Started),
1253 ConstraintValidationProgress::Advanced {
1254 phase,
1255 rows_scanned,
1256 } => Ok(SchemaChangeProgressStatus::Advanced {
1257 phase: schema_change_validation_phase(phase),
1258 rows_scanned,
1259 }),
1260 ConstraintValidationProgress::Findings {
1261 receipt,
1262 phase,
1263 rows_scanned,
1264 } => {
1265 let activation = snapshot
1266 .constraint_catalog()
1267 .activation(constraint_id)
1268 .ok_or_else(InternalError::store_corruption)?;
1269 let findings = receipt
1270 .findings()
1271 .iter()
1272 .map(|finding| {
1273 let primary_key = finding
1274 .primary_key()
1275 .encoded_primary_key_bytes()
1276 .ok_or_else(InternalError::store_invariant)?;
1277 constraint_validation_finding_diagnostic(
1278 snapshot,
1279 activation,
1280 snapshot.entity_path(),
1281 primary_key,
1282 finding,
1283 )
1284 })
1285 .collect::<Result<Vec<_>, InternalError>>()?;
1286 Ok(SchemaChangeProgressStatus::Findings {
1287 phase: schema_change_validation_phase(phase),
1288 rows_scanned,
1289 page_sequence: receipt.page_sequence(),
1290 findings,
1291 })
1292 }
1293 ConstraintValidationProgress::Restarted { rows_scanned } => {
1294 Ok(SchemaChangeProgressStatus::Restarted { rows_scanned })
1295 }
1296 ConstraintValidationProgress::Promoted { .. } => Ok(SchemaChangeProgressStatus::Applied),
1297 }
1298}
1299
1300const fn schema_change_validation_phase(
1301 phase: ConstraintValidationPhase,
1302) -> SchemaChangeValidationPhase {
1303 match phase {
1304 ConstraintValidationPhase::Forward => SchemaChangeValidationPhase::Forward,
1305 ConstraintValidationPhase::Verify => SchemaChangeValidationPhase::Verify,
1306 }
1307}
1308
1309fn finalize_schema_application<C: CanisterKind>(
1310 db: &Db<C>,
1311 record: &SchemaApplicationRecord,
1312 candidate_head: &ExpectedAcceptedHead,
1313 status: SchemaChangeProgressStatus,
1314) -> Result<SchemaChangeProgress, InternalError> {
1315 if schema_application_target(db)?.accepted_head() != candidate_head {
1316 return Err(InternalError::schema_application_conflict());
1317 }
1318 let receipt = SchemaChangeReceipt::new(
1319 record.receipt().database_identity(),
1320 record.receipt().submission_key().clone(),
1321 record.receipt().proposal_digest(),
1322 record.receipt().prior_head().clone(),
1323 SchemaChangeOutcome::Applied {
1324 accepted_head: candidate_head.clone(),
1325 },
1326 )?;
1327 let terminal = SchemaApplicationRecord::new(receipt.clone(), Vec::new())?;
1328 let operation = SchemaApplicationRecordOp::replace(record, &terminal)?;
1329 publish_accepted_schema_candidates_with_application_record(Vec::new(), operation)?;
1330 Ok(SchemaChangeProgress::new(receipt, status))
1331}
1332
1333fn application_publications<'a>(
1334 authorities: &[StoreApplicationAuthority],
1335 current_bundles: &[Option<crate::db::schema::AcceptedSchemaRevisionBundle>],
1336 candidates: &'a [crate::db::schema::CandidateSchemaRevision],
1337) -> Result<Vec<AcceptedSchemaPublication<'a>>, InternalError> {
1338 candidates
1339 .iter()
1340 .map(|candidate| {
1341 let (position, authority) = authorities
1342 .iter()
1343 .enumerate()
1344 .find(|(_, authority)| authority.path == candidate.store_path())
1345 .ok_or_else(InternalError::store_invariant)?;
1346 let expected_revision = current_bundles[position].as_ref().map_or(
1347 AcceptedSchemaRevision::NONE,
1348 crate::db::schema::AcceptedSchemaRevisionBundle::revision,
1349 );
1350 Ok(AcceptedSchemaPublication::new(
1351 authority.path,
1352 authority.handle,
1353 expected_revision,
1354 candidate,
1355 ))
1356 })
1357 .collect()
1358}
1359
1360fn application_authorities<C: CanisterKind>(db: &Db<C>) -> Vec<StoreApplicationAuthority> {
1361 let mut authorities = db.with_store_registry(|registry| {
1362 registry
1363 .iter()
1364 .map(|(path, handle)| StoreApplicationAuthority { path, handle })
1365 .collect::<Vec<_>>()
1366 });
1367 authorities.sort_by(|left, right| left.path.cmp(right.path));
1368 authorities
1369}
1370
1371fn accepted_head_after_candidates(
1372 authorities: &[StoreApplicationAuthority],
1373 candidates: &[crate::db::schema::CandidateSchemaRevision],
1374) -> Result<ExpectedAcceptedHead, InternalError> {
1375 let heads = authorities
1376 .iter()
1377 .map(|authority| {
1378 let candidate = candidates
1379 .iter()
1380 .find(|candidate| candidate.store_path() == authority.path);
1381 let head = match candidate {
1382 Some(candidate) => Some(AcceptedStoreHead {
1383 revision: candidate.revision().get(),
1384 fingerprint: candidate.root().fingerprint().as_bytes(),
1385 }),
1386 None => authority
1387 .handle
1388 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
1389 .map(|selection| AcceptedStoreHead {
1390 revision: selection.root().revision().get(),
1391 fingerprint: selection.root().fingerprint().as_bytes(),
1392 }),
1393 };
1394 Ok((authority.path, head))
1395 })
1396 .collect::<Result<Vec<_>, InternalError>>()?;
1397 Ok(derive_accepted_head(heads.as_slice()))
1398}
1399
1400fn derive_database_identity(
1401 incarnation: [u8; 16],
1402 stores: &[StoreApplicationAuthority],
1403) -> TargetDatabaseIdentity {
1404 let mut hasher = new_hash_sha256_prefixed(DATABASE_TARGET_FINGERPRINT_PROFILE);
1405 hasher.update(incarnation);
1406 write_hash_len_u32(&mut hasher, stores.len());
1407 for store in stores {
1408 write_store_authority(&mut hasher, store);
1409 }
1410 TargetDatabaseIdentity::from_bytes(finalize_hash_sha256(hasher))
1411}
1412
1413fn derive_store_identity(
1414 database_identity: TargetDatabaseIdentity,
1415 store: &StoreApplicationAuthority,
1416) -> TargetStoreIdentity {
1417 let mut hasher = new_hash_sha256_prefixed(STORE_TARGET_FINGERPRINT_PROFILE);
1418 hasher.update(database_identity.to_bytes());
1419 write_store_authority(&mut hasher, store);
1420 TargetStoreIdentity::from_bytes(finalize_hash_sha256(hasher))
1421}
1422
1423fn derive_accepted_head(stores: &[(&str, Option<AcceptedStoreHead>)]) -> ExpectedAcceptedHead {
1424 let Some(revision) = stores
1425 .iter()
1426 .filter_map(|(_, head)| head.map(|head| head.revision))
1427 .max()
1428 else {
1429 return ExpectedAcceptedHead::Empty;
1430 };
1431
1432 let mut hasher = new_hash_sha256_prefixed(ACCEPTED_DATABASE_HEAD_FINGERPRINT_PROFILE);
1433 write_hash_len_u32(&mut hasher, stores.len());
1434 for (path, head) in stores {
1435 write_hash_str_u32(&mut hasher, path);
1436 match head {
1437 None => write_hash_tag_u8(&mut hasher, 0),
1438 Some(head) => {
1439 write_hash_tag_u8(&mut hasher, 1);
1440 write_hash_u64(&mut hasher, head.revision);
1441 hasher.update(head.fingerprint);
1442 }
1443 }
1444 }
1445
1446 ExpectedAcceptedHead::Exact {
1447 revision,
1448 fingerprint: ExpectedSchemaFingerprint::from_bytes(finalize_hash_sha256(hasher)),
1449 }
1450}
1451
1452fn write_store_authority(hasher: &mut sha2::Sha256, store: &StoreApplicationAuthority) {
1453 write_hash_str_u32(hasher, store.path);
1454 write_storage_capabilities(hasher, store.handle);
1455 for allocation in [
1456 store.handle.data_allocation(),
1457 store.handle.index_allocation(),
1458 store.handle.schema_allocation(),
1459 store.handle.journal_allocation(),
1460 ] {
1461 write_allocation_identity(hasher, allocation);
1462 }
1463}
1464
1465fn write_storage_capabilities(hasher: &mut sha2::Sha256, store: StoreHandle) {
1466 let capabilities = store.storage_capabilities();
1467 write_hash_tag_u8(
1468 hasher,
1469 match capabilities.storage_mode() {
1470 StoreRuntimeStorageMode::Heap => 0,
1471 StoreRuntimeStorageMode::Journaled => 1,
1472 },
1473 );
1474 write_hash_tag_u8(
1475 hasher,
1476 match capabilities.allocation_identity() {
1477 StoreAllocationIdentityCapability::Present => 0,
1478 StoreAllocationIdentityCapability::Absent => 1,
1479 },
1480 );
1481 write_hash_tag_u8(
1482 hasher,
1483 match capabilities.durability() {
1484 StoreDurability::Durable => 0,
1485 StoreDurability::Volatile => 1,
1486 },
1487 );
1488 write_hash_tag_u8(
1489 hasher,
1490 match capabilities.recovery() {
1491 StoreRecoveryCapability::StableBasePlusJournalReplay => 0,
1492 StoreRecoveryCapability::None => 1,
1493 },
1494 );
1495 write_hash_tag_u8(
1496 hasher,
1497 match capabilities.commit_participation() {
1498 StoreCommitParticipation::Durable => 0,
1499 StoreCommitParticipation::LiveOnly => 1,
1500 },
1501 );
1502 write_hash_tag_u8(
1503 hasher,
1504 match capabilities.schema_metadata() {
1505 StoreSchemaMetadataCapability::LiveRebuiltMetadata => 0,
1506 StoreSchemaMetadataCapability::CanonicalStableHistoryPlusJournalTail => 1,
1507 },
1508 );
1509 write_hash_tag_u8(
1510 hasher,
1511 match capabilities.relation_source() {
1512 StoreRelationSourceCapability::DurableSource => 0,
1513 StoreRelationSourceCapability::LiveSource => 1,
1514 },
1515 );
1516 write_hash_tag_u8(
1517 hasher,
1518 match capabilities.relation_target() {
1519 StoreRelationTargetCapability::DurableTarget => 0,
1520 StoreRelationTargetCapability::VolatileTarget => 1,
1521 },
1522 );
1523}
1524
1525fn write_allocation_identity(
1526 hasher: &mut sha2::Sha256,
1527 allocation: Option<StoreAllocationIdentity>,
1528) {
1529 match allocation {
1530 None => write_hash_tag_u8(hasher, 0),
1531 Some(allocation) => {
1532 write_hash_tag_u8(hasher, 1);
1533 write_hash_tag_u8(hasher, allocation.memory_id());
1534 write_hash_str_u32(hasher, allocation.stable_key());
1535 }
1536 }
1537}
1538
1539#[cfg(test)]
1540mod tests {
1541 use super::{
1542 AcceptedSchemaPublication, AcceptedStoreHead, DirectGeneratedRowLocalProof,
1543 PendingGeneratedRowLocalConstraint, abort_schema_application,
1544 aborted_generated_row_local_candidate, accepted_head_after_candidates,
1545 application_authorities, apply_schema, continue_schema_application, derive_accepted_head,
1546 derive_schema_change_job_id, ensure_recovered,
1547 final_candidates_for_pending_row_local_constraint, include_identity_state_count,
1548 lower_existing_schema_proposal, lower_initial_schema_proposal,
1549 publish_accepted_schema_candidates_with_application_record,
1550 require_exact_empty_entity_count, schema_application_target,
1551 };
1552 use crate::{
1553 db::{
1554 Db,
1555 commit::forget_recovered_domain_for_tests,
1556 data::DataStore,
1557 index::IndexStore,
1558 journal::JournalTailStore,
1559 registry::{
1560 StoreAllocationIdentities, StoreAllocationIdentity, StoreRegistry,
1561 StoreRuntimeStorageCapabilities,
1562 },
1563 schema::{
1564 AcceptedConstraintKind, AcceptedRuleOperation, AcceptedSchemaRevisionBundle,
1565 CandidateSchemaRevision, ConstraintOrigin, ConstraintValidationJob,
1566 ExistingProposalStore, ProposalStoreTarget, SchemaApplicationRecord,
1567 SchemaApplicationRecordOp, SchemaChangeActivation, SchemaChangeJob,
1568 SchemaChangeOutcome, SchemaChangeProgressStatus, SchemaStore,
1569 },
1570 },
1571 error::{ErrorClass, ErrorOrigin},
1572 testing::test_memory,
1573 traits::{CanisterKind, Path},
1574 };
1575 use icydb_schema::{
1576 ConstraintFragment, ConstraintSourceKey, EntityFragment, EntitySourceKey,
1577 EntityStoreAssignment, ExpectedAcceptedHead, ExpectedSchemaFingerprint, FieldFragment,
1578 FieldInsertPolicy, FieldSourceKey, FieldType, NamedTypeFragment, RuleSourceKey,
1579 ScalarLiteral, ScalarType, SchemaCapability, SchemaFragment, SchemaName, SchemaProposal,
1580 SchemaSubmissionKey, SourceCheckExpr, SourceCheckInstruction, SourceRuleOperation,
1581 TargetDatabaseIdentity, TargetStoreIdentity, TargetedRuleFragment, TypeSourceKey,
1582 };
1583 use std::cell::RefCell;
1584
1585 const ABORT_STORE_PATH: &str = "schema_application_tests::AbortStore";
1586 const EVOLUTION_STORE_PATH: &str = "schema_application_tests::EvolutionStore";
1587
1588 #[test]
1589 fn database_identity_state_capacity_combines_store_inventories_exactly() {
1590 let below = include_identity_state_count(0, 65_535)
1591 .expect("the first store inventory should remain below the database cap");
1592 let exact = include_identity_state_count(below, 1)
1593 .expect("the combined database boundary should admit");
1594 assert_eq!(exact, 65_536);
1595
1596 let error = include_identity_state_count(exact, 1)
1597 .expect_err("the next owner in another store must reject");
1598 assert_eq!(error.class(), ErrorClass::Unsupported);
1599 assert_eq!(error.origin(), ErrorOrigin::Identity);
1600 }
1601
1602 #[test]
1603 fn exact_empty_entity_proof_distinguishes_corruption_from_non_empty_input() {
1604 let corrupt = require_exact_empty_entity_count(None)
1605 .expect_err("uninspectable cardinality must fail closed");
1606 assert_eq!(corrupt.class(), ErrorClass::Corruption);
1607
1608 let non_empty = require_exact_empty_entity_count(Some(1))
1609 .expect_err("non-empty cardinality must reject removal");
1610 assert_eq!(non_empty.class(), ErrorClass::Unsupported);
1611 assert!(require_exact_empty_entity_count(Some(0)).is_ok());
1612 }
1613
1614 thread_local! {
1615 static ABORT_DATA: RefCell<DataStore> =
1616 RefCell::new(DataStore::init_journaled(test_memory(180)));
1617 static ABORT_INDEX: RefCell<IndexStore> =
1618 RefCell::new(IndexStore::init_journaled(test_memory(181)));
1619 static ABORT_SCHEMA: RefCell<SchemaStore> =
1620 RefCell::new(SchemaStore::init_journaled(test_memory(182)));
1621 static ABORT_JOURNAL: RefCell<JournalTailStore> =
1622 RefCell::new(JournalTailStore::init(test_memory(183)));
1623 static ABORT_REGISTRY: StoreRegistry = {
1624 let mut registry = StoreRegistry::new();
1625 registry.register_journaled_store(
1626 ABORT_STORE_PATH,
1627 &ABORT_DATA,
1628 &ABORT_INDEX,
1629 &ABORT_SCHEMA,
1630 &ABORT_JOURNAL,
1631 StoreAllocationIdentities::new_journaled(
1632 StoreAllocationIdentity::new(180, "icydb.test.application-abort.data.v1"),
1633 StoreAllocationIdentity::new(181, "icydb.test.application-abort.index.v1"),
1634 StoreAllocationIdentity::new(182, "icydb.test.application-abort.schema.v1"),
1635 StoreAllocationIdentity::new(183, "icydb.test.application-abort.journal.v1"),
1636 ),
1637 StoreRuntimeStorageCapabilities::journaled(),
1638 ).expect("abort journaled store should register");
1639 registry
1640 };
1641 }
1642
1643 thread_local! {
1644 static EVOLUTION_DATA: RefCell<DataStore> =
1645 RefCell::new(DataStore::init_journaled(test_memory(192)));
1646 static EVOLUTION_INDEX: RefCell<IndexStore> =
1647 RefCell::new(IndexStore::init_journaled(test_memory(193)));
1648 static EVOLUTION_SCHEMA: RefCell<SchemaStore> =
1649 RefCell::new(SchemaStore::init_journaled(test_memory(194)));
1650 static EVOLUTION_JOURNAL: RefCell<JournalTailStore> =
1651 RefCell::new(JournalTailStore::init(test_memory(195)));
1652 static EVOLUTION_REGISTRY: StoreRegistry = {
1653 let mut registry = StoreRegistry::new();
1654 registry.register_journaled_store(
1655 EVOLUTION_STORE_PATH,
1656 &EVOLUTION_DATA,
1657 &EVOLUTION_INDEX,
1658 &EVOLUTION_SCHEMA,
1659 &EVOLUTION_JOURNAL,
1660 StoreAllocationIdentities::new_journaled(
1661 StoreAllocationIdentity::new(192, "icydb.test.rule-evolution.data.v1"),
1662 StoreAllocationIdentity::new(193, "icydb.test.rule-evolution.index.v1"),
1663 StoreAllocationIdentity::new(194, "icydb.test.rule-evolution.schema.v1"),
1664 StoreAllocationIdentity::new(195, "icydb.test.rule-evolution.journal.v1"),
1665 ),
1666 StoreRuntimeStorageCapabilities::journaled(),
1667 ).expect("rule-evolution journaled store should register");
1668 registry
1669 };
1670 }
1671
1672 struct AbortCanister;
1673
1674 impl Path for AbortCanister {
1675 const PATH: &'static str = "schema_application_tests::AbortCanister";
1676 }
1677
1678 impl CanisterKind for AbortCanister {
1679 const COMMIT_MEMORY_ID: u8 = 184;
1680 const COMMIT_STABLE_KEY: &'static str = "icydb.test.application-abort.commit.v1";
1681 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 185;
1682 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
1683 "icydb.test.application-abort.integrity.v1";
1684 }
1685
1686 struct EvolutionCanister;
1687
1688 impl Path for EvolutionCanister {
1689 const PATH: &'static str = "schema_application_tests::EvolutionCanister";
1690 }
1691
1692 impl CanisterKind for EvolutionCanister {
1693 const COMMIT_MEMORY_ID: u8 = 196;
1694 const COMMIT_STABLE_KEY: &'static str = "icydb.test.rule-evolution.commit.v1";
1695 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 197;
1696 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
1697 "icydb.test.rule-evolution.integrity.v1";
1698 }
1699
1700 fn name(value: &str) -> SchemaName {
1701 SchemaName::try_new(value).expect("test schema name should admit")
1702 }
1703
1704 fn generated_check_proposal(
1705 expected_head: ExpectedAcceptedHead,
1706 submission_key: &str,
1707 include_check: bool,
1708 database: TargetDatabaseIdentity,
1709 store: TargetStoreIdentity,
1710 ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
1711 let entity_source = EntitySourceKey::try_new("Item").expect("entity source should admit");
1712 let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
1713 let score_source = FieldSourceKey::try_new("score").expect("score source should admit");
1714 let check_source =
1715 ConstraintSourceKey::try_new("score_non_negative").expect("check source should admit");
1716 let check = SourceCheckExpr::try_new(vec![
1717 SourceCheckInstruction::Field(score_source),
1718 SourceCheckInstruction::Literal(ScalarLiteral::Int(0)),
1719 SourceCheckInstruction::GreaterThanOrEqual,
1720 ])
1721 .expect("check expression should admit");
1722 let constraints = include_check
1723 .then(|| ConstraintFragment::check(name("score_non_negative"), check))
1724 .into_iter()
1725 .collect();
1726 let entity = EntityFragment::try_new(
1727 name("Item"),
1728 vec![
1729 FieldFragment::new(
1730 name("id"),
1731 FieldType::Scalar(ScalarType::Nat64),
1732 false,
1733 FieldInsertPolicy::Required,
1734 None,
1735 ),
1736 FieldFragment::new(
1737 name("score"),
1738 FieldType::Scalar(ScalarType::Int64),
1739 false,
1740 FieldInsertPolicy::Required,
1741 None,
1742 ),
1743 ],
1744 vec![id_source],
1745 Vec::new(),
1746 Vec::new(),
1747 constraints,
1748 )
1749 .expect("entity should admit");
1750 let proposal = SchemaProposal::try_compose(
1751 vec![SchemaCapability::ACCEPTED_CHECKS],
1752 database,
1753 SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
1754 expected_head,
1755 vec![
1756 SchemaFragment::try_new(vec![entity], Vec::new())
1757 .expect("schema fragment should admit"),
1758 ],
1759 vec![EntityStoreAssignment::new(entity_source.clone(), store)],
1760 Vec::new(),
1761 )
1762 .expect("schema proposal should compose");
1763 (proposal, entity_source, check_source)
1764 }
1765
1766 fn targeted_rule_proposal(
1767 expected_head: ExpectedAcceptedHead,
1768 submission_key: &str,
1769 operation: SourceRuleOperation,
1770 database: TargetDatabaseIdentity,
1771 store: TargetStoreIdentity,
1772 ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
1773 let entity_source =
1774 EntitySourceKey::try_new("Measured").expect("entity source should admit");
1775 let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
1776 let value_source = FieldSourceKey::try_new("value").expect("value source should admit");
1777 let value_type = TypeSourceKey::try_new("Measure").expect("type source should admit");
1778 let rule_source = RuleSourceKey::try_new("limit").expect("rule source should admit");
1779 let constraint_source =
1780 ConstraintSourceKey::for_targeted_field_rule(&value_source, &value_type, &rule_source);
1781 let entity = EntityFragment::try_new(
1782 name("Measured"),
1783 vec![
1784 FieldFragment::new(
1785 name("id"),
1786 FieldType::Scalar(ScalarType::Nat64),
1787 false,
1788 FieldInsertPolicy::Required,
1789 None,
1790 ),
1791 FieldFragment::new(
1792 name("value"),
1793 FieldType::Named(value_type.clone()),
1794 false,
1795 FieldInsertPolicy::Required,
1796 None,
1797 ),
1798 ],
1799 vec![id_source],
1800 Vec::new(),
1801 Vec::new(),
1802 vec![ConstraintFragment::targeted_rule(
1803 TargetedRuleFragment::new(value_source, value_type, name("limit"), operation),
1804 )],
1805 )
1806 .expect("targeted entity should admit");
1807 let proposal = SchemaProposal::try_compose(
1808 vec![SchemaCapability::ACCEPTED_CHECKS],
1809 database,
1810 SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
1811 expected_head,
1812 vec![
1813 SchemaFragment::try_new(
1814 vec![entity],
1815 vec![NamedTypeFragment::newtype(
1816 name("Measure"),
1817 FieldType::Scalar(ScalarType::Nat8),
1818 )],
1819 )
1820 .expect("schema fragment should admit"),
1821 ],
1822 vec![EntityStoreAssignment::new(entity_source.clone(), store)],
1823 Vec::new(),
1824 )
1825 .expect("schema proposal should compose");
1826 (proposal, entity_source, constraint_source)
1827 }
1828
1829 #[test]
1830 fn database_head_is_empty_only_when_every_store_root_is_absent() {
1831 assert_eq!(
1832 derive_accepted_head(&[("test::A", None), ("test::B", None)]),
1833 ExpectedAcceptedHead::Empty,
1834 );
1835 }
1836
1837 #[test]
1838 fn database_head_covers_store_path_revision_fingerprint_and_absence() {
1839 let first = derive_accepted_head(&[
1840 (
1841 "test::A",
1842 Some(AcceptedStoreHead {
1843 revision: 3,
1844 fingerprint: [0x11; 32],
1845 }),
1846 ),
1847 ("test::B", None),
1848 ]);
1849 let changed_fingerprint = derive_accepted_head(&[
1850 (
1851 "test::A",
1852 Some(AcceptedStoreHead {
1853 revision: 3,
1854 fingerprint: [0x12; 32],
1855 }),
1856 ),
1857 ("test::B", None),
1858 ]);
1859 let changed_absence = derive_accepted_head(&[
1860 (
1861 "test::A",
1862 Some(AcceptedStoreHead {
1863 revision: 3,
1864 fingerprint: [0x11; 32],
1865 }),
1866 ),
1867 (
1868 "test::B",
1869 Some(AcceptedStoreHead {
1870 revision: 1,
1871 fingerprint: [0x22; 32],
1872 }),
1873 ),
1874 ]);
1875
1876 assert_ne!(first, changed_fingerprint);
1877 assert_ne!(first, changed_absence);
1878 assert!(matches!(
1879 first,
1880 ExpectedAcceptedHead::Exact { revision: 3, .. }
1881 ));
1882 }
1883
1884 #[test]
1885 #[allow(
1886 clippy::too_many_lines,
1887 reason = "the end-to-end catalog assertion is clearer as one lifecycle test"
1888 )]
1889 fn generated_check_abort_retires_source_identity_and_allows_fresh_reproposal() {
1890 let database = TargetDatabaseIdentity::from_bytes([0x71; 32]);
1891 let store = TargetStoreIdentity::from_bytes([0x72; 32]);
1892 let (initial, entity_source, _) = generated_check_proposal(
1893 ExpectedAcceptedHead::Empty,
1894 "abort-initial",
1895 false,
1896 database,
1897 store,
1898 );
1899 let initial_candidate = lower_initial_schema_proposal(
1900 &initial,
1901 &[ProposalStoreTarget {
1902 path: "abort::Store",
1903 identity: store,
1904 }],
1905 )
1906 .expect("initial proposal should lower")
1907 .pop()
1908 .expect("initial proposal should produce one candidate");
1909 let (with_check, _, check_source) = generated_check_proposal(
1910 ExpectedAcceptedHead::Exact {
1911 revision: 1,
1912 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x73; 32]),
1913 },
1914 "abort-add-check",
1915 true,
1916 database,
1917 store,
1918 );
1919 let pending_candidate = lower_existing_schema_proposal(
1920 &with_check,
1921 &[ExistingProposalStore {
1922 path: "abort::Store",
1923 identity: store,
1924 bundle: initial_candidate.bundle(),
1925 }],
1926 )
1927 .expect("generated check should lower")
1928 .pop()
1929 .expect("generated check should produce one candidate");
1930 let entity_tag = pending_candidate
1931 .bundle()
1932 .source_bindings_for_tests()
1933 .entity(&entity_source)
1934 .expect("entity source should remain bound");
1935 let constraint_id = pending_candidate
1936 .bundle()
1937 .source_bindings_for_tests()
1938 .constraint(entity_tag, &check_source)
1939 .expect("generated check source should bind");
1940 let pending_snapshot = pending_candidate
1941 .bundle()
1942 .entity_snapshots()
1943 .get(&entity_tag)
1944 .expect("pending entity should exist");
1945 let activation = pending_snapshot
1946 .constraint_catalog()
1947 .activation(constraint_id)
1948 .expect("generated check should remain an activation");
1949 assert_eq!(activation.origin(), ConstraintOrigin::Generated);
1950
1951 let aborted = aborted_generated_row_local_candidate(
1952 pending_candidate.bundle(),
1953 entity_tag,
1954 constraint_id,
1955 )
1956 .expect("generated check abort should build one catalog-native candidate");
1957 let aborted_snapshot = aborted
1958 .bundle()
1959 .entity_snapshots()
1960 .get(&entity_tag)
1961 .expect("aborted entity should remain");
1962 assert!(
1963 aborted_snapshot
1964 .constraint_catalog()
1965 .activation(constraint_id)
1966 .is_none(),
1967 );
1968 assert_eq!(aborted_snapshot.row_layout(), pending_snapshot.row_layout());
1969 assert!(
1970 aborted
1971 .bundle()
1972 .source_bindings_for_tests()
1973 .constraint(entity_tag, &check_source)
1974 .is_none(),
1975 );
1976
1977 let reproposed = lower_existing_schema_proposal(
1978 &with_check,
1979 &[ExistingProposalStore {
1980 path: "abort::Store",
1981 identity: store,
1982 bundle: aborted.bundle(),
1983 }],
1984 )
1985 .expect("aborted generated check should be independently reproposable")
1986 .pop()
1987 .expect("reproposal should produce one candidate");
1988 let replacement_id = reproposed
1989 .bundle()
1990 .source_bindings_for_tests()
1991 .constraint(entity_tag, &check_source)
1992 .expect("reproposal should bind a fresh constraint identity");
1993 assert!(
1994 replacement_id > constraint_id,
1995 "aborted accepted IDs must remain retired",
1996 );
1997 }
1998
1999 #[test]
2000 fn targeted_rule_edit_abort_keeps_prior_accepted_semantics_and_source_identity() {
2001 let database = TargetDatabaseIdentity::from_bytes([0x81; 32]);
2002 let store = TargetStoreIdentity::from_bytes([0x82; 32]);
2003 let (initial, entity_source, constraint_source) = targeted_rule_proposal(
2004 ExpectedAcceptedHead::Empty,
2005 "targeted-abort-initial",
2006 SourceRuleOperation::NumericRangeInclusive {
2007 min: ScalarLiteral::Nat(0),
2008 max: ScalarLiteral::Nat(10),
2009 },
2010 database,
2011 store,
2012 );
2013 let initial_candidate = lower_initial_schema_proposal(
2014 &initial,
2015 &[ProposalStoreTarget {
2016 path: "abort::TargetedStore",
2017 identity: store,
2018 }],
2019 )
2020 .expect("initial targeted proposal should lower")
2021 .pop()
2022 .expect("initial targeted proposal should produce one candidate");
2023 let initial_bundle = initial_candidate.bundle();
2024 let entity_tag = initial_bundle
2025 .source_bindings_for_tests()
2026 .entity(&entity_source)
2027 .expect("entity source should bind");
2028 let constraint_id = initial_bundle
2029 .source_bindings_for_tests()
2030 .constraint(entity_tag, &constraint_source)
2031 .expect("targeted source should bind");
2032 let high_water = initial_bundle.entity_snapshots()[&entity_tag]
2033 .constraint_id_allocator()
2034 .high_water();
2035 let (edited, _, _) = targeted_rule_proposal(
2036 ExpectedAcceptedHead::Exact {
2037 revision: initial_bundle.revision().get(),
2038 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x83; 32]),
2039 },
2040 "targeted-abort-edit",
2041 SourceRuleOperation::NumericMaximumInclusive {
2042 value: ScalarLiteral::Nat(8),
2043 },
2044 database,
2045 store,
2046 );
2047 let staged = lower_existing_schema_proposal(
2048 &edited,
2049 &[ExistingProposalStore {
2050 path: "abort::TargetedStore",
2051 identity: store,
2052 bundle: initial_bundle,
2053 }],
2054 )
2055 .expect("targeted semantic edit should stage")
2056 .pop()
2057 .expect("targeted semantic edit should produce one candidate");
2058 let aborted =
2059 aborted_generated_row_local_candidate(staged.bundle(), entity_tag, constraint_id)
2060 .expect("targeted semantic edit should abort through catalog authority");
2061 let snapshot = &aborted.bundle().entity_snapshots()[&entity_tag];
2062
2063 assert!(
2064 snapshot
2065 .constraint_catalog()
2066 .activation(constraint_id)
2067 .is_none()
2068 );
2069 assert_eq!(snapshot.constraint_id_allocator().high_water(), high_water);
2070 assert_eq!(
2071 aborted
2072 .bundle()
2073 .source_bindings_for_tests()
2074 .constraint(entity_tag, &constraint_source),
2075 Some(constraint_id),
2076 );
2077 assert!(snapshot.constraints().iter().any(|constraint| {
2078 constraint.id() == constraint_id
2079 && matches!(
2080 constraint.kind(),
2081 AcceptedConstraintKind::TargetedRule { operation, .. }
2082 if matches!(
2083 operation.as_ref(),
2084 AcceptedRuleOperation::NumericRangeInclusive { .. }
2085 )
2086 )
2087 }));
2088 }
2089
2090 #[test]
2091 #[allow(
2092 clippy::too_many_lines,
2093 reason = "the staged publication, recovery, and promotion assertions form one lifecycle"
2094 )]
2095 fn targeted_rule_edit_activation_recovers_and_promotes_without_source_model() {
2096 let db = Db::<EvolutionCanister>::new(&EVOLUTION_REGISTRY);
2097 let empty_target =
2098 schema_application_target(&db).expect("empty evolution target should issue");
2099 let store_identity = empty_target
2100 .stores()
2101 .first()
2102 .expect("evolution store should register")
2103 .identity();
2104 let (initial, entity_source, constraint_source) = targeted_rule_proposal(
2105 empty_target.accepted_head().clone(),
2106 "targeted-recovery-initial",
2107 SourceRuleOperation::NumericRangeInclusive {
2108 min: ScalarLiteral::Nat(0),
2109 max: ScalarLiteral::Nat(10),
2110 },
2111 empty_target.database_identity(),
2112 store_identity,
2113 );
2114 assert!(matches!(
2115 apply_schema(&db, &initial)
2116 .expect("initial targeted proposal should publish")
2117 .outcome(),
2118 SchemaChangeOutcome::Applied { .. },
2119 ));
2120
2121 let direct_target =
2122 schema_application_target(&db).expect("direct evolution target should issue");
2123 let (direct_edit, _, _) = targeted_rule_proposal(
2124 direct_target.accepted_head().clone(),
2125 "targeted-direct-edit",
2126 SourceRuleOperation::NumericMaximumInclusive {
2127 value: ScalarLiteral::Nat(8),
2128 },
2129 direct_target.database_identity(),
2130 store_identity,
2131 );
2132 assert!(matches!(
2133 apply_schema(&db, &direct_edit)
2134 .expect("empty-domain semantic edit should publish directly")
2135 .outcome(),
2136 SchemaChangeOutcome::Applied { .. },
2137 ));
2138 let store = db
2139 .store_handle(EVOLUTION_STORE_PATH)
2140 .expect("evolution store should resolve");
2141 let direct = store
2142 .with_schema(SchemaStore::current_accepted_schema_bundle)
2143 .expect("directly edited bundle should remain readable")
2144 .expect("directly edited bundle should exist");
2145 let entity_tag = direct
2146 .source_bindings_for_tests()
2147 .entity(&entity_source)
2148 .expect("entity source should remain bound");
2149 let constraint_id = direct
2150 .source_bindings_for_tests()
2151 .constraint(entity_tag, &constraint_source)
2152 .expect("direct edit should preserve constraint identity");
2153 assert!(
2154 direct.entity_snapshots()[&entity_tag]
2155 .constraint_catalog()
2156 .activation(constraint_id)
2157 .is_none()
2158 );
2159 assert!(
2160 direct.entity_snapshots()[&entity_tag]
2161 .constraints()
2162 .iter()
2163 .any(|constraint| {
2164 constraint.id() == constraint_id
2165 && matches!(
2166 constraint.kind(),
2167 AcceptedConstraintKind::TargetedRule { operation, .. }
2168 if matches!(
2169 operation.as_ref(),
2170 AcceptedRuleOperation::NumericMaximumInclusive { .. }
2171 )
2172 )
2173 })
2174 );
2175
2176 let target = schema_application_target(&db).expect("staged evolution target should issue");
2177 let (edited, _, _) = targeted_rule_proposal(
2178 target.accepted_head().clone(),
2179 "targeted-recovery-edit",
2180 SourceRuleOperation::MultipleOf {
2181 divisor: ScalarLiteral::Nat(2),
2182 },
2183 target.database_identity(),
2184 store_identity,
2185 );
2186 let current = store
2187 .with_schema(SchemaStore::current_accepted_schema_bundle)
2188 .expect("accepted evolution bundle should remain readable")
2189 .expect("directly edited evolution bundle should exist");
2190 let staged = lower_existing_schema_proposal(
2191 &edited,
2192 &[ExistingProposalStore {
2193 path: EVOLUTION_STORE_PATH,
2194 identity: store_identity,
2195 bundle: ¤t,
2196 }],
2197 )
2198 .expect("targeted edit should stage")
2199 .pop()
2200 .expect("targeted edit should produce one candidate");
2201 assert_eq!(
2202 staged
2203 .bundle()
2204 .source_bindings_for_tests()
2205 .constraint(entity_tag, &constraint_source),
2206 Some(constraint_id),
2207 );
2208 let proof = DirectGeneratedRowLocalProof {
2209 candidate_index: 0,
2210 store,
2211 store_path: EVOLUTION_STORE_PATH,
2212 entity_tag,
2213 entity_path: staged.bundle().entity_snapshots()[&entity_tag]
2214 .entity_path()
2215 .to_string(),
2216 constraint_id,
2217 historical_rows: 0,
2218 };
2219 let final_candidates = final_candidates_for_pending_row_local_constraint(
2220 std::slice::from_ref(&staged),
2221 &PendingGeneratedRowLocalConstraint { proof },
2222 )
2223 .expect("final semantic replacement should derive without source input");
2224 let authorities = application_authorities(&db);
2225 let candidate_head =
2226 accepted_head_after_candidates(authorities.as_slice(), &final_candidates)
2227 .expect("final candidate head should derive");
2228 let digest = edited.digest().expect("proposal digest should derive");
2229 let job_id = derive_schema_change_job_id(
2230 target.database_identity(),
2231 edited.submission_key(),
2232 digest,
2233 target.accepted_head(),
2234 )
2235 .expect("job identity should derive");
2236 let receipt = crate::db::schema::SchemaChangeReceipt::new(
2237 target.database_identity(),
2238 edited.submission_key().clone(),
2239 digest,
2240 target.accepted_head().clone(),
2241 SchemaChangeOutcome::Pending {
2242 job: SchemaChangeJob::new(job_id),
2243 candidate_head,
2244 },
2245 )
2246 .expect("pending replacement receipt should admit");
2247 let record = SchemaApplicationRecord::new(
2248 receipt,
2249 vec![
2250 SchemaChangeActivation::new(
2251 store_identity,
2252 entity_tag.value(),
2253 constraint_id.get(),
2254 )
2255 .expect("replacement activation should admit"),
2256 ],
2257 )
2258 .expect("pending replacement record should admit");
2259 let operation =
2260 SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
2261 publish_accepted_schema_candidates_with_application_record(
2262 vec![AcceptedSchemaPublication::new(
2263 EVOLUTION_STORE_PATH,
2264 store,
2265 current.revision(),
2266 &staged,
2267 )],
2268 operation,
2269 )
2270 .expect("staged replacement and record should publish atomically");
2271
2272 forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
2273 ensure_recovered(&db).expect("recovery should restore the staged accepted activation");
2274
2275 let recovered = store
2276 .with_schema(SchemaStore::current_accepted_schema_bundle)
2277 .expect("recovered staged bundle should decode")
2278 .expect("recovered staged bundle should exist");
2279 let recovered_snapshot = recovered.entity_snapshots()[&entity_tag].clone();
2280 let validating_catalog = recovered_snapshot
2281 .constraint_catalog()
2282 .clone()
2283 .with_validation_started(constraint_id)
2284 .expect("recovered replacement should enter validation");
2285 let mut validating_snapshots = recovered.entity_snapshots().clone();
2286 validating_snapshots.insert(
2287 entity_tag,
2288 recovered_snapshot.with_constraint_catalog(validating_catalog),
2289 );
2290 let validating_bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
2291 recovered
2292 .revision()
2293 .checked_next()
2294 .expect("validation revision should remain available"),
2295 recovered.store_path(),
2296 recovered.enum_catalog().clone(),
2297 recovered.composite_catalog().clone(),
2298 recovered.source_bindings_for_tests().clone(),
2299 validating_snapshots,
2300 )
2301 .expect("validating replacement bundle should close");
2302 let validating_candidate = CandidateSchemaRevision::new(validating_bundle)
2303 .expect("validating replacement candidate should encode");
2304 let validating_activation = validating_candidate.bundle().entity_snapshots()[&entity_tag]
2305 .constraint_catalog()
2306 .activation(constraint_id)
2307 .expect("validating replacement activation should remain present");
2308 let validation_job = ConstraintValidationJob::start(
2309 entity_tag,
2310 validating_candidate.bundle().entity_snapshots()[&entity_tag]
2311 .entity_path()
2312 .to_string(),
2313 validating_activation,
2314 None,
2315 )
2316 .expect("validating replacement job should derive from accepted state");
2317 store
2318 .with_schema(|schema| {
2319 schema.validate_live_activation_transition(validating_candidate.bundle())?;
2320 schema.validate_constraint_validation_job_closure_with_change(
2321 validating_candidate.bundle(),
2322 Some(&validation_job),
2323 None,
2324 )
2325 })
2326 .expect("validating replacement transition and job should close");
2327
2328 let started = continue_schema_application(&db, job_id, None).unwrap_or_else(|error| {
2329 panic!(
2330 "recovered replacement should begin validation: {:?}/{:?}",
2331 error.class(),
2332 error.origin(),
2333 )
2334 });
2335 assert_eq!(started.status(), &SchemaChangeProgressStatus::Started);
2336 let mut applied = None;
2337 for _ in 0..8 {
2338 let progress = continue_schema_application(&db, job_id, None)
2339 .expect("replacement validation should advance from durable authority");
2340 if progress.status() == &SchemaChangeProgressStatus::Applied {
2341 applied = Some(progress);
2342 break;
2343 }
2344 }
2345 let applied = applied.expect("empty historical domain should promote within bounded steps");
2346 assert!(matches!(
2347 applied.receipt().outcome(),
2348 SchemaChangeOutcome::Applied { .. }
2349 ));
2350 let promoted = store
2351 .with_schema(SchemaStore::current_accepted_schema_bundle)
2352 .expect("promoted bundle should remain readable")
2353 .expect("promoted bundle should exist");
2354 let snapshot = &promoted.entity_snapshots()[&entity_tag];
2355 assert!(
2356 snapshot
2357 .constraint_catalog()
2358 .activation(constraint_id)
2359 .is_none()
2360 );
2361 assert_eq!(
2362 promoted
2363 .source_bindings_for_tests()
2364 .constraint(entity_tag, &constraint_source),
2365 Some(constraint_id),
2366 );
2367 assert!(snapshot.constraints().iter().any(|constraint| {
2368 constraint.id() == constraint_id
2369 && matches!(
2370 constraint.kind(),
2371 AcceptedConstraintKind::TargetedRule { operation, .. }
2372 if matches!(
2373 operation.as_ref(),
2374 AcceptedRuleOperation::MultipleOf { .. }
2375 )
2376 )
2377 }));
2378 }
2379
2380 #[test]
2381 #[allow(
2382 clippy::too_many_lines,
2383 reason = "the journaled abort, replay, and recovery assertions form one scenario"
2384 )]
2385 fn pending_generated_check_abort_is_atomic_terminal_and_replayable() {
2386 let db = Db::<AbortCanister>::new(&ABORT_REGISTRY);
2387 let empty_target =
2388 schema_application_target(&db).expect("empty application target should issue");
2389 let store_identity = empty_target
2390 .stores()
2391 .first()
2392 .expect("abort store should be registered")
2393 .identity();
2394 let (initial, entity_source, _) = generated_check_proposal(
2395 empty_target.accepted_head().clone(),
2396 "abort-runtime-initial",
2397 false,
2398 empty_target.database_identity(),
2399 store_identity,
2400 );
2401 assert!(matches!(
2402 apply_schema(&db, &initial)
2403 .expect("initial application should publish")
2404 .outcome(),
2405 SchemaChangeOutcome::Applied { .. },
2406 ));
2407
2408 let target =
2409 schema_application_target(&db).expect("existing application target should issue");
2410 let (with_check, _, check_source) = generated_check_proposal(
2411 target.accepted_head().clone(),
2412 "abort-runtime-pending",
2413 true,
2414 target.database_identity(),
2415 store_identity,
2416 );
2417 let store = db
2418 .store_handle(ABORT_STORE_PATH)
2419 .expect("abort store should resolve");
2420 let current = store
2421 .with_schema(SchemaStore::current_accepted_schema_bundle)
2422 .expect("accepted bundle should remain readable")
2423 .expect("initial accepted bundle should exist");
2424 let pending_candidate = lower_existing_schema_proposal(
2425 &with_check,
2426 &[ExistingProposalStore {
2427 path: ABORT_STORE_PATH,
2428 identity: store_identity,
2429 bundle: ¤t,
2430 }],
2431 )
2432 .expect("pending generated check should lower")
2433 .pop()
2434 .expect("pending generated check should produce one candidate");
2435 let entity_tag = pending_candidate
2436 .bundle()
2437 .source_bindings_for_tests()
2438 .entity(&entity_source)
2439 .expect("entity source should bind");
2440 let constraint_id = pending_candidate
2441 .bundle()
2442 .source_bindings_for_tests()
2443 .constraint(entity_tag, &check_source)
2444 .expect("generated check source should bind");
2445 let digest = with_check.digest().expect("proposal digest should derive");
2446 let job_id = derive_schema_change_job_id(
2447 target.database_identity(),
2448 with_check.submission_key(),
2449 digest,
2450 target.accepted_head(),
2451 )
2452 .expect("job identity should derive");
2453 let receipt = crate::db::schema::SchemaChangeReceipt::new(
2454 target.database_identity(),
2455 with_check.submission_key().clone(),
2456 digest,
2457 target.accepted_head().clone(),
2458 SchemaChangeOutcome::Pending {
2459 job: SchemaChangeJob::new(job_id),
2460 candidate_head: ExpectedAcceptedHead::Exact {
2461 revision: pending_candidate.revision().get().saturating_add(2),
2462 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x76; 32]),
2463 },
2464 },
2465 )
2466 .expect("pending receipt should admit");
2467 let record = SchemaApplicationRecord::new(
2468 receipt,
2469 vec![
2470 SchemaChangeActivation::new(
2471 store_identity,
2472 entity_tag.value(),
2473 constraint_id.get(),
2474 )
2475 .expect("application activation should admit"),
2476 ],
2477 )
2478 .expect("pending application record should admit");
2479 let operation =
2480 SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
2481 publish_accepted_schema_candidates_with_application_record(
2482 vec![AcceptedSchemaPublication::new(
2483 ABORT_STORE_PATH,
2484 store,
2485 current.revision(),
2486 &pending_candidate,
2487 )],
2488 operation,
2489 )
2490 .expect("pending candidate and record should publish atomically");
2491
2492 let started = continue_schema_application(&db, job_id, None)
2493 .expect("first continuation should durably start validation");
2494 assert_eq!(started.status(), &SchemaChangeProgressStatus::Started);
2495 let progress =
2496 abort_schema_application(&db, job_id, None).expect("pending application should abort");
2497 assert_eq!(progress.status(), &SchemaChangeProgressStatus::Aborted);
2498 assert!(matches!(
2499 progress.receipt().outcome(),
2500 SchemaChangeOutcome::Aborted { .. },
2501 ));
2502 let replay =
2503 abort_schema_application(&db, job_id, None).expect("terminal abort should replay");
2504 assert_eq!(replay, progress);
2505 assert_eq!(
2506 continue_schema_application(&db, job_id, None)
2507 .expect("continuation after abort should replay terminal state"),
2508 progress,
2509 );
2510
2511 let aborted = store
2512 .with_schema(SchemaStore::current_accepted_schema_bundle)
2513 .expect("accepted bundle should remain readable")
2514 .expect("aborted accepted bundle should exist");
2515 assert!(
2516 aborted
2517 .entity_snapshots()
2518 .get(&entity_tag)
2519 .expect("entity should remain after abort")
2520 .constraint_catalog()
2521 .activation(constraint_id)
2522 .is_none(),
2523 );
2524 assert!(
2525 aborted
2526 .source_bindings_for_tests()
2527 .constraint(entity_tag, &check_source)
2528 .is_none(),
2529 );
2530 assert!(
2531 store
2532 .with_schema(|schema| {
2533 schema.constraint_validation_job(entity_tag, constraint_id)
2534 })
2535 .expect("validation-job storage should remain readable")
2536 .is_none(),
2537 );
2538
2539 ABORT_DATA.with(|store| {
2540 *store.borrow_mut() = DataStore::init_journaled(test_memory(180));
2541 });
2542 ABORT_INDEX.with(|store| {
2543 *store.borrow_mut() = IndexStore::init_journaled(test_memory(181));
2544 });
2545 ABORT_SCHEMA.with(|store| {
2546 *store.borrow_mut() = SchemaStore::init_journaled(test_memory(182));
2547 });
2548 ABORT_JOURNAL.with(|store| {
2549 *store.borrow_mut() = JournalTailStore::init(test_memory(183));
2550 });
2551 forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
2552 ensure_recovered(&db).expect("recovery should retain the terminal abort");
2553 assert_eq!(
2554 abort_schema_application(&db, job_id, None)
2555 .expect("recovered terminal abort should replay"),
2556 progress,
2557 );
2558 assert!(
2559 store
2560 .with_schema(|schema| {
2561 schema.constraint_validation_job(entity_tag, constraint_id)
2562 })
2563 .expect("recovered validation-job storage should remain readable")
2564 .is_none(),
2565 );
2566 }
2567}