1#![deny(missing_docs)]
15
16use std::collections::BTreeSet;
17use std::ffi::OsString;
18use std::fs;
19use std::path::{Component, Path, PathBuf};
20
21use clap::{Parser, Subcommand};
22#[cfg(test)]
23use type_bridge_schema::SystemSchemaSourceService;
24use type_bridge_schema_migration::MigrationGenerationOutcome;
25use type_bridge_schema_migration_typedb::execution_capability_vocabulary;
26use type_bridge_workspace::{
27 ConfigOrigin, ExtensionRegistryService, ExtensionRequirement, SecretReference,
28 SecretReferenceService, TypeBridgeConfigSpec, TypeBridgeWorkspace, TypeBridgeWorkspaceServices,
29 WorkspaceDirectoryAuthority, WorkspaceEnvironment, WorkspaceRoot, WorkspaceServiceError,
30 WorkspaceTransportPolicy,
31};
32
33#[derive(Parser)]
34#[command(
35 name = "type-bridge",
36 version,
37 about = "TypeBridge V2 workspace commands"
38)]
39struct Cli {
40 #[arg(long, global = true, default_value = "typebridge.yaml")]
42 manifest: PathBuf,
43 #[command(subcommand)]
44 command: Command,
45}
46
47#[derive(Subcommand)]
48enum Command {
49 Schema {
51 #[command(subcommand)]
52 command: SchemaCommand,
53 },
54 Migration {
56 #[command(subcommand)]
57 command: MigrationCommand,
58 },
59}
60
61#[derive(Subcommand)]
62enum SchemaCommand {
63 Check,
65 Generate,
67 ExportDeclared {
69 #[arg(long, default_value = "declared-schema.json")]
71 output: PathBuf,
72 },
73}
74
75#[derive(Subcommand)]
76enum MigrationCommand {
77 Make {
79 #[arg(long)]
81 name: String,
82 },
83 Plan,
85 Apply {
87 #[arg(long)]
89 environment: String,
90 #[arg(long = "approve")]
92 approvals: Vec<String>,
93 },
94 Verify {
96 #[arg(long)]
98 environment: String,
99 },
100 Adopt {
102 #[arg(long)]
104 environment: String,
105 #[arg(long)]
107 archive_directory: PathBuf,
108 #[arg(long, default_value = "0000_archive_frontier")]
110 name: String,
111 },
112}
113
114struct DeferSecrets;
116
117impl SecretReferenceService for DeferSecrets {
118 fn validate_reference(
119 &self,
120 _reference: &SecretReference,
121 ) -> Result<(), WorkspaceServiceError> {
122 Ok(())
123 }
124}
125
126struct NoExtensions;
128
129impl ExtensionRegistryService for NoExtensions {
130 fn validate_requirement(
131 &self,
132 _requirement: &ExtensionRequirement,
133 ) -> Result<(), WorkspaceServiceError> {
134 Err(WorkspaceServiceError::new(
135 "extension_handlers_unavailable_in_cli",
136 ))
137 }
138}
139
140pub fn run_cli<I, T>(arguments: I) -> i32
147where
148 I: IntoIterator<Item = T>,
149 T: Into<OsString> + Clone,
150{
151 let cli = match Cli::try_parse_from(arguments) {
152 Ok(cli) => cli,
153 Err(error) => {
154 let _ = error.print();
155 return if error.use_stderr() { 2 } else { 0 };
156 }
157 };
158 match run(&cli) {
159 Ok(()) => 0,
160 Err(message) => {
161 eprintln!("error: {message}");
162 1
163 }
164 }
165}
166
167fn run(cli: &Cli) -> Result<(), String> {
168 let workspace = load_workspace(&cli.manifest)?;
169 match &cli.command {
170 Command::Schema {
171 command: SchemaCommand::Check,
172 } => {
173 println!(
174 "schema sources are valid\n declared identity: {}\n managed semantics: {}",
175 workspace
176 .declared_schema()
177 .declared_identity_fingerprint()
178 .as_fingerprint()
179 .digest()
180 .to_hex(),
181 workspace
182 .managed_state()
183 .managed_semantic_schema()
184 .as_fingerprint()
185 .digest()
186 .to_hex(),
187 );
188 Ok(())
189 }
190 Command::Schema {
191 command: SchemaCommand::Generate,
192 } => run_schema_generate(&workspace),
193 Command::Schema {
194 command: SchemaCommand::ExportDeclared { output },
195 } => run_schema_export_declared(&workspace, output),
196 Command::Migration { command } => match command {
197 MigrationCommand::Make { name } => {
198 let directory = workspace.ensure_migration_directory().map_err(display)?;
199 match workspace
200 .migration_make_in(&directory, name)
201 .map_err(display)?
202 {
203 MigrationGenerationOutcome::UpToDate => {
204 println!("history already reaches the desired schema");
205 }
206 MigrationGenerationOutcome::Generated(generated) => {
207 workspace
208 .write_generated_migration_in(&directory, &generated)
209 .map_err(display)?;
210 let path = directory.display_path().join(generated.file_name());
211 println!(
212 "wrote {}\n safety: {:?}\n preview: {}",
213 path.display(),
214 generated.manifest().safety(),
215 path.with_file_name(generated.preview_file_name()).display(),
216 );
217 }
218 }
219 Ok(())
220 }
221 MigrationCommand::Apply {
222 environment,
223 approvals,
224 } => run_connected(
225 &workspace,
226 environment,
227 ConnectedAction::Apply {
228 approvals: approvals.clone(),
229 },
230 ),
231 MigrationCommand::Verify { environment } => {
232 run_connected(&workspace, environment, ConnectedAction::Verify)
233 }
234 MigrationCommand::Adopt {
235 environment,
236 archive_directory,
237 name,
238 } => run_connected(
239 &workspace,
240 environment,
241 ConnectedAction::Adopt {
242 archive_directory: archive_directory.clone(),
243 name: name.clone(),
244 },
245 ),
246 MigrationCommand::Plan => {
247 let directory = workspace.open_migration_directory().map_err(display)?;
248 let plan = workspace
249 .migration_plan_in(&directory, &BTreeSet::new())
250 .map_err(display)?;
251 if plan.is_empty() {
252 println!("no committed migrations");
253 return Ok(());
254 }
255 for entry in plan {
256 println!(
257 "{}/{} safety={:?} reversible={}",
258 entry.id().app_label().as_str(),
259 entry.id().name().as_str(),
260 entry.safety(),
261 entry.reversible(),
262 );
263 }
264 Ok(())
265 }
266 },
267 }
268}
269
270fn load_workspace(manifest: &PathBuf) -> Result<TypeBridgeWorkspace, String> {
271 let manifest = fs::canonicalize(manifest)
272 .map_err(|error| format!("cannot resolve {}: {error}", manifest.display()))?;
273 let root = manifest
274 .parent()
275 .ok_or_else(|| "workspace manifest has no parent directory".to_owned())?;
276 let file_name = manifest
277 .file_name()
278 .and_then(|name| name.to_str())
279 .ok_or_else(|| "workspace manifest has no UTF-8 file name".to_owned())?;
280 let root = WorkspaceRoot::new(root).map_err(display)?;
281 let source = WorkspaceDirectoryAuthority::open(root.clone()).map_err(display)?;
282 let origin = ConfigOrigin::new(root, file_name, "type-bridge cli").map_err(display)?;
283 let limit = type_bridge_contract::limits::MAX_CANONICAL_BYTES;
287 let captured = source
288 .capture_relative_file(Path::new(file_name), limit)
289 .map_err(|error| format!("cannot read {}: {error}", manifest.display()))?;
290 let bytes = captured.bytes();
291 if bytes.len() > limit {
292 return Err(format!(
293 "{} exceeds the 16 MiB manifest ceiling",
294 manifest.display()
295 ));
296 }
297 let located = TypeBridgeConfigSpec::from_yaml_bytes(bytes, origin).map_err(display)?;
298
299 let available = execution_capability_vocabulary().map_err(display)?;
300 let secrets = DeferSecrets;
301 let extensions = NoExtensions;
302 let services = TypeBridgeWorkspaceServices::new(&source, &secrets, &extensions, &available);
303 TypeBridgeWorkspace::from_located_config(located, &services).map_err(display)
305}
306
307fn display(error: impl std::fmt::Display) -> String {
308 error.to_string()
309}
310
311fn sanitize_connected_error(
318 context: String,
319 code: &'static str,
320 error: type_bridge_orm::SecureConnectError,
321) -> String {
322 match error.credential_safe_diagnostic() {
323 Some(diagnostic) => format!("{context}: {diagnostic}"),
324 None => format!("{context} [{code}]; inspect provider logs"),
325 }
326}
327
328fn sanitize_connected_orm_error(
333 context: String,
334 code: &'static str,
335 _error: type_bridge_orm::OrmError,
336) -> String {
337 format!("{context} [{code}]; inspect provider logs")
338}
339
340fn sanitize_schema_export_error(_error: type_bridge_orm::OrmError) -> String {
343 "cannot export the managed schema [typedb_schema_export_failed]; inspect provider logs"
344 .to_owned()
345}
346
347fn sanitize_migration_execution_outcome(
354 context: &str,
355 outcome: type_bridge_schema_migration::MigrationExecutionOutcome,
356) -> String {
357 use type_bridge_schema_migration::{
358 MigrationExecutionOutcome as Outcome, MigrationExecutionPosition as Position,
359 };
360
361 let render_position = |position| match position {
362 Position::TransactionGroup(ordinal) => format!("transaction group {ordinal}"),
363 Position::ManifestCheckpoint => "manifest checkpoint".to_owned(),
364 };
365 match outcome {
366 Outcome::Applied => format!("{context}: applied"),
367 Outcome::RetrySafe {
368 migration_id,
369 position,
370 diagnostic,
371 } => format!(
372 "{context}: retry-safe at {}/{} ({position}): {diagnostic}",
373 migration_id.app_label().as_str(),
374 migration_id.name().as_str(),
375 position = render_position(position),
376 ),
377 Outcome::RequiresExplicitRecovery {
378 migration_id,
379 position,
380 diagnostic,
381 } => format!(
382 "{context}: explicit recovery required at {}/{} ({position}): {diagnostic}",
383 migration_id.app_label().as_str(),
384 migration_id.name().as_str(),
385 position = render_position(position),
386 ),
387 }
388}
389
390fn run_schema_generate(workspace: &TypeBridgeWorkspace) -> Result<(), String> {
400 use type_bridge_contract::projection::{BindingTarget, ProjectionConfig};
401 use type_bridge_schema::{build_schema_authority, encode_schema_authority, project};
402 use type_bridge_schema_codegen::{PythonEmitter, RustEmitter, TypeScriptEmitter};
403
404 let outputs = workspace.config().outputs();
405 let authority_output = workspace.config().schema_authority_output();
406 if outputs.is_empty() && authority_output.is_none() {
407 return Err(
408 "no generated outputs configured; add bindings.<target>.output or \
409 artifacts.schema-authority.output to the manifest"
410 .into(),
411 );
412 }
413
414 let resolved = workspace.resolved_schema();
415 let authority = build_schema_authority(
416 workspace.declared_schema(),
417 workspace.required_capabilities(),
418 workspace.delta_context(),
419 )
420 .map_err(display)?;
421 let authority_bytes = encode_schema_authority(&authority);
422
423 let mut packages = Vec::with_capacity(outputs.len());
427 for (&target, directory) in outputs {
428 let package = match target {
429 BindingTarget::Python => {
430 let emitter = PythonEmitter::new();
431 let projection = project(
432 resolved,
433 BindingTarget::Python,
434 &ProjectionConfig::python(),
435 &emitter.generator_handlers(),
436 &emitter.code_resources().map_err(display)?,
437 )
438 .map_err(display)?;
439 emitter.emit(&projection, &authority)
440 }
441 BindingTarget::TypeScript => {
442 let emitter = TypeScriptEmitter::new();
443 let projection = project(
444 resolved,
445 BindingTarget::TypeScript,
446 &ProjectionConfig::typescript(),
447 &emitter.generator_handlers(),
448 &emitter.code_resources().map_err(display)?,
449 )
450 .map_err(display)?;
451 emitter.emit(&projection, &authority)
452 }
453 BindingTarget::Rust => {
454 let emitter = RustEmitter::new();
455 let projection = project(
456 resolved,
457 BindingTarget::Rust,
458 &ProjectionConfig::rust(),
459 &emitter.generator_handlers(),
460 &emitter.code_resources().map_err(display)?,
461 )
462 .map_err(display)?;
463 emitter.emit(&projection, &authority)
464 }
465 }
466 .map_err(display)?;
467 packages.push((target, directory, package));
468 }
469
470 let workspace_root = workspace.output_root()?;
475 let mut generated_files = Vec::new();
476 let mut generated_packages = Vec::with_capacity(packages.len());
477 for (target, directory, package) in &packages {
478 let display_root = workspace_root.display_path().join(directory.as_path());
479 let file_count = package.files().len();
480 for (path, bytes) in package.files() {
481 let relative = std::path::Path::new(path);
482 validate_generated_relative_path(relative)?;
483 generated_files.push((directory.as_path().join(relative), bytes.as_slice()));
484 }
485 generated_packages.push((*target, display_root, file_count));
486 }
487 let prepared_authority = authority_output
488 .map(|output| {
489 let path = output.as_path();
490 let _file_name = path
491 .file_name()
492 .ok_or_else(|| "schema-authority output has no file name".to_owned())?;
493 Ok::<_, String>(path.to_path_buf())
494 })
495 .transpose()?;
496
497 if let Some(relative) = &prepared_authority {
498 generated_files.push((relative.clone(), authority_bytes.as_slice()));
499 }
500 workspace_root.write_atomic_batch(
501 generated_files
502 .iter()
503 .map(|(path, bytes)| (path.as_path(), *bytes)),
504 )?;
505
506 for (target, display_root, file_count) in generated_packages {
507 println!(
508 "generated {} file(s) for {} into {}",
509 file_count,
510 match target {
511 BindingTarget::Python => "python",
512 BindingTarget::TypeScript => "typescript",
513 BindingTarget::Rust => "rust",
514 },
515 display_root.display(),
516 );
517 }
518 if let Some(relative) = prepared_authority {
519 println!(
520 "generated schema authority at {}\n authority identity: {}",
521 workspace_root.display_path().join(relative).display(),
522 authority.authority_fingerprint().digest().to_hex(),
523 );
524 }
525 Ok(())
526}
527
528fn run_schema_export_declared(
530 workspace: &TypeBridgeWorkspace,
531 output: &Path,
532) -> Result<(), String> {
533 use type_bridge_contract::schema::encode_declared_schema;
534
535 validate_declared_output_path(output)?;
536 let root = workspace.output_root()?;
537 let parent = root.open_beneath(output.parent().unwrap_or_else(|| std::path::Path::new("")))?;
538 let file_name = output
539 .file_name()
540 .ok_or_else(|| "declared-schema output has no file name".to_owned())?;
541 let destination = parent.display_path().join(file_name);
542 let bytes = encode_declared_schema(workspace.declared_schema()).map_err(display)?;
543 parent.write_atomic(file_name, &bytes)?;
544 println!(
545 "wrote canonical declared schema to {}\n declared identity: {}",
546 destination.display(),
547 workspace
548 .declared_schema()
549 .declared_identity_fingerprint()
550 .as_fingerprint()
551 .digest()
552 .to_hex(),
553 );
554 Ok(())
555}
556
557fn validate_declared_output_path(output: &Path) -> Result<(), String> {
558 let Some(portable) = output.to_str() else {
559 return Err("declared-schema output must be valid UTF-8".into());
560 };
561 let invalid_spelling = portable.is_empty()
562 || portable.contains(['\\', ':', '\0'])
563 || portable.bytes().any(|byte| byte.is_ascii_control())
564 || portable
565 .split('/')
566 .any(|segment| segment.is_empty() || matches!(segment, "." | ".."));
567 let invalid_components = output.is_absolute()
568 || output
569 .components()
570 .any(|component| !matches!(component, Component::Normal(_)));
571 if invalid_spelling || invalid_components {
572 return Err("declared-schema output must be a confined portable workspace path".into());
573 }
574 if output.extension().and_then(|extension| extension.to_str()) != Some("json") {
575 return Err("declared-schema output must end in lowercase .json".into());
576 }
577 Ok(())
578}
579
580fn validate_generated_relative_path(path: &std::path::Path) -> Result<(), String> {
581 if path.as_os_str().is_empty()
582 || path
583 .components()
584 .any(|component| !matches!(component, std::path::Component::Normal(_)))
585 {
586 return Err(format!(
587 "generated output path {:?} is not a confined relative file",
588 path
589 ));
590 }
591 Ok(())
592}
593
594enum ConnectedAction {
595 Apply {
596 approvals: Vec<String>,
597 },
598 Verify,
599 Adopt {
600 archive_directory: PathBuf,
601 name: String,
602 },
603}
604
605fn secure_connect_options(
606 environment: &WorkspaceEnvironment,
607) -> type_bridge_orm::SecureConnectOptions {
608 let tls_mode = match environment.transport_policy() {
609 WorkspaceTransportPolicy::Disabled => type_bridge_orm::TlsMode::Disabled,
610 WorkspaceTransportPolicy::NativeRoots => type_bridge_orm::TlsMode::NativeRoots,
611 WorkspaceTransportPolicy::CustomRootCa(root_ca) => {
612 type_bridge_orm::TlsMode::CustomRootCa(root_ca.as_path().to_path_buf())
613 }
614 };
615 let mut options = type_bridge_orm::SecureConnectOptions {
616 tls_mode,
617 ..type_bridge_orm::SecureConnectOptions::default()
618 };
619 if let Some(port) = environment.http_port() {
620 options.http_port = port;
621 }
622 options
623}
624
625fn preflight_secure_connect_options(
626 workspace: &TypeBridgeWorkspace,
627 environment_name: &str,
628) -> Result<type_bridge_orm::PreparedSecureConnectOptions, String> {
629 let environment = workspace
630 .config()
631 .environment(environment_name)
632 .ok_or_else(|| {
633 format!("environment {environment_name:?} is not owned by this workspace")
634 })?;
635 let options = secure_connect_options(environment);
636 match workspace
637 .capture_environment_custom_root_ca(environment_name)
638 .map_err(display)?
639 {
640 Some(bytes) => options
641 .prepare_transport_from_captured_custom_root(bytes)
642 .map_err(display),
643 None => options.prepare_transport().map_err(display),
644 }
645}
646
647fn run_connected(
648 workspace: &TypeBridgeWorkspace,
649 environment: &str,
650 action: ConnectedAction,
651) -> Result<(), String> {
652 let runtime = tokio::runtime::Runtime::new()
653 .map_err(|error| format!("cannot start the async runtime: {error}"))?;
654 runtime.block_on(run_connected_async(workspace, environment, action))
655}
656
657async fn run_connected_async(
658 workspace: &TypeBridgeWorkspace,
659 environment_name: &str,
660 action: ConnectedAction,
661) -> Result<(), String> {
662 let config = workspace.config();
663 let Some(environment) = config.environment(environment_name) else {
664 let known = config
665 .environments()
666 .keys()
667 .cloned()
668 .collect::<Vec<_>>()
669 .join(", ");
670 return Err(format!(
671 "unknown environment {environment_name:?}; the manifest declares: [{known}]"
672 ));
673 };
674 if matches!(
675 &action,
676 ConnectedAction::Apply { .. } | ConnectedAction::Adopt { .. }
677 ) && !environment.migrate()
678 {
679 return Err(format!(
680 "environment {environment_name:?} is not opted into migration \
681 application; set `migrate: true` in the manifest to allow it"
682 ));
683 }
684 let supported = &type_bridge_schema_migration::typedb_3_12_1_profile().semantic_profile;
685 if config.semantic_profile() != supported {
686 return Err(format!(
687 "workspace semantic profile {:?} cannot run connected TypeDB migration operations \
688 [migration_typedb_semantic_profile_unsupported]; expected {:?}",
689 config.semantic_profile().as_str(),
690 supported.as_str(),
691 ));
692 }
693 environment
694 .requirements()
695 .ensure_supported_by(&execution_capability_vocabulary().map_err(display)?)
696 .map_err(display)?;
697
698 let prepared_adoption = match &action {
701 ConnectedAction::Adopt {
702 archive_directory,
703 name,
704 } => Some(prepare_archive_adoption(
705 workspace,
706 archive_directory,
707 name,
708 )?),
709 ConnectedAction::Apply { .. } | ConnectedAction::Verify => None,
710 };
711
712 let migration_directory = if matches!(&action, ConnectedAction::Adopt { .. }) {
716 workspace.ensure_migration_directory().map_err(display)?
717 } else {
718 workspace.open_migration_directory().map_err(display)?
719 };
720 let ordinary_graph = if prepared_adoption.is_none() {
724 Some(
725 workspace
726 .discover_migrations_in(&migration_directory)
727 .map_err(display)?,
728 )
729 } else {
730 None
731 };
732 let prepared_approvals = match &action {
737 ConnectedAction::Apply { approvals } => Some(bind_approvals(
738 ordinary_graph
739 .as_ref()
740 .ok_or_else(|| "internal apply history was not retained".to_owned())?,
741 approvals,
742 )?),
743 ConnectedAction::Verify | ConnectedAction::Adopt { .. } => None,
744 };
745
746 let options = preflight_secure_connect_options(workspace, environment_name)?;
750 let username = resolve_credential(environment.username())?;
751 let password = resolve_credential(environment.password())?;
752 let journal_name =
753 type_bridge_schema_migration_typedb::derived_journal_database_name(environment.database());
754 let managed_requires_existing = match &action {
761 ConnectedAction::Verify => Some(
762 "`migration verify` is read-only and never creates databases \
763 — apply migrations to this environment first",
764 ),
765 ConnectedAction::Adopt { .. } => {
766 Some("`migration adopt` cutover requires the migrated v1 database to already exist")
767 }
768 ConnectedAction::Apply { .. } => None,
769 };
770 let managed = std::sync::Arc::new(
775 type_bridge_orm::Database::connect_prepared_secure_with_options(
776 environment.uri(),
777 environment.database(),
778 &username,
779 &password,
780 options.clone(),
781 )
782 .await
783 .map_err(|error| {
784 sanitize_connected_error(
785 "cannot connect the managed database".to_owned(),
786 "typedb_database_connect_failed",
787 error,
788 )
789 })?,
790 );
791 let journal = std::sync::Arc::new(
792 type_bridge_orm::Database::connect_prepared_secure_with_options(
793 environment.uri(),
794 &journal_name,
795 &username,
796 &password,
797 options,
798 )
799 .await
800 .map_err(|error| {
801 sanitize_connected_error(
802 "cannot connect the journal database".to_owned(),
803 "typedb_database_connect_failed",
804 error,
805 )
806 })?,
807 );
808 type_bridge_schema_migration_typedb::require_supported_migration_execution_binding(
809 &managed,
810 &journal,
811 workspace.delta_context(),
812 )
813 .map_err(display)?;
814
815 if let Some(reason) = managed_requires_existing {
816 let exists = managed.database_exists().await.map_err(|error| {
817 sanitize_connected_orm_error(
818 format!("cannot check database {:?}", environment.database()),
819 "typedb_database_exists_failed",
820 error,
821 )
822 })?;
823 if !exists {
824 return Err(format!(
825 "database {:?} does not exist; {reason}",
826 environment.database()
827 ));
828 }
829 } else {
830 managed.create_database().await.map_err(|error| {
831 sanitize_connected_orm_error(
832 format!("cannot ensure database {:?}", environment.database()),
833 "typedb_database_ensure_failed",
834 error,
835 )
836 })?;
837 }
838
839 let adoption_files = if let Some(prepared) = prepared_adoption.as_ref() {
844 verify_prepared_adoption_live(&managed, prepared).await?;
845 Some(publish_prepared_adoption(
846 workspace,
847 &migration_directory,
848 prepared,
849 )?)
850 } else {
851 None
852 };
853
854 if matches!(&action, ConnectedAction::Verify) {
855 let exists = journal.database_exists().await.map_err(|error| {
856 sanitize_connected_orm_error(
857 format!("cannot check database {journal_name:?}"),
858 "typedb_database_exists_failed",
859 error,
860 )
861 })?;
862 if !exists {
863 return Err(format!(
864 "database {journal_name:?} does not exist; `migration verify` is read-only and never creates databases"
865 ));
866 }
867 } else {
868 journal.create_database().await.map_err(|error| {
869 sanitize_connected_orm_error(
870 format!("cannot ensure database {journal_name:?}"),
871 "typedb_database_ensure_failed",
872 error,
873 )
874 })?;
875 }
876
877 let genesis = workspace
878 .migration_genesis_in(&migration_directory)
879 .map_err(display)?;
880 let lowering = type_bridge_schema_migration::SchemaLoweringBinding::current(
881 workspace.delta_context().available_capabilities().clone(),
882 )
883 .map_err(display)?;
884 let runner = type_bridge_schema_migration_typedb::TypeDbMigrationRunner::new(
885 managed,
886 journal,
887 genesis.clone(),
888 workspace.delta_context().clone(),
889 lowering,
890 config.migration_policy().clone(),
891 );
892 let holder =
893 type_bridge_schema_migration::LeaseHolderId::new("type-bridge-cli").map_err(display)?;
894 let directory = migration_directory.directory();
895
896 match action {
897 ConnectedAction::Apply { .. } => {
898 let approvals = prepared_approvals
899 .as_deref()
900 .ok_or_else(|| "internal apply approvals were not retained".to_owned())?;
901 let outcome = runner
902 .apply_in(
903 directory,
904 &type_bridge_schema_migration::MigrationApplyTarget::DefaultHead,
905 &holder,
906 approvals,
907 )
908 .await
909 .map_err(display)?;
910 match outcome {
911 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::UpToDate => {
912 println!("applied ledger already reaches the committed head");
913 Ok(())
914 }
915 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
916 type_bridge_schema_migration::MigrationExecutionOutcome::Applied,
917 ) => {
918 println!("applied the committed chain");
919 Ok(())
920 }
921 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
922 outcome,
923 ) => Err(sanitize_migration_execution_outcome(
924 "apply did not complete",
925 outcome,
926 )),
927 }
928 }
929 ConnectedAction::Verify => {
930 let report = runner
931 .verify_in(directory, Some(workspace.declared_schema()))
932 .await
933 .map_err(display)?;
934 if report.is_clean() {
935 println!(
936 "migration state is coherent\n applied frontier: {}",
937 report
938 .applied_frontier()
939 .iter()
940 .map(|id| format!("{}/{}", id.app_label().as_str(), id.name().as_str()))
941 .collect::<Vec<_>>()
942 .join(", "),
943 );
944 Ok(())
945 } else {
946 for finding in report.findings() {
947 eprintln!("drift: {finding:?}");
948 }
949 Err(format!("{} drift finding(s)", report.findings().len()))
950 }
951 }
952 ConnectedAction::Adopt { .. } => {
953 let bridge_display_path = adoption_files
954 .ok_or_else(|| "internal adoption preflight state was not retained".to_owned())?;
955 let prepared = prepared_adoption
956 .as_ref()
957 .ok_or_else(|| "internal adoption authority was not retained".to_owned())?;
958 let outcome = runner
959 .import_verified_legacy_frontier_in(
960 &prepared.history,
961 &prepared.reconstructed,
962 directory,
963 &holder,
964 )
965 .await;
966 match outcome {
967 Ok(
968 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::UpToDate,
969 ) => {
970 println!("archive history is already adopted; the bridged ledger is current");
971 Ok(())
972 }
973 Ok(
974 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
975 type_bridge_schema_migration::MigrationExecutionOutcome::Applied,
976 ),
977 ) => {
978 println!(
979 "adopted the archive history\n genesis: {}\n bridge: {}",
980 migration_directory
981 .display_path()
982 .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
983 .display(),
984 bridge_display_path.display(),
985 );
986 Ok(())
987 }
988 Ok(
989 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
990 outcome,
991 ),
992 ) => Err(sanitize_migration_execution_outcome(
993 "adoption checkpoint did not complete",
994 outcome,
995 )),
996 Err(error) => Err(display(error)),
997 }
998 }
999 }
1000}
1001
1002struct PreparedArchiveAdoption {
1003 history: type_bridge_migration::LegacyAdoptionHistory,
1004 reconstructed: type_bridge_migration::VerifiedLegacyHead,
1005 authority: type_bridge_schema_compat::AdoptedGenesisAuthority,
1006 bridge: type_bridge_schema_migration::VerifiedSchemaMigrationManifest,
1007 bridge_name: String,
1008 bridge_bytes: Vec<u8>,
1009}
1010
1011fn prepare_archive_adoption(
1014 workspace: &TypeBridgeWorkspace,
1015 archive_directory: &std::path::Path,
1016 name: &str,
1017) -> Result<PreparedArchiveAdoption, String> {
1018 let migration_name =
1021 type_bridge_contract::migration::MigrationName::new(name.to_owned()).map_err(display)?;
1022 let bridge_name = format!("{}.tbmigration.json", migration_name.as_str());
1023 let history =
1024 type_bridge_migration::load_adoption_history(archive_directory).map_err(|error| {
1025 format!("archive migration directory failed the checked adoption loader: {error}")
1026 })?;
1027 let reconstructed = type_bridge_migration::reconstruct_legacy_head(&history)
1028 .map_err(|error| format!("archive head reconstruction failed: {error}"))?;
1029 let authority = type_bridge_schema_compat::parse_adopted_genesis_authority(
1030 type_bridge_contract::schema::DocumentId::new("legacy-head-snapshot.typeql")
1031 .map_err(display)?,
1032 reconstructed.schema_typeql(),
1033 )
1034 .map_err(display)?;
1035 let frontier = type_bridge_schema_migration_typedb::extract_legacy_frontier(history.graph())
1036 .map_err(display)?;
1037 let applied_set =
1038 type_bridge_schema_migration_typedb::extract_legacy_applied_set_digest(history.graph())
1039 .map_err(display)?;
1040 let id = type_bridge_contract::migration::MigrationId::from_components(
1041 type_bridge_contract::migration::MigrationAppLabel::new(
1042 workspace.config().app_label().as_str().to_owned(),
1043 )
1044 .map_err(display)?,
1045 migration_name,
1046 );
1047 let bridge = type_bridge_schema_migration::build_legacy_frontier_bridge(
1048 id,
1049 frontier,
1050 applied_set,
1051 authority.declared(),
1052 workspace.delta_context(),
1053 )
1054 .map_err(display)?;
1055 let bridge_bytes =
1056 type_bridge_schema_migration::encode_verified_manifest(&bridge).map_err(display)?;
1057 history
1058 .require_unchanged_head(&reconstructed)
1059 .map_err(|error| {
1060 format!("archive migration directory changed during adoption preparation: {error}")
1061 })?;
1062 Ok(PreparedArchiveAdoption {
1063 history,
1064 reconstructed,
1065 authority,
1066 bridge,
1067 bridge_name,
1068 bridge_bytes,
1069 })
1070}
1071
1072async fn verify_prepared_adoption_live(
1075 managed: &type_bridge_orm::Database,
1076 prepared: &PreparedArchiveAdoption,
1077) -> Result<(), String> {
1078 let export = managed
1079 .schema_text()
1080 .await
1081 .map_err(sanitize_schema_export_error)?;
1082 prepared
1083 .history
1084 .require_unchanged_head(&prepared.reconstructed)
1085 .map_err(|error| format!("archive adoption history changed during live export: {error}"))?;
1086 let expected_internal = type_bridge_schema_compat::released_typeql_to_declared_projection(
1087 type_bridge_contract::schema::DocumentId::new("managed-fence-schema.typeql")
1088 .map_err(display)?,
1089 type_bridge_schema_migration_typedb::MANAGED_FENCE_SCHEMA_TYPEQL,
1090 )
1091 .map_err(display)?;
1092 let live = type_bridge_schema_compat::parse_adopted_genesis_authority_with_internal(
1093 type_bridge_contract::schema::DocumentId::new("legacy-live-head.typeql")
1094 .map_err(display)?,
1095 &export,
1096 Some(&expected_internal),
1097 )
1098 .map_err(display)?;
1099 if live.legacy_identity() != prepared.authority.legacy_identity()
1100 || live.declared().declared_identity_fingerprint()
1101 != prepared
1102 .authority
1103 .declared()
1104 .declared_identity_fingerprint()
1105 || live.released_extension_identity() != prepared.authority.released_extension_identity()
1106 {
1107 return Err(
1108 "live managed schema differs from the independently verified archive-head snapshot"
1109 .to_owned(),
1110 );
1111 }
1112 Ok(())
1113}
1114
1115fn publish_prepared_adoption(
1122 workspace: &TypeBridgeWorkspace,
1123 migration_directory: &type_bridge_workspace::MigrationDirectoryAuthority,
1124 prepared: &PreparedArchiveAdoption,
1125) -> Result<PathBuf, String> {
1126 publish_prepared_adoption_with_after_bridge(workspace, migration_directory, prepared, || {})
1127}
1128
1129fn publish_prepared_adoption_with_after_bridge<F>(
1130 workspace: &TypeBridgeWorkspace,
1131 migration_directory: &type_bridge_workspace::MigrationDirectoryAuthority,
1132 prepared: &PreparedArchiveAdoption,
1133 after_bridge: F,
1134) -> Result<PathBuf, String>
1135where
1136 F: FnOnce(),
1137{
1138 let directory = migration_directory.directory();
1139 let _lock = directory.try_acquire_authoring_lock().map_err(|error| {
1140 if error.kind() == std::io::ErrorKind::WouldBlock {
1141 "migration adoption conflicts with another canonical history publisher".to_owned()
1142 } else {
1143 format!("cannot lock canonical migration publication: {error}")
1144 }
1145 })?;
1146 let genesis_name = type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME;
1147 let genesis_bytes = prepared.reconstructed.schema_typeql().as_bytes();
1148 let mut bridge_created = false;
1149 let mut genesis_created = false;
1150 let mut after_bridge = Some(after_bridge);
1151
1152 let publication = (|| -> Result<(), String> {
1153 if let Some(existing) = read_existing_authority(directory, genesis_name)?
1154 && existing != genesis_bytes
1155 {
1156 return Err(format!(
1157 "{genesis_name} already exists but differs from the verified archive-head snapshot"
1158 ));
1159 }
1160 let bridge_already_published =
1161 if let Some(existing) = read_existing_authority(directory, &prepared.bridge_name)? {
1162 if existing != prepared.bridge_bytes {
1163 return Err(format!(
1164 "{} already exists with different authority bytes",
1165 prepared.bridge_name
1166 ));
1167 }
1168 true
1169 } else {
1170 false
1171 };
1172
1173 let (current, evidence) =
1174 type_bridge_schema_migration::discover_verified_migration_chain_with_evidence_in(
1175 directory,
1176 prepared.authority.declared(),
1177 workspace.delta_context(),
1178 )
1179 .map_err(display)?;
1180 let prospective = if current.manifest(prepared.bridge.id()).is_some() {
1181 current
1182 } else {
1183 let manifests = current
1184 .manifests()
1185 .map(|(_, manifest)| manifest.clone())
1186 .chain(std::iter::once(prepared.bridge.clone()))
1187 .collect::<Vec<_>>();
1188 type_bridge_schema_migration::MigrationHistoryGraph::from_verified(manifests)
1189 .map_err(display)?
1190 };
1191 type_bridge_schema_migration::require_adoption_authority_pair(&prospective, true)
1192 .map_err(display)?;
1193 evidence.require_unchanged(directory).map_err(display)?;
1194 prepared
1195 .history
1196 .require_unchanged_head(&prepared.reconstructed)
1197 .map_err(|error| {
1198 format!("archive adoption history changed before pair publication: {error}")
1199 })?;
1200
1201 if !bridge_already_published {
1202 bridge_created =
1203 publish_authority(directory, &prepared.bridge_name, &prepared.bridge_bytes)?;
1204 }
1205 if let Some(after_bridge) = after_bridge.take() {
1206 after_bridge();
1207 }
1208 prepared
1209 .history
1210 .require_unchanged_head(&prepared.reconstructed)
1211 .map_err(|error| {
1212 format!("archive adoption history changed before genesis publication: {error}")
1213 })?;
1214 genesis_created = publish_authority(directory, genesis_name, genesis_bytes)?;
1215 prepared
1216 .history
1217 .require_unchanged_head(&prepared.reconstructed)
1218 .map_err(|error| {
1219 format!("archive adoption history changed after pair publication: {error}")
1220 })?;
1221 workspace
1222 .discover_migrations_in(migration_directory)
1223 .map_err(display)?;
1224 Ok(())
1225 })();
1226
1227 if let Err(error) = publication {
1228 return Err(rollback_adoption_publication(
1229 directory,
1230 &prepared.bridge_name,
1231 bridge_created,
1232 genesis_created,
1233 error,
1234 ));
1235 }
1236 Ok(migration_directory
1237 .display_path()
1238 .join(&prepared.bridge_name))
1239}
1240
1241fn rollback_adoption_publication(
1242 directory: &type_bridge_schema_migration::MigrationDirectory,
1243 bridge_name: &str,
1244 bridge_created: bool,
1245 genesis_created: bool,
1246 primary: String,
1247) -> String {
1248 let mut cleanup_errors = Vec::new();
1249 if genesis_created
1250 && let Err(error) =
1251 directory.remove_file(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME.as_ref())
1252 {
1253 cleanup_errors.push(format!("cannot remove newly published genesis: {error}"));
1254 }
1255 if bridge_created && let Err(error) = directory.remove_file(bridge_name.as_ref()) {
1256 cleanup_errors.push(format!("cannot remove newly published bridge: {error}"));
1257 }
1258 if (bridge_created || genesis_created)
1259 && let Err(error) = directory.sync_all()
1260 {
1261 cleanup_errors.push(format!("cannot flush adoption rollback: {error}"));
1262 }
1263 if cleanup_errors.is_empty() {
1264 primary
1265 } else {
1266 format!(
1267 "{primary}; adoption publication rollback failed: {}",
1268 cleanup_errors.join("; ")
1269 )
1270 }
1271}
1272
1273fn publish_authority(
1282 directory: &type_bridge_schema_migration::MigrationDirectory,
1283 name: &str,
1284 bytes: &[u8],
1285) -> Result<bool, String> {
1286 use std::io::Write;
1287 if let Some(existing) = read_existing_authority(directory, name)? {
1288 if existing == bytes {
1289 return Ok(false);
1290 }
1291 return Err(format!(
1292 "{name} already exists with different authority bytes"
1293 ));
1294 }
1295 let mut temporary = None;
1296 for attempt in 0..128_u64 {
1297 let candidate = unique_authority_temporary_name(name, attempt);
1298 match directory.create_new(candidate.as_ref()) {
1299 Ok(mut file) => {
1300 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
1301 let _ = directory.remove_file(candidate.as_ref());
1302 return Err(format!("cannot write {candidate}: {error}"));
1303 }
1304 temporary = Some(candidate);
1305 break;
1306 }
1307 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1308 Err(error) => {
1309 return Err(format!("cannot create {candidate}: {error}"));
1310 }
1311 }
1312 }
1313 let temporary = temporary.ok_or_else(|| {
1314 format!("cannot allocate a unique temporary authority file beside {name}")
1315 })?;
1316 let publication = directory.hard_link(temporary.as_ref(), name.as_ref());
1317 match publication {
1318 Ok(()) => {
1319 if let Err(error) = directory.sync_all() {
1320 let _ = directory.remove_file(temporary.as_ref());
1321 return Err(format!("cannot flush migration directory: {error}"));
1322 }
1323 let _ = directory.remove_file(temporary.as_ref());
1324 directory
1325 .sync_all()
1326 .map_err(|error| format!("cannot flush migration directory: {error}"))?;
1327 Ok(true)
1328 }
1329 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1330 let _ = directory.remove_file(temporary.as_ref());
1331 let existing = read_existing_authority(directory, name)?
1332 .ok_or_else(|| format!("{name} disappeared during no-replace publication"))?;
1333 if existing == bytes {
1334 Ok(false)
1335 } else {
1336 Err(format!(
1337 "{name} was concurrently published with different authority bytes"
1338 ))
1339 }
1340 }
1341 Err(error) => {
1342 let _ = directory.remove_file(temporary.as_ref());
1343 Err(format!("cannot publish {name}: {error}"))
1344 }
1345 }
1346}
1347
1348fn read_existing_authority(
1349 directory: &type_bridge_schema_migration::MigrationDirectory,
1350 name: &str,
1351) -> Result<Option<Vec<u8>>, String> {
1352 use std::io::Read;
1353 let file = match directory.open_regular_readonly(name.as_ref()) {
1354 Ok(file) => file,
1355 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1356 Err(error) => return Err(format!("cannot read {name}: {error}")),
1357 };
1358 let limit = type_bridge_contract::limits::MAX_CANONICAL_BYTES;
1359 let mut bytes = Vec::new();
1360 file.take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1))
1361 .read_to_end(&mut bytes)
1362 .map_err(|error| format!("cannot read {name}: {error}"))?;
1363 if bytes.len() > limit {
1364 return Err(format!("{name} exceeds the 16 MiB authority ceiling"));
1365 }
1366 Ok(Some(bytes))
1367}
1368
1369fn unique_authority_temporary_name(name: &str, attempt: u64) -> String {
1370 use std::sync::atomic::{AtomicU64, Ordering};
1371 static NEXT_AUTHORITY_TEMPORARY: AtomicU64 = AtomicU64::new(1);
1372 let nonce = NEXT_AUTHORITY_TEMPORARY.fetch_add(1, Ordering::Relaxed);
1373 format!(".{name}.{}.{}.{}.tmp", std::process::id(), nonce, attempt)
1374}
1375
1376fn resolve_credential(
1377 reference: &type_bridge_workspace::SecretReference,
1378) -> Result<String, String> {
1379 std::env::var(reference.environment_variable()).map_err(|_| {
1380 format!(
1381 "credential environment variable {:?} is not set",
1382 reference.environment_variable()
1383 )
1384 })
1385}
1386
1387#[cfg(test)]
1388mod credential_error_redaction_tests {
1389 use super::*;
1390 use type_bridge_contract::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCode};
1391 use type_bridge_typedb_runtime::RuntimeError;
1392
1393 const PROVIDER_TEXT: &str =
1394 "TB_ADDRESS_SECRET TB_USERNAME_SECRET TB_PASSWORD_SECRET TB_PROVIDER_SECRET";
1395 const SECRETS: [&str; 4] = [
1396 "TB_ADDRESS_SECRET",
1397 "TB_USERNAME_SECRET",
1398 "TB_PASSWORD_SECRET",
1399 "TB_PROVIDER_SECRET",
1400 ];
1401
1402 fn hostile_secure_error() -> type_bridge_orm::SecureConnectError {
1403 type_bridge_orm::SecureConnectError::Runtime(RuntimeError::Connection(
1404 PROVIDER_TEXT.to_owned(),
1405 ))
1406 }
1407
1408 #[test]
1409 fn connected_lifecycle_contexts_drop_hostile_provider_text() {
1410 for (context, code) in [
1411 (
1412 "cannot check database \"managed\"",
1413 "typedb_database_exists_failed",
1414 ),
1415 (
1416 "cannot ensure database \"managed\"",
1417 "typedb_database_ensure_failed",
1418 ),
1419 (
1420 "cannot connect the managed database",
1421 "typedb_database_connect_failed",
1422 ),
1423 ] {
1424 let sanitized =
1425 sanitize_connected_error(context.to_owned(), code, hostile_secure_error());
1426 let rendered = format!("{sanitized}\n{sanitized:?}");
1427 for secret in SECRETS {
1428 assert!(!rendered.contains(secret), "{secret}: {rendered}");
1429 }
1430 assert!(rendered.contains(context), "{rendered}");
1431 assert!(rendered.contains(code), "{rendered}");
1432 }
1433 }
1434
1435 #[test]
1436 fn connected_orm_lifecycle_contexts_drop_hostile_provider_text() {
1437 for (context, code) in [
1438 (
1439 "cannot check database \"managed\"",
1440 "typedb_database_exists_failed",
1441 ),
1442 (
1443 "cannot ensure database \"managed\"",
1444 "typedb_database_ensure_failed",
1445 ),
1446 ] {
1447 let sanitized = sanitize_connected_orm_error(
1448 context.to_owned(),
1449 code,
1450 type_bridge_orm::OrmError::Connection(PROVIDER_TEXT.to_owned()),
1451 );
1452 let rendered = format!("{sanitized}\n{sanitized:?}");
1453 for secret in SECRETS {
1454 assert!(!rendered.contains(secret), "{secret}: {rendered}");
1455 }
1456 assert!(rendered.contains(context), "{rendered}");
1457 assert!(rendered.contains(code), "{rendered}");
1458 }
1459 }
1460
1461 #[test]
1462 fn connected_lifecycle_preserves_only_typed_safe_diagnostics() {
1463 let sanitized = sanitize_connected_error(
1464 "cannot connect the managed database".to_owned(),
1465 "typedb_database_connect_failed",
1466 type_bridge_orm::SecureConnectError::DriverTlsConfiguration { band: 9 },
1467 );
1468 assert!(
1469 sanitized.contains("tls_driver_lowering_failed"),
1470 "{sanitized}"
1471 );
1472 assert!(sanitized.contains("driver band 9"), "{sanitized}");
1473 assert!(
1474 !sanitized.contains("typedb_database_connect_failed"),
1475 "{sanitized}"
1476 );
1477 }
1478
1479 #[test]
1480 fn schema_export_drops_hostile_orm_text_and_source() {
1481 let sanitized = sanitize_schema_export_error(type_bridge_orm::OrmError::Connection(
1482 PROVIDER_TEXT.into(),
1483 ));
1484 let rendered = format!("{sanitized}\n{sanitized:?}");
1485 for secret in SECRETS {
1486 assert!(!rendered.contains(secret), "{secret}: {rendered}");
1487 }
1488 assert!(
1489 rendered.contains("typedb_schema_export_failed"),
1490 "{rendered}"
1491 );
1492 }
1493
1494 #[test]
1495 fn migration_runner_display_omits_provider_details() {
1496 let diagnostic = Diagnostic::new(
1497 DiagnosticCategory::InvalidContract,
1498 DiagnosticCode::new("migration_provider_test_failed").expect("static code"),
1499 "migration provider operation failed",
1500 )
1501 .with_detail("provider", PROVIDER_TEXT);
1502 let error = type_bridge_schema_migration_typedb::MigrationDirectoryApplyError::Diagnostic(
1503 diagnostic,
1504 );
1505
1506 let rendered = display(error);
1507 for secret in SECRETS {
1508 assert!(!rendered.contains(secret), "{secret}: {rendered}");
1509 }
1510 assert!(
1511 rendered.contains("migration_provider_test_failed"),
1512 "{rendered}"
1513 );
1514 }
1515
1516 #[test]
1517 fn migration_outcome_projection_omits_provider_details_for_apply_and_adopt() {
1518 use type_bridge_contract::migration::MigrationId;
1519 use type_bridge_schema_migration::{MigrationExecutionOutcome, MigrationExecutionPosition};
1520
1521 let diagnostic = || {
1522 Diagnostic::new(
1523 DiagnosticCategory::InvalidContract,
1524 DiagnosticCode::new("migration_provider_test_failed").expect("static code"),
1525 "migration provider operation failed",
1526 )
1527 .with_detail("provider", PROVIDER_TEXT)
1528 };
1529 for context in [
1530 "apply did not complete",
1531 "adoption checkpoint did not complete",
1532 ] {
1533 for (outcome, expected_state, expected_position) in [
1534 (
1535 MigrationExecutionOutcome::RetrySafe {
1536 migration_id: MigrationId::new("example", "0001_initial")
1537 .expect("migration id"),
1538 position: MigrationExecutionPosition::TransactionGroup(7),
1539 diagnostic: diagnostic(),
1540 },
1541 "retry-safe",
1542 "transaction group 7",
1543 ),
1544 (
1545 MigrationExecutionOutcome::RequiresExplicitRecovery {
1546 migration_id: MigrationId::new("example", "0001_initial")
1547 .expect("migration id"),
1548 position: MigrationExecutionPosition::ManifestCheckpoint,
1549 diagnostic: diagnostic(),
1550 },
1551 "explicit recovery required",
1552 "manifest checkpoint",
1553 ),
1554 ] {
1555 let rendered = sanitize_migration_execution_outcome(context, outcome);
1556 for secret in SECRETS {
1557 assert!(!rendered.contains(secret), "{secret}: {rendered}");
1558 }
1559 for expected in [
1560 context,
1561 expected_state,
1562 "example/0001_initial",
1563 expected_position,
1564 "migration_provider_test_failed",
1565 "migration provider operation failed",
1566 ] {
1567 assert!(rendered.contains(expected), "{expected}: {rendered}");
1568 }
1569 }
1570 }
1571 }
1572}
1573
1574#[cfg(all(test, unix))]
1575mod output_authority_tests {
1576 use super::*;
1577 use std::os::unix::fs::symlink;
1578
1579 #[test]
1580 fn retained_output_authority_survives_component_swap_without_redirecting() {
1581 let workspace = tempfile::tempdir().expect("workspace directory");
1582 let outside = tempfile::tempdir().expect("outside directory");
1583 fs::create_dir_all(workspace.path().join("generated/python")).expect("output directory");
1584 let authority = WorkspaceDirectoryAuthority::open(
1585 WorkspaceRoot::new(fs::canonicalize(workspace.path()).expect("canonical workspace"))
1586 .expect("workspace root"),
1587 )
1588 .expect("workspace authority");
1589 let root = authority.output_root().expect("output authority");
1590 let output = root
1591 .open_beneath(Path::new("generated/python"))
1592 .expect("output authority");
1593
1594 let held = workspace.path().join("generated/python-held");
1595 fs::rename(workspace.path().join("generated/python"), &held)
1596 .expect("move retained output directory");
1597 symlink(outside.path(), workspace.path().join("generated/python"))
1598 .expect("redirect configured output path");
1599
1600 output
1601 .write_atomic("_models.py".as_ref(), b"retained authority")
1602 .expect("publication remains handle-relative");
1603 assert_eq!(
1604 fs::read(held.join("_models.py")).expect("retained output reads"),
1605 b"retained authority"
1606 );
1607 assert!(
1608 !outside.path().join("_models.py").exists(),
1609 "component replacement redirected output outside the workspace"
1610 );
1611 }
1612
1613 #[test]
1614 fn retained_output_root_survives_root_entry_swap_without_redirecting() {
1615 let workspace = tempfile::tempdir().expect("workspace directory");
1616 let outside = tempfile::tempdir().expect("outside directory");
1617 fs::create_dir_all(workspace.path().join("generated/python")).expect("output directory");
1618 let authority = WorkspaceDirectoryAuthority::open(
1619 WorkspaceRoot::new(fs::canonicalize(workspace.path()).expect("canonical workspace"))
1620 .expect("workspace root"),
1621 )
1622 .expect("workspace authority");
1623 let root = authority.output_root().expect("output authority");
1624 let held = workspace
1625 .path()
1626 .parent()
1627 .expect("temporary parent")
1628 .join(format!(
1629 "{}-retained",
1630 workspace
1631 .path()
1632 .file_name()
1633 .expect("temporary name")
1634 .to_string_lossy()
1635 ));
1636 fs::rename(workspace.path(), &held).expect("workspace root moves after validation");
1637 symlink(outside.path(), workspace.path()).expect("workspace name redirects outside");
1638
1639 let output = root
1640 .open_beneath(Path::new("generated/python"))
1641 .expect("output opens through retained root");
1642 output
1643 .write_atomic("_models.py".as_ref(), b"retained root authority")
1644 .expect("publication remains rooted in the retained handle");
1645 assert_eq!(
1646 fs::read(held.join("generated/python/_models.py")).expect("retained output reads"),
1647 b"retained root authority"
1648 );
1649 assert!(
1650 !outside.path().join("generated/python/_models.py").exists(),
1651 "root replacement redirected output outside the workspace"
1652 );
1653
1654 fs::remove_file(workspace.path()).expect("replacement symlink removes");
1655 fs::rename(&held, workspace.path()).expect("workspace restores for cleanup");
1656 }
1657}
1658
1659fn bind_approvals(
1660 graph: &type_bridge_schema_migration::MigrationHistoryGraph,
1661 approvals: &[String],
1662) -> Result<Vec<type_bridge_schema_migration::MigrationApplyApproval>, String> {
1663 if approvals.is_empty() {
1664 return Ok(Vec::new());
1665 }
1666 approvals
1667 .iter()
1668 .map(|compound| {
1669 let (app_label, name) = compound
1670 .split_once('/')
1671 .ok_or_else(|| format!("approval {compound:?} must be app-label/name"))?;
1672 let id = type_bridge_contract::migration::MigrationId::from_components(
1673 type_bridge_contract::migration::MigrationAppLabel::new(app_label.to_owned())
1674 .map_err(display)?,
1675 type_bridge_contract::migration::MigrationName::new(name.to_owned())
1676 .map_err(display)?,
1677 );
1678 let manifest = graph.manifest(&id).ok_or_else(|| {
1679 format!("approval target {compound:?} is not in the committed history")
1680 })?;
1681 type_bridge_schema_migration::MigrationApplyApproval::for_manifest(manifest)
1682 .map_err(display)
1683 })
1684 .collect()
1685}
1686
1687#[cfg(test)]
1688mod transport_option_tests {
1689 use super::*;
1690
1691 fn environment(policy: WorkspaceTransportPolicy) -> WorkspaceEnvironment {
1692 WorkspaceEnvironment::new(
1693 "typedb.example:1729",
1694 "example",
1695 SecretReference::environment("TYPEBRIDGE_TEST_USERNAME").expect("username reference"),
1696 SecretReference::environment("TYPEBRIDGE_TEST_PASSWORD").expect("password reference"),
1697 )
1698 .expect("environment")
1699 .with_transport_policy(policy)
1700 }
1701
1702 fn custom_root_workspace(root_bytes: &[u8]) -> (tempfile::TempDir, TypeBridgeWorkspace) {
1703 let directory = tempfile::tempdir().expect("workspace directory");
1704 fs::create_dir_all(directory.path().join("schema/fragments")).expect("schema directory");
1705 fs::create_dir_all(directory.path().join("migrations/v2")).expect("migration directory");
1706 fs::create_dir_all(directory.path().join("certs")).expect("certificate directory");
1707 fs::write(
1708 directory.path().join("schema/schema.yaml"),
1709 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
1710 )
1711 .expect("schema set writes");
1712 fs::write(
1713 directory.path().join("schema/fragments/model.yaml"),
1714 "format: typebridge.schema/v2\nentities: {person: {}}\n",
1715 )
1716 .expect("schema writes");
1717 fs::write(directory.path().join("certs/root.pem"), root_bytes).expect("certificate writes");
1718 let manifest = directory.path().join("typebridge.yaml");
1719 fs::write(
1720 &manifest,
1721 "format: typebridge.workspace/v1\n\
1722 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: tls-test\n\
1723 compatibility:\n semantic-profile: typedb-3.12.1/v1\n\
1724 migrations:\n directory: migrations/v2\n app-label: tlstest\n\
1725 environments:\n dev:\n database: tls_test\n uri: never-contact.invalid:1729\n \
1726 tls: 'true'\n tls-root-ca: certs/root.pem\n credential:\n username: \
1727 env:TYPEBRIDGE_TEST_USERNAME\n password: env:TYPEBRIDGE_TEST_PASSWORD\n",
1728 )
1729 .expect("manifest writes");
1730 let workspace = load_workspace(&manifest).expect("custom-root workspace loads");
1731 (directory, workspace)
1732 }
1733
1734 #[test]
1735 fn workspace_transport_policy_maps_without_changing_plaintext_defaults() {
1736 let defaults = type_bridge_orm::SecureConnectOptions::default();
1737 let disabled = secure_connect_options(&environment(WorkspaceTransportPolicy::Disabled));
1738 assert_eq!(disabled.tls_mode, type_bridge_orm::TlsMode::Disabled);
1739 assert_eq!(disabled.http_port, defaults.http_port);
1740 assert_eq!(disabled.server_version, defaults.server_version);
1741
1742 let native = secure_connect_options(
1743 &environment(WorkspaceTransportPolicy::NativeRoots).with_http_port(9443),
1744 );
1745 assert_eq!(native.tls_mode, type_bridge_orm::TlsMode::NativeRoots);
1746 assert_eq!(native.http_port, 9443);
1747 assert_eq!(native.server_version, defaults.server_version);
1748 }
1749
1750 #[test]
1751 fn custom_root_mapping_preserves_the_validated_canonical_path() {
1752 let directory = tempfile::tempdir().expect("workspace directory");
1753 let canonical = fs::canonicalize(directory.path()).expect("canonical workspace");
1754 fs::create_dir_all(canonical.join("certs")).expect("certificate directory");
1755 fs::write(
1756 canonical.join("certs/root.pem"),
1757 b"not parsed at workspace boundary\n",
1758 )
1759 .expect("certificate writes");
1760 let root = WorkspaceRoot::new(canonical.clone()).expect("workspace root");
1761 let root_ca = type_bridge_workspace::WorkspaceRootCa::new(
1762 &root,
1763 "certs/root.pem",
1764 &SystemSchemaSourceService,
1765 )
1766 .expect("confined root CA");
1767
1768 let options = secure_connect_options(
1769 &environment(WorkspaceTransportPolicy::CustomRootCa(root_ca)).with_http_port(8443),
1770 );
1771 assert_eq!(
1772 options.tls_mode,
1773 type_bridge_orm::TlsMode::CustomRootCa(canonical.join("certs/root.pem"))
1774 );
1775 assert_eq!(options.http_port, 8443);
1776 }
1777
1778 #[test]
1779 fn malformed_custom_root_fails_transport_preflight_before_credentials_are_needed() {
1780 let (_directory, workspace) = custom_root_workspace(b"definitely not a certificate\n");
1781
1782 let error = preflight_secure_connect_options(&workspace, "dev")
1783 .expect_err("PEM parsing must happen before credential resolution");
1784 assert!(error.contains("tls_custom_root_ca_invalid_pem"), "{error}");
1785 assert!(!error.contains("TYPEBRIDGE_TEST_USERNAME"), "{error}");
1786 assert!(!error.contains("TYPEBRIDGE_TEST_PASSWORD"), "{error}");
1787 }
1788
1789 #[cfg(unix)]
1790 #[test]
1791 fn workspace_root_swap_to_outside_symlink_is_rejected_at_transport_preflight() {
1792 use std::os::unix::fs::symlink;
1793
1794 let (directory, workspace) = custom_root_workspace(b"initial regular root\n");
1795 let outside = tempfile::tempdir().expect("outside directory");
1796 let configured = directory.path().join("certs/root.pem");
1797
1798 let outside_root = outside.path().join("malicious.pem");
1799 fs::write(
1800 &outside_root,
1801 include_bytes!("../../core/tests/fixtures/valid-root.pem"),
1802 )
1803 .expect("write outside replacement root");
1804 fs::remove_file(&configured).expect("remove validated confined root");
1805 symlink(&outside_root, &configured).expect("install outside symlink after validation");
1806
1807 let error = preflight_secure_connect_options(&workspace, "dev")
1808 .expect_err("retained workspace paths must never follow a replacement symlink");
1809 assert!(error.contains("tls_custom_root_ca_unreadable"), "{error}");
1810 assert!(!error.contains("tls_custom_root_ca_invalid_pem"), "{error}");
1811 }
1812
1813 #[cfg(unix)]
1814 #[test]
1815 fn real_directory_root_replacement_cannot_substitute_custom_trust() {
1816 let (directory, workspace) =
1817 custom_root_workspace(include_bytes!("../../core/tests/fixtures/valid-root.pem"));
1818 let configured_root = directory.path().to_path_buf();
1819 let held_root = configured_root.with_extension("retained-custom-root-ca");
1820 fs::rename(&configured_root, &held_root).expect("move retained workspace root");
1821 fs::create_dir_all(configured_root.join("certs")).expect("replacement root creates");
1822 fs::write(
1823 configured_root.join("certs/root.pem"),
1824 b"attacker-controlled replacement is not a certificate\n",
1825 )
1826 .expect("replacement root writes");
1827
1828 let preflight = preflight_secure_connect_options(&workspace, "dev");
1829
1830 fs::remove_dir_all(&configured_root).expect("replacement root removes");
1831 fs::rename(&held_root, &configured_root).expect("retained root restores");
1832 preflight.expect("transport must use the CA under the retained original root");
1833 }
1834}
1835
1836#[cfg(test)]
1837mod migration_command_tests {
1838 use super::*;
1839
1840 fn write_workspace_manifest(root: &Path, semantic_profile: &str) -> PathBuf {
1841 fs::create_dir_all(root.join("schema/fragments")).expect("schema directory");
1842 fs::write(
1843 root.join("schema/schema.yaml"),
1844 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
1845 )
1846 .expect("schema set writes");
1847 fs::write(
1848 root.join("schema/fragments/model.yaml"),
1849 "format: typebridge.schema/v2\nentities: {person: {}}\n",
1850 )
1851 .expect("schema writes");
1852 let manifest = root.join("typebridge.yaml");
1853 fs::write(
1854 &manifest,
1855 format!(
1856 "format: typebridge.workspace/v1\n\
1857 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: command-test\n\
1858 compatibility:\n semantic-profile: {semantic_profile}\n\
1859 migrations:\n directory: migrations/v2\n app-label: commandtest\n\
1860 environments:\n dev:\n database: command_test\n uri: never-contact.invalid:1729\n migrate: 'true'\n credential:\n username: env:TYPEBRIDGE_COMMAND_TEST_USERNAME\n password: env:TYPEBRIDGE_COMMAND_TEST_PASSWORD\n"
1861 ),
1862 )
1863 .expect("manifest writes");
1864 manifest
1865 }
1866
1867 #[test]
1868 fn migration_make_creates_its_missing_authoring_directory() {
1869 let directory = tempfile::tempdir().expect("workspace directory");
1870 let manifest = write_workspace_manifest(directory.path(), "typedb-3.11.5/v1");
1871 let migration_directory = directory.path().join("migrations/v2");
1872 assert!(!migration_directory.exists());
1873
1874 run(&Cli {
1875 manifest,
1876 command: Command::Migration {
1877 command: MigrationCommand::Make {
1878 name: "initial".to_owned(),
1879 },
1880 },
1881 })
1882 .expect("migration make creates and publishes into its authoring directory");
1883
1884 assert!(
1885 migration_directory
1886 .join("0001_initial.tbmigration.json")
1887 .is_file()
1888 );
1889 assert!(migration_directory.join("0001_initial.typeql").is_file());
1890 }
1891
1892 #[test]
1893 fn unsupported_execution_profile_rejects_before_credentials_or_filesystem_mutation() {
1894 let directory = tempfile::tempdir().expect("workspace directory");
1895 let manifest = write_workspace_manifest(directory.path(), "typedb-3.11.5/v1");
1896 let workspace = load_workspace(&manifest).expect("workspace loads");
1897
1898 for action in [
1899 ConnectedAction::Apply {
1900 approvals: Vec::new(),
1901 },
1902 ConnectedAction::Verify,
1903 ConnectedAction::Adopt {
1904 archive_directory: directory.path().join("missing-archive"),
1905 name: "0000_archive_frontier".to_owned(),
1906 },
1907 ] {
1908 let error = run_connected(&workspace, "dev", action)
1909 .expect_err("every connected migration operation uses the exact profile");
1910
1911 assert!(
1912 error.contains("migration_typedb_semantic_profile_unsupported"),
1913 "{error}"
1914 );
1915 assert!(error.contains("typedb-3.11.5/v1"), "{error}");
1916 assert!(error.contains("typedb-3.12.1/v1"), "{error}");
1917 assert!(
1918 !error.contains("credential environment variable")
1919 && !error.contains("cannot connect")
1920 && !error.contains("cannot check database"),
1921 "profile gate ran after external setup: {error}"
1922 );
1923 }
1924 assert!(
1925 !directory.path().join("migrations/v2").exists(),
1926 "profile rejection must not create the migration directory"
1927 );
1928 }
1929}
1930
1931#[cfg(test)]
1932mod adoption_file_tests {
1933 use super::*;
1934 use sha2::{Digest as _, Sha256};
1935
1936 const LEGACY_SCHEMA: &str = "define\nentity person;\n";
1937
1938 fn adoption_workspace() -> (tempfile::TempDir, TypeBridgeWorkspace) {
1939 let directory = tempfile::tempdir().expect("workspace directory");
1940 fs::create_dir_all(directory.path().join("schema/fragments")).expect("schema directory");
1941 fs::write(
1942 directory.path().join("schema/schema.yaml"),
1943 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
1944 )
1945 .expect("schema set writes");
1946 fs::write(
1947 directory.path().join("schema/fragments/model.yaml"),
1948 "format: typebridge.schema/v2\nentities: {person: {}}\n",
1949 )
1950 .expect("schema writes");
1951 let manifest = directory.path().join("typebridge.yaml");
1952 fs::write(
1953 &manifest,
1954 "format: typebridge.workspace/v1\n\
1955 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: adoption-test\n\
1956 compatibility:\n semantic-profile: typedb-3.12.1/v1\n\
1957 migrations:\n directory: migrations/v2\n app-label: smoke\n\
1958 environments:\n dev:\n database: adoption_test\n uri: never-contact.invalid:1729\n migrate: 'true'\n credential:\n username: env:TYPEBRIDGE_TEST_USERNAME\n password: env:TYPEBRIDGE_TEST_PASSWORD\n",
1959 )
1960 .expect("manifest writes");
1961 let workspace = load_workspace(&manifest).expect("workspace loads");
1962 (directory, workspace)
1963 }
1964
1965 fn write_legacy_fixture(root: &Path) -> PathBuf {
1966 let directory = root.join("migrations/legacy");
1967 fs::create_dir_all(&directory).expect("legacy directory");
1968 let name = "0001_initial";
1969 let python_source = "class Migration:\n operations = []\n";
1970 let checksum = type_bridge_migration::migration_file_checksum(python_source);
1971 let source_sha256 = format!("{:x}", Sha256::digest(python_source.as_bytes()));
1972 let schema_hash = format!("{:x}", Sha256::digest(LEGACY_SCHEMA.as_bytes()));
1973 fs::write(directory.join(format!("{name}.py")), python_source)
1974 .expect("legacy source writes");
1975 let adoption = type_bridge_migration::LegacyAdoptionMetadata::new(
1976 "legacy",
1977 name,
1978 Vec::new(),
1979 checksum,
1980 source_sha256,
1981 type_bridge_migration::LegacySchemaEffect::Snapshot,
1982 type_bridge_migration::MigrationDependencySpec {
1983 app_label: "legacy".to_owned(),
1984 migration_name: name.to_owned(),
1985 },
1986 schema_hash.clone(),
1987 )
1988 .expect("legacy adoption metadata");
1989 fs::write(
1990 directory.join(format!("{name}.adoption.json")),
1991 serde_json::to_vec_pretty(&adoption).expect("metadata encodes"),
1992 )
1993 .expect("metadata writes");
1994 let snapshot = directory.join("snapshots/v0001");
1995 fs::create_dir_all(&snapshot).expect("snapshot directory");
1996 fs::write(snapshot.join("schema.tql"), LEGACY_SCHEMA).expect("snapshot schema writes");
1997 fs::write(
1998 snapshot.join("snapshot.json"),
1999 serde_json::to_vec_pretty(&serde_json::json!({
2000 "version": "v0001",
2001 "source_migration": name,
2002 "schema_hash": schema_hash,
2003 "file_hashes": {"schema.tql": schema_hash},
2004 "type_bridge_version": "1.5.11",
2005 "type_bridge_core_version": "1.5.11"
2006 }))
2007 .expect("snapshot manifest encodes"),
2008 )
2009 .expect("snapshot manifest writes");
2010 directory
2011 }
2012
2013 #[test]
2014 fn authority_publication_is_atomic_no_replace_and_resumable() {
2015 let directory = tempfile::tempdir().expect("directory");
2016 let authority =
2017 type_bridge_schema_migration::MigrationDirectory::open_ambient(directory.path())
2018 .expect("directory authority");
2019 let name = "adopted-genesis.typeql";
2020 let path = directory.path().join("adopted-genesis.typeql");
2021 assert!(
2022 publish_authority(&authority, name, b"define\nentity person;\n")
2023 .expect("first publish")
2024 );
2025 assert!(
2026 !publish_authority(&authority, name, b"define\nentity person;\n")
2027 .expect("identical recovery")
2028 );
2029 assert!(publish_authority(&authority, name, b"define\nentity company;\n").is_err());
2030 assert_eq!(
2031 fs::read(&path).expect("authority reads"),
2032 b"define\nentity person;\n"
2033 );
2034 assert!(
2035 fs::read_dir(directory.path())
2036 .expect("directory reads")
2037 .all(|entry| !entry
2038 .expect("entry")
2039 .file_name()
2040 .to_string_lossy()
2041 .ends_with(".tmp"))
2042 );
2043 }
2044
2045 #[cfg(unix)]
2046 #[test]
2047 fn authority_publication_rejects_final_symlink() {
2048 use std::os::unix::fs::symlink;
2049
2050 let directory = tempfile::tempdir().expect("directory");
2051 let outside = directory.path().join("outside");
2052 fs::write(&outside, b"untouched").expect("outside writes");
2053 let path = directory.path().join("adopted-genesis.typeql");
2054 symlink(&outside, &path).expect("symlink");
2055 let authority =
2056 type_bridge_schema_migration::MigrationDirectory::open_ambient(directory.path())
2057 .expect("directory authority");
2058
2059 assert!(publish_authority(&authority, "adopted-genesis.typeql", b"replacement").is_err());
2060 assert_eq!(fs::read(&outside).expect("outside reads"), b"untouched");
2061 }
2062
2063 #[test]
2064 fn invalid_adoption_name_creates_no_canonical_directory() {
2065 let (directory, workspace) = adoption_workspace();
2066 let missing_archive = directory.path().join("missing-legacy");
2067 let error = run_connected(
2068 &workspace,
2069 "dev",
2070 ConnectedAction::Adopt {
2071 archive_directory: missing_archive,
2072 name: String::new(),
2073 },
2074 )
2075 .expect_err("invalid name fails before history or network access");
2076 assert!(error.contains("migration"), "{error}");
2077 assert!(
2078 !directory.path().join("migrations/v2").exists(),
2079 "bad-name validation must not create canonical filesystem state"
2080 );
2081 }
2082
2083 #[test]
2084 fn invalid_apply_approvals_fail_before_credentials_or_network() {
2085 let (directory, workspace) = adoption_workspace();
2086 fs::create_dir_all(directory.path().join("migrations/v2")).expect("canonical directory");
2087
2088 for (approval, expected) in [
2089 ("not-a-compound-id", "must be app-label/name"),
2090 ("smoke/0001_missing", "is not in the committed history"),
2091 ] {
2092 let error = run_connected(
2093 &workspace,
2094 "dev",
2095 ConnectedAction::Apply {
2096 approvals: vec![approval.to_owned()],
2097 },
2098 )
2099 .expect_err("invalid approval is rejected by local authority");
2100 assert!(error.contains(expected), "{approval}: {error}");
2101 assert!(
2102 !error.contains("credential")
2103 && !error.contains("connect")
2104 && !error.contains("database"),
2105 "approval validation ran after external setup: {error}"
2106 );
2107 }
2108 }
2109
2110 #[test]
2111 fn adoption_retry_completes_either_exact_orphan_direction() {
2112 for orphan in ["genesis", "bridge"] {
2113 let (directory, workspace) = adoption_workspace();
2114 let legacy = write_legacy_fixture(directory.path());
2115 let prepared = prepare_archive_adoption(&workspace, &legacy, "0000_archive_frontier")
2116 .expect("adoption prepares");
2117 let migration_directory = workspace
2118 .ensure_migration_directory()
2119 .expect("canonical directory");
2120 match orphan {
2121 "genesis" => {
2122 publish_authority(
2123 migration_directory.directory(),
2124 type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME,
2125 prepared.reconstructed.schema_typeql().as_bytes(),
2126 )
2127 .expect("genesis orphan publishes");
2128 }
2129 "bridge" => {
2130 publish_authority(
2131 migration_directory.directory(),
2132 &prepared.bridge_name,
2133 &prepared.bridge_bytes,
2134 )
2135 .expect("bridge orphan publishes");
2136 }
2137 _ => unreachable!(),
2138 }
2139
2140 publish_prepared_adoption(&workspace, &migration_directory, &prepared)
2141 .expect("adoption retry completes the exact orphan");
2142 workspace
2143 .discover_migrations_in(&migration_directory)
2144 .expect("completed adoption pair discovers");
2145 assert!(
2146 migration_directory
2147 .display_path()
2148 .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
2149 .is_file()
2150 );
2151 assert!(
2152 migration_directory
2153 .display_path()
2154 .join(&prepared.bridge_name)
2155 .is_file()
2156 );
2157 }
2158 }
2159
2160 #[test]
2161 fn legacy_history_race_after_bridge_rolls_back_new_publication() {
2162 let (directory, workspace) = adoption_workspace();
2163 let legacy = write_legacy_fixture(directory.path());
2164 let prepared = prepare_archive_adoption(&workspace, &legacy, "0000_archive_frontier")
2165 .expect("adoption prepares");
2166 let migration_directory = workspace
2167 .ensure_migration_directory()
2168 .expect("canonical directory");
2169 let legacy_source = legacy.join("0001_initial.py");
2170
2171 let error = publish_prepared_adoption_with_after_bridge(
2172 &workspace,
2173 &migration_directory,
2174 &prepared,
2175 || {
2176 fs::write(
2177 &legacy_source,
2178 "class Migration:\n operations = ['changed']\n",
2179 )
2180 .expect("race mutation writes");
2181 },
2182 )
2183 .expect_err("legacy authority race aborts pair publication");
2184 assert!(error.contains("changed"), "{error}");
2185 assert!(
2186 !migration_directory
2187 .display_path()
2188 .join(&prepared.bridge_name)
2189 .exists(),
2190 "new bridge is rolled back"
2191 );
2192 assert!(
2193 !migration_directory
2194 .display_path()
2195 .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
2196 .exists(),
2197 "genesis is never published after the race"
2198 );
2199 }
2200}