1#![deny(missing_docs)]
16
17use std::collections::BTreeSet;
18use std::ffi::OsString;
19use std::fs;
20use std::path::{Component, Path, PathBuf};
21
22use clap::{Parser, Subcommand, ValueEnum};
23#[cfg(test)]
24use type_bridge_schema::SystemSchemaSourceService;
25use type_bridge_schema_migration::MigrationGenerationOutcome;
26use type_bridge_schema_migration_typedb::execution_capability_vocabulary;
27use type_bridge_workspace::{
28 ConfigOrigin, ExtensionRegistryService, ExtensionRequirement, SecretReference,
29 SecretReferenceService, TypeBridgeConfigSpec, TypeBridgeWorkspace, TypeBridgeWorkspaceServices,
30 WorkspaceDirectoryAuthority, WorkspaceEnvironment, WorkspaceRoot, WorkspaceServiceError,
31 WorkspaceTransportPolicy, c_symbol_prefix_for_app_label,
32};
33
34mod build_identity {
35 include!(concat!(env!("OUT_DIR"), "/cli_build_identity.rs"));
36}
37
38#[derive(Parser)]
39#[command(
40 name = "type-bridge",
41 version = build_identity::CLI_VERSION,
42 about = "TypeBridge V2 workspace commands"
43)]
44struct Cli {
45 #[arg(long, global = true, default_value = "typebridge.yaml")]
47 manifest: PathBuf,
48 #[command(subcommand)]
49 command: Command,
50}
51
52#[derive(Subcommand)]
53enum Command {
54 Schema {
56 #[command(subcommand)]
57 command: SchemaCommand,
58 },
59 Migration {
61 #[command(subcommand)]
62 command: MigrationCommand,
63 },
64}
65
66#[derive(Subcommand)]
67enum SchemaCommand {
68 Check,
70 Generate,
72 ExportDeclared {
74 #[arg(long, default_value = "declared-schema.json")]
76 output: PathBuf,
77 },
78}
79
80#[derive(Subcommand)]
81enum MigrationCommand {
82 Make {
84 #[arg(long)]
86 name: String,
87 #[arg(long)]
89 backfill_intent: Option<PathBuf>,
90 },
91 Plan,
93 Apply {
95 #[arg(long)]
97 environment: String,
98 #[arg(long = "approve")]
100 approvals: Vec<String>,
101 },
102 Verify {
104 #[arg(long)]
106 environment: String,
107 },
108 Rollback {
110 #[arg(long)]
112 environment: String,
113 #[arg(long = "remove", required = true)]
115 removals: Vec<String>,
116 #[arg(long = "approve")]
118 approvals: Vec<String>,
119 #[arg(long)]
121 execute: bool,
122 #[arg(long, value_enum, default_value_t = RollbackOutput::Text)]
124 output: RollbackOutput,
125 },
126 Adopt {
128 #[arg(long)]
130 environment: String,
131 #[arg(long)]
133 archive_directory: PathBuf,
134 #[arg(long, default_value = "0000_archive_frontier")]
136 name: String,
137 },
138}
139
140#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
141enum RollbackOutput {
142 Text,
143 Json,
144}
145
146struct DeferSecrets;
148
149impl SecretReferenceService for DeferSecrets {
150 fn validate_reference(
151 &self,
152 _reference: &SecretReference,
153 ) -> Result<(), WorkspaceServiceError> {
154 Ok(())
155 }
156}
157
158struct NoExtensions;
160
161impl ExtensionRegistryService for NoExtensions {
162 fn validate_requirement(
163 &self,
164 _requirement: &ExtensionRequirement,
165 ) -> Result<(), WorkspaceServiceError> {
166 Err(WorkspaceServiceError::new(
167 "extension_handlers_unavailable_in_cli",
168 ))
169 }
170}
171
172pub fn run_cli<I, T>(arguments: I) -> i32
179where
180 I: IntoIterator<Item = T>,
181 T: Into<OsString> + Clone,
182{
183 let cli = match Cli::try_parse_from(arguments) {
184 Ok(cli) => cli,
185 Err(error) => {
186 let _ = error.print();
187 return if error.use_stderr() { 2 } else { 0 };
188 }
189 };
190 match run(&cli) {
191 Ok(()) => 0,
192 Err(message) => {
193 eprintln!("error: {message}");
194 1
195 }
196 }
197}
198
199fn run(cli: &Cli) -> Result<(), String> {
200 let workspace = load_workspace(&cli.manifest)?;
201 match &cli.command {
202 Command::Schema {
203 command: SchemaCommand::Check,
204 } => {
205 println!(
206 "schema sources are valid\n declared identity: {}\n managed semantics: {}",
207 workspace
208 .declared_schema()
209 .declared_identity_fingerprint()
210 .as_fingerprint()
211 .digest()
212 .to_hex(),
213 workspace
214 .managed_state()
215 .managed_semantic_schema()
216 .as_fingerprint()
217 .digest()
218 .to_hex(),
219 );
220 Ok(())
221 }
222 Command::Schema {
223 command: SchemaCommand::Generate,
224 } => run_schema_generate(&workspace),
225 Command::Schema {
226 command: SchemaCommand::ExportDeclared { output },
227 } => run_schema_export_declared(&workspace, output),
228 Command::Migration { command } => match command {
229 MigrationCommand::Make {
230 name,
231 backfill_intent,
232 } => {
233 let directory = workspace.ensure_migration_directory().map_err(display)?;
234 if let Some(intent) = backfill_intent {
235 let generated = workspace
236 .author_backfill_migration_in(&directory, name, intent)
237 .map_err(display)?;
238 let path = directory.display_path().join(generated.file_name());
239 println!(
240 "wrote {}\n safety: {:?}\n preview: {}",
241 path.display(),
242 generated.manifest().safety(),
243 path.with_file_name(generated.preview_file_name()).display(),
244 );
245 return Ok(());
246 }
247 match workspace
248 .migration_make_in(&directory, name)
249 .map_err(display)?
250 {
251 MigrationGenerationOutcome::UpToDate => {
252 println!("history already reaches the desired schema");
253 }
254 MigrationGenerationOutcome::Generated(generated) => {
255 workspace
256 .write_generated_migration_in(&directory, &generated)
257 .map_err(display)?;
258 let path = directory.display_path().join(generated.file_name());
259 println!(
260 "wrote {}\n safety: {:?}\n preview: {}",
261 path.display(),
262 generated.manifest().safety(),
263 path.with_file_name(generated.preview_file_name()).display(),
264 );
265 }
266 }
267 Ok(())
268 }
269 MigrationCommand::Apply {
270 environment,
271 approvals,
272 } => run_connected(
273 &workspace,
274 environment,
275 ConnectedAction::Apply {
276 approvals: approvals.clone(),
277 },
278 ),
279 MigrationCommand::Verify { environment } => {
280 run_connected(&workspace, environment, ConnectedAction::Verify)
281 }
282 MigrationCommand::Rollback {
283 environment,
284 removals,
285 approvals,
286 execute,
287 output,
288 } => run_migration_rollback(
289 &workspace,
290 environment,
291 removals,
292 approvals,
293 *execute,
294 *output,
295 ),
296 MigrationCommand::Adopt {
297 environment,
298 archive_directory,
299 name,
300 } => run_connected(
301 &workspace,
302 environment,
303 ConnectedAction::Adopt {
304 archive_directory: archive_directory.clone(),
305 name: name.clone(),
306 },
307 ),
308 MigrationCommand::Plan => {
309 let directory = workspace.open_migration_directory().map_err(display)?;
310 let plan = workspace
311 .migration_plan_in(&directory, &BTreeSet::new())
312 .map_err(display)?;
313 if plan.is_empty() {
314 println!("no committed migrations");
315 return Ok(());
316 }
317 for entry in plan {
318 println!(
319 "{}/{} safety={:?} reversible={}",
320 entry.id().app_label().as_str(),
321 entry.id().name().as_str(),
322 entry.safety(),
323 entry.reversible(),
324 );
325 }
326 Ok(())
327 }
328 },
329 }
330}
331
332fn run_migration_rollback(
333 workspace: &TypeBridgeWorkspace,
334 environment: &str,
335 removals: &[String],
336 approvals: &[String],
337 execute: bool,
338 output: RollbackOutput,
339) -> Result<(), String> {
340 if workspace.config().environment(environment).is_none() {
341 return Err(format!(
342 "unknown environment {environment:?}; rollback requires an exact workspace environment binding"
343 ));
344 }
345 let directory = workspace.open_migration_directory().map_err(display)?;
346 let graph = workspace
347 .discover_migrations_in(&directory)
348 .map_err(display)?;
349 let removals = parse_migration_ids(&graph, removals, "rollback removal")?;
350 let _ = bind_rollback_approvals(&graph, approvals)?;
351 let applied = graph
352 .manifests()
353 .map(|(id, _)| id.clone())
354 .collect::<BTreeSet<_>>();
355 let lowering = type_bridge_schema_migration::SchemaLoweringBinding::current(
356 workspace.delta_context().available_capabilities().clone(),
357 )
358 .map_err(display)?;
359 let plan = type_bridge_schema_migration::build_verified_migration_rollback_preview(
360 &graph,
361 &applied,
362 &removals,
363 workspace.delta_context(),
364 &lowering,
365 )
366 .map_err(display)?;
367 render_rollback_preview(environment, &plan, execute, output)?;
368 if !execute {
369 return Ok(());
370 }
371 run_connected(
372 workspace,
373 environment,
374 ConnectedAction::Rollback {
375 removals,
376 approvals: approvals.to_vec(),
377 },
378 )
379}
380
381fn render_rollback_preview(
382 environment: &str,
383 plan: &type_bridge_schema_migration::VerifiedMigrationRollbackPlan,
384 execute: bool,
385 output: RollbackOutput,
386) -> Result<(), String> {
387 let order = plan
388 .rollbacks()
389 .iter()
390 .map(|rollback| {
391 format!(
392 "{}/{}",
393 rollback.manifest().id().app_label().as_str(),
394 rollback.manifest().id().name().as_str()
395 )
396 })
397 .collect::<Vec<_>>();
398 let target = plan
399 .remaining_applied()
400 .iter()
401 .map(|id| format!("{}/{}", id.app_label().as_str(), id.name().as_str()))
402 .collect::<Vec<_>>();
403 let safety = plan
404 .rollbacks()
405 .iter()
406 .map(|rollback| rollback_safety_wire(rollback.rollback_safety()).to_owned())
407 .collect::<Vec<_>>();
408 let plan_identity = plan
409 .rollbacks()
410 .iter()
411 .map(|rollback| rollback.digest().to_hex())
412 .collect::<Vec<_>>()
413 .join(":");
414 let reverse_backfills = plan
415 .rollbacks()
416 .iter()
417 .map(|rollback| rollback.backfills().len())
418 .sum::<usize>();
419 match output {
420 RollbackOutput::Text => println!(
421 "rollback preview\n environment: {environment}\n basis: committed-history\n plan identity: {plan_identity}\n order: {}\n target applied: {}\n safety: {}\n reverse backfills: {reverse_backfills}\n execution requested: {execute}",
422 order.join(", "),
423 target.join(", "),
424 safety.join(", "),
425 ),
426 RollbackOutput::Json => {
427 let strings = |values: &[String]| {
428 values
429 .iter()
430 .map(|value| format!("\"{value}\""))
431 .collect::<Vec<_>>()
432 .join(",")
433 };
434 println!(
435 "{{\"basis\":\"committed-history\",\"environment\":\"{environment}\",\"execute\":{execute},\"format\":\"typebridge.migration-rollback-preview/v1\",\"order\":[{}],\"plan_identity\":\"{plan_identity}\",\"reverse_backfills\":{reverse_backfills},\"safety\":[{}],\"target_applied\":[{}]}}",
436 strings(&order),
437 strings(&safety),
438 strings(&target),
439 );
440 }
441 }
442 Ok(())
443}
444
445fn rollback_safety_wire(safety: type_bridge_schema::SafetyClass) -> &'static str {
446 match safety {
447 type_bridge_schema::SafetyClass::FormalOnly => "formal_only",
448 type_bridge_schema::SafetyClass::SchemaMetadata => "schema_metadata",
449 type_bridge_schema::SafetyClass::Additive => "additive",
450 type_bridge_schema::SafetyClass::Conditional => "conditional",
451 type_bridge_schema::SafetyClass::BackfillRequired => "backfill_required",
452 type_bridge_schema::SafetyClass::Destructive => "destructive",
453 type_bridge_schema::SafetyClass::Opaque => "opaque",
454 type_bridge_schema::SafetyClass::Unsupported => "unsupported",
455 }
456}
457
458fn load_workspace(manifest: &PathBuf) -> Result<TypeBridgeWorkspace, String> {
459 let manifest = fs::canonicalize(manifest)
460 .map_err(|error| format!("cannot resolve {}: {error}", manifest.display()))?;
461 let root = manifest
462 .parent()
463 .ok_or_else(|| "workspace manifest has no parent directory".to_owned())?;
464 let file_name = manifest
465 .file_name()
466 .and_then(|name| name.to_str())
467 .ok_or_else(|| "workspace manifest has no UTF-8 file name".to_owned())?;
468 let root = WorkspaceRoot::new(root).map_err(display)?;
469 let source = WorkspaceDirectoryAuthority::open(root.clone()).map_err(display)?;
470 let origin = ConfigOrigin::new(root, file_name, "type-bridge cli").map_err(display)?;
471 let limit = type_bridge_contract::limits::MAX_CANONICAL_BYTES;
475 let captured = source
476 .capture_relative_file(Path::new(file_name), limit)
477 .map_err(|error| format!("cannot read {}: {error}", manifest.display()))?;
478 let bytes = captured.bytes();
479 if bytes.len() > limit {
480 return Err(format!(
481 "{} exceeds the 16 MiB manifest ceiling",
482 manifest.display()
483 ));
484 }
485 let located = TypeBridgeConfigSpec::from_yaml_bytes(bytes, origin).map_err(display)?;
486
487 let available = execution_capability_vocabulary().map_err(display)?;
488 let secrets = DeferSecrets;
489 let extensions = NoExtensions;
490 let services = TypeBridgeWorkspaceServices::new(&source, &secrets, &extensions, &available);
491 TypeBridgeWorkspace::from_located_config(located, &services).map_err(display)
493}
494
495fn display(error: impl std::fmt::Display) -> String {
496 error.to_string()
497}
498
499fn sanitize_connected_error(
506 context: String,
507 code: &'static str,
508 error: type_bridge_orm::SecureConnectError,
509) -> String {
510 match error.credential_safe_diagnostic() {
511 Some(diagnostic) => format!("{context}: {diagnostic}"),
512 None => format!("{context} [{code}]; inspect provider logs"),
513 }
514}
515
516fn sanitize_connected_orm_error(
521 context: String,
522 code: &'static str,
523 _error: type_bridge_orm::OrmError,
524) -> String {
525 format!("{context} [{code}]; inspect provider logs")
526}
527
528fn sanitize_schema_export_error(_error: type_bridge_orm::OrmError) -> String {
531 "cannot export the managed schema [typedb_schema_export_failed]; inspect provider logs"
532 .to_owned()
533}
534
535fn sanitize_migration_execution_outcome(
542 context: &str,
543 outcome: type_bridge_schema_migration::MigrationExecutionOutcome,
544) -> String {
545 use type_bridge_schema_migration::{
546 MigrationExecutionOutcome as Outcome, MigrationExecutionPosition as Position,
547 };
548
549 let render_position = |position| match position {
550 Position::TransactionGroup(ordinal) => format!("transaction group {ordinal}"),
551 Position::BackfillStep(ordinal) => format!("backfill step {ordinal}"),
552 Position::ManifestCheckpoint => "manifest checkpoint".to_owned(),
553 };
554 match outcome {
555 Outcome::Applied { .. } => format!("{context}: applied"),
556 Outcome::RetrySafe {
557 migration_id,
558 position,
559 diagnostic,
560 } => format!(
561 "{context}: retry-safe at {}/{} ({position}): {diagnostic}",
562 migration_id.app_label().as_str(),
563 migration_id.name().as_str(),
564 position = render_position(position),
565 ),
566 Outcome::RequiresExplicitRecovery {
567 migration_id,
568 position,
569 diagnostic,
570 } => format!(
571 "{context}: explicit recovery required at {}/{} ({position}): {diagnostic}",
572 migration_id.app_label().as_str(),
573 migration_id.name().as_str(),
574 position = render_position(position),
575 ),
576 }
577}
578
579fn sanitize_migration_rollback_outcome(
580 outcome: type_bridge_schema_migration::MigrationRollbackOutcome,
581) -> String {
582 use type_bridge_schema_migration::MigrationRollbackOutcome as Outcome;
583 match outcome {
584 Outcome::RolledBack { .. } => "rollback completed".to_owned(),
585 Outcome::RetrySafe {
586 migration_id,
587 step_ordinal,
588 diagnostic,
589 } => format!(
590 "rollback may be retried at {}/{} step {} [{}]",
591 migration_id.app_label().as_str(),
592 migration_id.name().as_str(),
593 step_ordinal,
594 diagnostic.code().as_str(),
595 ),
596 Outcome::RequiresExplicitRecovery {
597 migration_id,
598 step_ordinal,
599 diagnostic,
600 } => format!(
601 "rollback requires explicit recovery at {}/{} step {} [{}]",
602 migration_id.app_label().as_str(),
603 migration_id.name().as_str(),
604 step_ordinal,
605 diagnostic.code().as_str(),
606 ),
607 }
608}
609
610fn run_schema_generate(workspace: &TypeBridgeWorkspace) -> Result<(), String> {
620 run_schema_generate_with(workspace, |target, resolved, authority| {
621 generate_binding_package(
622 target,
623 resolved,
624 authority,
625 workspace.config().app_label(),
626 workspace.config().type_name_overrides(target),
627 )
628 })
629}
630
631fn generate_binding_package(
632 target: type_bridge_contract::projection::BindingTarget,
633 resolved: &type_bridge_schema::ResolvedSchema,
634 authority: &type_bridge_schema::VerifiedSchemaAuthority,
635 app_label: &type_bridge_contract::migration::MigrationAppLabel,
636 type_names: &[type_bridge_contract::projection::TypeNameOverride],
637) -> Result<type_bridge_schema_codegen::GeneratedPackage, String> {
638 use type_bridge_contract::projection::{BindingTarget, ProjectionConfig};
639 use type_bridge_schema::project;
640 use type_bridge_schema_codegen::{CEmitter, PythonEmitter, RustEmitter, TypeScriptEmitter};
641
642 let configure = |mut config: ProjectionConfig| -> Result<ProjectionConfig, String> {
643 for item in type_names {
644 config = config
645 .with_type_name_override(item.type_id().clone(), item.name().as_str())
646 .map_err(display)?;
647 }
648 Ok(config)
649 };
650
651 match target {
652 BindingTarget::Python => {
653 let emitter = PythonEmitter::new();
654 let handlers = emitter.generator_handlers_for(resolved);
655 let resources = emitter.code_resources_for(resolved).map_err(display)?;
656 let projection = project(
657 resolved,
658 BindingTarget::Python,
659 &configure(ProjectionConfig::python())?,
660 &handlers,
661 &resources,
662 )
663 .map_err(display)?;
664 emitter.emit(&projection, authority)
665 }
666 BindingTarget::TypeScript => {
667 let emitter = TypeScriptEmitter::new();
668 let handlers = emitter.generator_handlers_for(resolved);
669 let resources = emitter.code_resources_for(resolved).map_err(display)?;
670 let projection = project(
671 resolved,
672 BindingTarget::TypeScript,
673 &configure(ProjectionConfig::typescript())?,
674 &handlers,
675 &resources,
676 )
677 .map_err(display)?;
678 emitter.emit(&projection, authority)
679 }
680 BindingTarget::Rust => {
681 let emitter = RustEmitter::new();
682 let handlers = emitter.generator_handlers_for(resolved);
683 let resources = emitter.code_resources_for(resolved).map_err(display)?;
684 let projection = project(
685 resolved,
686 BindingTarget::Rust,
687 &configure(ProjectionConfig::rust())?,
688 &handlers,
689 &resources,
690 )
691 .map_err(display)?;
692 emitter.emit(&projection, authority)
693 }
694 BindingTarget::C => {
695 let emitter = CEmitter::new();
696 let handlers = emitter.generator_handlers_for(resolved);
697 let resources = emitter.code_resources_for(resolved).map_err(display)?;
698 let config = configure(ProjectionConfig::c(c_symbol_prefix_for_app_label(
699 app_label,
700 )))?;
701 let projection = project(resolved, BindingTarget::C, &config, &handlers, &resources)
702 .map_err(display)?;
703 emitter.emit(&projection, authority)
704 }
705 _ => {
706 return Err(format!(
707 "schema generation does not support binding target {}",
708 target.as_str()
709 ));
710 }
711 }
712 .map_err(display)
713}
714
715fn run_schema_generate_with(
716 workspace: &TypeBridgeWorkspace,
717 mut generate: impl FnMut(
718 type_bridge_contract::projection::BindingTarget,
719 &type_bridge_schema::ResolvedSchema,
720 &type_bridge_schema::VerifiedSchemaAuthority,
721 ) -> Result<type_bridge_schema_codegen::GeneratedPackage, String>,
722) -> Result<(), String> {
723 use type_bridge_schema::{build_schema_authority, encode_schema_authority};
724
725 let outputs = workspace.config().outputs();
726 let authority_output = workspace.config().schema_authority_output();
727 if outputs.is_empty() && authority_output.is_none() {
728 return Err(
729 "no generated outputs configured; add bindings.<target>.output or \
730 artifacts.schema-authority.output to the manifest"
731 .into(),
732 );
733 }
734
735 let resolved = workspace.resolved_schema();
736 let migration_directory = workspace.ensure_migration_directory().map_err(display)?;
737 let migration_history = workspace
738 .migration_history_bundle_in(&migration_directory)
739 .map_err(display)?;
740 let authority = build_schema_authority(
741 workspace.declared_schema(),
742 workspace.required_capabilities(),
743 workspace.delta_context(),
744 )
745 .map_err(display)?;
746 let authority_bytes = encode_schema_authority(&authority);
747
748 let mut packages = Vec::with_capacity(outputs.len());
752 for (&target, directory) in outputs {
753 let package = generate(target, resolved, &authority)?
754 .with_migration_history_bundle(&migration_history)
755 .map_err(display)?;
756 packages.push((target, directory, package));
757 }
758
759 let workspace_root = workspace.output_root()?;
764 let mut generated_files = Vec::new();
765 let mut generated_packages = Vec::with_capacity(packages.len());
766 for (target, directory, package) in &packages {
767 let display_root = workspace_root.display_path().join(directory.as_path());
768 let file_count = package.files().len();
769 for (path, bytes) in package.files() {
770 let relative = std::path::Path::new(path);
771 validate_generated_relative_path(relative)?;
772 generated_files.push((directory.as_path().join(relative), bytes.as_slice()));
773 }
774 generated_packages.push((*target, display_root, file_count));
775 }
776 let prepared_authority = authority_output
777 .map(|output| {
778 let path = output.as_path();
779 let _file_name = path
780 .file_name()
781 .ok_or_else(|| "schema-authority output has no file name".to_owned())?;
782 Ok::<_, String>(path.to_path_buf())
783 })
784 .transpose()?;
785
786 if let Some(relative) = &prepared_authority {
787 generated_files.push((relative.clone(), authority_bytes.as_slice()));
788 }
789 workspace_root.write_atomic_batch(
790 generated_files
791 .iter()
792 .map(|(path, bytes)| (path.as_path(), *bytes)),
793 )?;
794
795 for (target, display_root, file_count) in generated_packages {
796 println!(
797 "generated {} file(s) for {} into {}",
798 file_count,
799 target.as_str(),
800 display_root.display(),
801 );
802 }
803 if let Some(relative) = prepared_authority {
804 println!(
805 "generated schema authority at {}\n authority identity: {}",
806 workspace_root.display_path().join(relative).display(),
807 authority.authority_fingerprint().digest().to_hex(),
808 );
809 }
810 Ok(())
811}
812
813#[cfg(test)]
814mod schema_generation_atomicity_tests {
815 use std::collections::BTreeMap;
816 use std::env;
817 use std::fs::OpenOptions;
818 use std::io::Write as _;
819
820 use super::*;
821 use serde_json::json;
822 use sha2::{Digest as _, Sha256};
823 use type_bridge_contract::codec::to_canonical_json;
824 use type_bridge_contract::projection::BindingTarget;
825
826 const ARTIFACT_OUTPUT_ENV: &str = "TYPE_BRIDGE_SDK_V3_ATOMIC_GENERATION_OUTPUT";
827 const ARTIFACT_SOURCE_PATH: &str = "type-bridge-core/crates/cli/src/lib.rs";
828 const ARTIFACT_FORMAT: &str = "typebridge.sdk-v3-artifact-observation/v1";
829 const MAX_ARTIFACT_BYTES: usize = 64 * 1024;
830
831 fn publish_atomic_generation_observation(observation: serde_json::Value) {
832 let Some(output) = env::var_os(ARTIFACT_OUTPUT_ENV) else {
833 return;
834 };
835 let output = PathBuf::from(output);
836 assert!(
837 output.is_absolute(),
838 "{ARTIFACT_OUTPUT_ENV} must be absolute"
839 );
840 let parent = output
841 .parent()
842 .expect("atomic-generation artifact path has a parent");
843 let parent_metadata =
844 fs::symlink_metadata(parent).expect("atomic-generation artifact parent is inspectable");
845 assert!(
846 parent_metadata.is_dir() && !parent_metadata.file_type().is_symlink(),
847 "atomic-generation artifact parent must be a real directory"
848 );
849 let source = include_bytes!("lib.rs");
850 let artifact = json!({
851 "format": ARTIFACT_FORMAT,
852 "semantic_profile": "typedb-3.12.1/v1",
853 "producer": {
854 "id": "type-bridge-cli.atomic-multibinding-v3-artifact",
855 "source": {
856 "path": ARTIFACT_SOURCE_PATH,
857 "sha256": format!("{:x}", Sha256::digest(source)),
858 },
859 "test_id": "schema_generation_atomicity_tests::injected_c_emitter_failure_preserves_all_four_ordered_packages",
860 },
861 "result": {
862 "observation_ref": "atomic_multibinding_generation",
863 "outcome": "passed",
864 "proof_kind": "artifact",
865 "observation": observation,
866 },
867 });
868 let mut bytes =
869 to_canonical_json(&artifact).expect("atomic-generation artifact encodes canonically");
870 bytes.push(b'\n');
871 assert!(
872 bytes.len() <= MAX_ARTIFACT_BYTES,
873 "atomic-generation artifact exceeds {MAX_ARTIFACT_BYTES} bytes"
874 );
875 let mut destination = OpenOptions::new()
876 .write(true)
877 .create_new(true)
878 .open(&output)
879 .expect("atomic-generation artifact destination must be new");
880 if let Err(error) = destination
881 .write_all(&bytes)
882 .and_then(|()| destination.sync_all())
883 {
884 drop(destination);
885 let _ = fs::remove_file(&output);
886 panic!("atomic-generation artifact publication failed: {error}");
887 }
888 }
889
890 fn snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
891 let mut files = BTreeMap::new();
892 let mut directories = vec![root.to_path_buf()];
893 while let Some(directory) = directories.pop() {
894 for entry in fs::read_dir(&directory).expect("generated directory reads") {
895 let path = entry.expect("generated entry reads").path();
896 if path.is_dir() {
897 directories.push(path);
898 } else {
899 files.insert(
900 path.strip_prefix(root)
901 .expect("generated path is beneath its root")
902 .to_path_buf(),
903 fs::read(path).expect("generated file reads"),
904 );
905 }
906 }
907 }
908 files
909 }
910
911 fn write_workspace(root: &Path, source: &str) -> PathBuf {
912 fs::create_dir_all(root.join("schema/fragments")).expect("schema directory creates");
913 fs::create_dir_all(root.join("migrations/v2")).expect("migration directory creates");
914 fs::write(
915 root.join("typebridge.yaml"),
916 "format: typebridge.workspace/v1\n\
917 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: ordered-atomic\n\
918 compatibility:\n semantic-profile: typedb-3.12.1/v1\n\
919 migrations:\n directory: migrations/v2\n app-label: ordered_atomic\n\
920 bindings:\n python:\n output: generated/python\n typescript:\n output: generated/typescript\n rust:\n output: generated/rust\n c:\n output: generated/c\n\
921 artifacts:\n schema-authority:\n output: generated/schema-authority.json\n",
922 )
923 .expect("manifest writes");
924 fs::write(
925 root.join("schema/schema.yaml"),
926 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
927 )
928 .expect("schema set writes");
929 fs::write(root.join("schema/fragments/model.yaml"), source).expect("schema writes");
930 root.join("typebridge.yaml")
931 }
932
933 #[test]
934 fn workspace_type_names_resolve_collisions_in_generated_packages() {
935 let directory = tempfile::tempdir().unwrap();
936 let root = directory.path();
937 let manifest = write_workspace(
938 root,
939 "format: typebridge.schema/v2\nattributes:\n powertrain_ref: { value: string }\nentities:\n Powertrain:\n owns:\n powertrain_ref: { card: 1 }\n",
940 );
941 let workspace = load_workspace(&manifest).unwrap();
942 assert!(run_schema_generate(&workspace).is_err());
943 let original = fs::read_to_string(&manifest).unwrap();
944 let mut configured = original;
945 for target in ["python", "typescript", "rust", "c"] {
946 let output = format!("output: generated/{target}");
947 configured = configured.replace(&output, &format!("{output}\n type-names:\n attribute:\n powertrain_ref: PowertrainReferenceValue"));
948 }
949 fs::write(&manifest, configured).unwrap();
950 let workspace = load_workspace(&manifest).unwrap();
951 run_schema_generate(&workspace).unwrap();
952 let models = fs::read_to_string(root.join("generated/typescript/src/models.ts")).unwrap();
953 assert!(models.contains("PowertrainReferenceValue"));
954 assert!(models.contains("powertrain_ref"));
955 let first = snapshot(&root.join("generated"));
956 run_schema_generate(&workspace).unwrap();
957 assert_eq!(first, snapshot(&root.join("generated")));
958 }
959
960 #[test]
961 fn injected_c_emitter_failure_preserves_all_four_ordered_packages() {
962 let directory = tempfile::tempdir().expect("workspace directory");
963 let root = directory.path();
964 let manifest = write_workspace(
965 root,
966 "format: typebridge.schema/v2\n\
967 attributes:\n identifier: { value: string }\n tag: { value: string }\n\
968 entities:\n person:\n owns:\n identifier: { key: true }\n tag: { card: { min: 0, max: 3 }, ordered: true, distinct: true }\n\
969 relations:\n group:\n relates:\n member: { card: { min: 0, max: 3 }, ordered: true, distinct: true }\n\
970 plays:\n person:\n group:\n member: { card: { min: 0, max: 1 } }\n",
971 );
972 let accepted = load_workspace(&manifest).expect("ordered workspace loads");
973 run_schema_generate(&accepted).expect("ordered packages generate");
974
975 let accepted_trees = ["python", "typescript", "rust", "c"]
976 .map(|target| (target, snapshot(&root.join("generated").join(target))));
977 let expected_history = accepted
978 .migration_history_bundle_bytes()
979 .expect("canonical migration history bundle");
980 for (target, tree) in &accepted_trees {
981 assert_eq!(
982 tree.get(std::path::Path::new(
983 type_bridge_schema_codegen::MIGRATION_HISTORY_BUNDLE_RESOURCE,
984 )),
985 Some(&expected_history),
986 "{target} package must embed the byte-identical canonical history bundle",
987 );
988 }
989 let accepted_authority =
990 fs::read(root.join("generated/schema-authority.json")).expect("authority reads");
991
992 run_schema_generate(&accepted).expect("identical ordered packages regenerate");
993 for (target, accepted_tree) in &accepted_trees {
994 assert_eq!(
995 &snapshot(&root.join("generated").join(target)),
996 accepted_tree,
997 "{target} destination changed after deterministic regeneration",
998 );
999 }
1000 assert_eq!(
1001 fs::read(root.join("generated/schema-authority.json")).expect("authority rereads"),
1002 accepted_authority,
1003 "schema authority changed after deterministic regeneration",
1004 );
1005
1006 fs::write(
1007 root.join("schema/fragments/model.yaml"),
1008 "format: typebridge.schema/v2\n\
1009 attributes:\n identifier: { value: string }\n tag: { value: string }\n title: { value: string }\n\
1010 entities:\n person:\n owns:\n identifier: { key: true }\n tag: { card: { min: 0, max: 4 }, ordered: true, distinct: true }\n title: { card: 1 }\n\
1011 relations:\n group:\n relates:\n member: { card: { min: 0, max: 4 }, ordered: true, distinct: true }\n\
1012 plays:\n person:\n group:\n member: { card: { min: 0, max: 1 } }\n",
1013 )
1014 .expect("changed schema writes");
1015 let changed = load_workspace(&manifest).expect("changed ordered workspace loads");
1016 let mut attempted = Vec::new();
1017 let error = run_schema_generate_with(&changed, |target, resolved, authority| {
1018 attempted.push(target);
1019 if target == BindingTarget::C {
1020 return Err("injected C emitter failure".to_owned());
1021 }
1022 generate_binding_package(
1023 target,
1024 resolved,
1025 authority,
1026 changed.config().app_label(),
1027 changed.config().type_name_overrides(target),
1028 )
1029 })
1030 .expect_err("injected C emitter failure rejects the transaction");
1031 assert_eq!(error, "injected C emitter failure");
1032 assert_eq!(
1033 attempted,
1034 vec![
1035 BindingTarget::Python,
1036 BindingTarget::TypeScript,
1037 BindingTarget::Rust,
1038 BindingTarget::C,
1039 ],
1040 "the injected failure did not occur after the three earlier packages prepared",
1041 );
1042
1043 for (target, accepted_tree) in &accepted_trees {
1044 assert_eq!(
1045 snapshot(&root.join("generated").join(target)),
1046 *accepted_tree,
1047 "{target} destination changed after the injected C emitter failure",
1048 );
1049 }
1050 assert_eq!(
1051 fs::read(root.join("generated/schema-authority.json")).expect("authority rereads"),
1052 accepted_authority,
1053 "schema authority changed after the injected C emitter failure",
1054 );
1055
1056 publish_atomic_generation_observation(json!({
1057 "targets": ["python", "typescript", "rust", "c"],
1058 "common_authority_identity": {
1059 "schema_source_equal": true,
1060 "semantic_profile": "typedb-3.12.1/v1",
1061 "semantic_fingerprint_equal": true,
1062 "resource_ledger_equal": true,
1063 },
1064 "package_identities_distinct": true,
1065 "generated_sidecars": [],
1066 "no_sidecar_runtime_dependency": true,
1067 "deterministic_rerun": {
1068 "byte_identical": true,
1069 "published_targets": accepted_trees.len(),
1070 },
1071 "injected_failure": {
1072 "failed_target": "c",
1073 "published_targets": 0,
1074 "previous_outputs_unchanged": true,
1075 "staging_artifacts_remaining": 0,
1076 },
1077 }));
1078 }
1079}
1080
1081fn run_schema_export_declared(
1083 workspace: &TypeBridgeWorkspace,
1084 output: &Path,
1085) -> Result<(), String> {
1086 use type_bridge_contract::schema::encode_declared_schema;
1087
1088 validate_declared_output_path(output)?;
1089 let root = workspace.output_root()?;
1090 let parent = root.open_beneath(output.parent().unwrap_or_else(|| std::path::Path::new("")))?;
1091 let file_name = output
1092 .file_name()
1093 .ok_or_else(|| "declared-schema output has no file name".to_owned())?;
1094 let destination = parent.display_path().join(file_name);
1095 let bytes = encode_declared_schema(workspace.declared_schema()).map_err(display)?;
1096 parent.write_atomic(file_name, &bytes)?;
1097 println!(
1098 "wrote canonical declared schema to {}\n declared identity: {}",
1099 destination.display(),
1100 workspace
1101 .declared_schema()
1102 .declared_identity_fingerprint()
1103 .as_fingerprint()
1104 .digest()
1105 .to_hex(),
1106 );
1107 Ok(())
1108}
1109
1110fn validate_declared_output_path(output: &Path) -> Result<(), String> {
1111 let Some(portable) = output.to_str() else {
1112 return Err("declared-schema output must be valid UTF-8".into());
1113 };
1114 let invalid_spelling = portable.is_empty()
1115 || portable.contains(['\\', ':', '\0'])
1116 || portable.bytes().any(|byte| byte.is_ascii_control())
1117 || portable
1118 .split('/')
1119 .any(|segment| segment.is_empty() || matches!(segment, "." | ".."));
1120 let invalid_components = output.is_absolute()
1121 || output
1122 .components()
1123 .any(|component| !matches!(component, Component::Normal(_)));
1124 if invalid_spelling || invalid_components {
1125 return Err("declared-schema output must be a confined portable workspace path".into());
1126 }
1127 if output.extension().and_then(|extension| extension.to_str()) != Some("json") {
1128 return Err("declared-schema output must end in lowercase .json".into());
1129 }
1130 Ok(())
1131}
1132
1133fn validate_generated_relative_path(path: &std::path::Path) -> Result<(), String> {
1134 if path.as_os_str().is_empty()
1135 || path
1136 .components()
1137 .any(|component| !matches!(component, std::path::Component::Normal(_)))
1138 {
1139 return Err(format!(
1140 "generated output path {:?} is not a confined relative file",
1141 path
1142 ));
1143 }
1144 Ok(())
1145}
1146
1147enum ConnectedAction {
1148 Apply {
1149 approvals: Vec<String>,
1150 },
1151 Verify,
1152 Rollback {
1153 removals: BTreeSet<type_bridge_contract::migration::MigrationId>,
1154 approvals: Vec<String>,
1155 },
1156 Adopt {
1157 archive_directory: PathBuf,
1158 name: String,
1159 },
1160}
1161
1162fn secure_connect_options(
1163 environment: &WorkspaceEnvironment,
1164) -> type_bridge_orm::SecureConnectOptions {
1165 let tls_mode = match environment.transport_policy() {
1166 WorkspaceTransportPolicy::Disabled => type_bridge_orm::TlsMode::Disabled,
1167 WorkspaceTransportPolicy::NativeRoots => type_bridge_orm::TlsMode::NativeRoots,
1168 WorkspaceTransportPolicy::CustomRootCa(root_ca) => {
1169 type_bridge_orm::TlsMode::CustomRootCa(root_ca.as_path().to_path_buf())
1170 }
1171 };
1172 let mut options = type_bridge_orm::SecureConnectOptions {
1173 tls_mode,
1174 ..type_bridge_orm::SecureConnectOptions::default()
1175 };
1176 if let Some(port) = environment.http_port() {
1177 options.http_port = port;
1178 }
1179 options
1180}
1181
1182fn preflight_secure_connect_options(
1183 workspace: &TypeBridgeWorkspace,
1184 environment_name: &str,
1185) -> Result<type_bridge_orm::PreparedSecureConnectOptions, String> {
1186 let environment = workspace
1187 .config()
1188 .environment(environment_name)
1189 .ok_or_else(|| {
1190 format!("environment {environment_name:?} is not owned by this workspace")
1191 })?;
1192 let options = secure_connect_options(environment);
1193 match workspace
1194 .capture_environment_custom_root_ca(environment_name)
1195 .map_err(display)?
1196 {
1197 Some(bytes) => options
1198 .prepare_transport_from_captured_custom_root(bytes)
1199 .map_err(display),
1200 None => options.prepare_transport().map_err(display),
1201 }
1202}
1203
1204fn run_connected(
1205 workspace: &TypeBridgeWorkspace,
1206 environment: &str,
1207 action: ConnectedAction,
1208) -> Result<(), String> {
1209 let runtime = tokio::runtime::Runtime::new()
1210 .map_err(|error| format!("cannot start the async runtime: {error}"))?;
1211 runtime.block_on(run_connected_async(workspace, environment, action))
1212}
1213
1214async fn run_connected_async(
1215 workspace: &TypeBridgeWorkspace,
1216 environment_name: &str,
1217 action: ConnectedAction,
1218) -> Result<(), String> {
1219 let config = workspace.config();
1220 let Some(environment) = config.environment(environment_name) else {
1221 let known = config
1222 .environments()
1223 .keys()
1224 .cloned()
1225 .collect::<Vec<_>>()
1226 .join(", ");
1227 return Err(format!(
1228 "unknown environment {environment_name:?}; the manifest declares: [{known}]"
1229 ));
1230 };
1231 if matches!(
1232 &action,
1233 ConnectedAction::Apply { .. }
1234 | ConnectedAction::Rollback { .. }
1235 | ConnectedAction::Adopt { .. }
1236 ) && !environment.migrate()
1237 {
1238 return Err(format!(
1239 "environment {environment_name:?} is not opted into migration \
1240 application; set `migrate: true` in the manifest to allow it"
1241 ));
1242 }
1243 let supported = &type_bridge_schema_migration::typedb_3_12_1_profile().semantic_profile;
1244 if config.semantic_profile() != supported {
1245 return Err(format!(
1246 "workspace semantic profile {:?} cannot run connected TypeDB migration operations \
1247 [migration_typedb_semantic_profile_unsupported]; expected {:?}",
1248 config.semantic_profile().as_str(),
1249 supported.as_str(),
1250 ));
1251 }
1252 environment
1253 .requirements()
1254 .ensure_supported_by(&execution_capability_vocabulary().map_err(display)?)
1255 .map_err(display)?;
1256
1257 let prepared_adoption = match &action {
1260 ConnectedAction::Adopt {
1261 archive_directory,
1262 name,
1263 } => Some(prepare_archive_adoption(
1264 workspace,
1265 archive_directory,
1266 name,
1267 )?),
1268 ConnectedAction::Apply { .. }
1269 | ConnectedAction::Rollback { .. }
1270 | ConnectedAction::Verify => None,
1271 };
1272
1273 let migration_directory = if matches!(&action, ConnectedAction::Adopt { .. }) {
1277 workspace.ensure_migration_directory().map_err(display)?
1278 } else {
1279 workspace.open_migration_directory().map_err(display)?
1280 };
1281 let ordinary_graph = if prepared_adoption.is_none() {
1285 Some(
1286 workspace
1287 .discover_migrations_in(&migration_directory)
1288 .map_err(display)?,
1289 )
1290 } else {
1291 None
1292 };
1293 let prepared_approvals = match &action {
1298 ConnectedAction::Apply { approvals } => Some(bind_approvals(
1299 ordinary_graph
1300 .as_ref()
1301 .ok_or_else(|| "internal apply history was not retained".to_owned())?,
1302 approvals,
1303 )?),
1304 ConnectedAction::Rollback { approvals, .. } => Some(bind_rollback_approvals(
1305 ordinary_graph
1306 .as_ref()
1307 .ok_or_else(|| "internal rollback history was not retained".to_owned())?,
1308 approvals,
1309 )?),
1310 ConnectedAction::Verify | ConnectedAction::Adopt { .. } => None,
1311 };
1312
1313 let options = preflight_secure_connect_options(workspace, environment_name)?;
1317 let username = resolve_credential(environment.username())?;
1318 let password = resolve_credential(environment.password())?;
1319 let journal_name =
1320 type_bridge_schema_migration_typedb::derived_journal_database_name(environment.database());
1321 let managed_requires_existing = match &action {
1328 ConnectedAction::Verify => Some(
1329 "`migration verify` is read-only and never creates databases \
1330 — apply migrations to this environment first",
1331 ),
1332 ConnectedAction::Adopt { .. } => {
1333 Some("`migration adopt` cutover requires the migrated v1 database to already exist")
1334 }
1335 ConnectedAction::Apply { .. } | ConnectedAction::Rollback { .. } => None,
1336 };
1337 let managed = std::sync::Arc::new(
1342 type_bridge_orm::Database::connect_prepared_secure_with_options(
1343 environment.uri(),
1344 environment.database(),
1345 &username,
1346 &password,
1347 options.clone(),
1348 )
1349 .await
1350 .map_err(|error| {
1351 sanitize_connected_error(
1352 "cannot connect the managed database".to_owned(),
1353 "typedb_database_connect_failed",
1354 error,
1355 )
1356 })?,
1357 );
1358 let journal = std::sync::Arc::new(
1359 type_bridge_orm::Database::connect_prepared_secure_with_options(
1360 environment.uri(),
1361 &journal_name,
1362 &username,
1363 &password,
1364 options,
1365 )
1366 .await
1367 .map_err(|error| {
1368 sanitize_connected_error(
1369 "cannot connect the journal database".to_owned(),
1370 "typedb_database_connect_failed",
1371 error,
1372 )
1373 })?,
1374 );
1375 type_bridge_schema_migration_typedb::require_supported_migration_execution_binding(
1376 &managed,
1377 &journal,
1378 workspace.delta_context(),
1379 )
1380 .map_err(display)?;
1381
1382 if let Some(reason) = managed_requires_existing {
1383 let exists = managed.database_exists().await.map_err(|error| {
1384 sanitize_connected_orm_error(
1385 format!("cannot check database {:?}", environment.database()),
1386 "typedb_database_exists_failed",
1387 error,
1388 )
1389 })?;
1390 if !exists {
1391 return Err(format!(
1392 "database {:?} does not exist; {reason}",
1393 environment.database()
1394 ));
1395 }
1396 } else {
1397 managed.create_database().await.map_err(|error| {
1398 sanitize_connected_orm_error(
1399 format!("cannot ensure database {:?}", environment.database()),
1400 "typedb_database_ensure_failed",
1401 error,
1402 )
1403 })?;
1404 }
1405
1406 let adoption_files = if let Some(prepared) = prepared_adoption.as_ref() {
1411 verify_prepared_adoption_live(&managed, prepared).await?;
1412 Some(publish_prepared_adoption(
1413 workspace,
1414 &migration_directory,
1415 prepared,
1416 )?)
1417 } else {
1418 None
1419 };
1420
1421 if matches!(&action, ConnectedAction::Verify) {
1422 let exists = journal.database_exists().await.map_err(|error| {
1423 sanitize_connected_orm_error(
1424 format!("cannot check database {journal_name:?}"),
1425 "typedb_database_exists_failed",
1426 error,
1427 )
1428 })?;
1429 if !exists {
1430 return Err(format!(
1431 "database {journal_name:?} does not exist; `migration verify` is read-only and never creates databases"
1432 ));
1433 }
1434 } else {
1435 journal.create_database().await.map_err(|error| {
1436 sanitize_connected_orm_error(
1437 format!("cannot ensure database {journal_name:?}"),
1438 "typedb_database_ensure_failed",
1439 error,
1440 )
1441 })?;
1442 }
1443
1444 let genesis = workspace
1445 .migration_genesis_in(&migration_directory)
1446 .map_err(display)?;
1447 let lowering = type_bridge_schema_migration::SchemaLoweringBinding::current(
1448 workspace.delta_context().available_capabilities().clone(),
1449 )
1450 .map_err(display)?;
1451 let runner = type_bridge_schema_migration_typedb::TypeDbMigrationRunner::new(
1452 managed,
1453 journal,
1454 genesis.clone(),
1455 workspace.delta_context().clone(),
1456 lowering,
1457 config.migration_policy().clone(),
1458 );
1459 let holder =
1460 type_bridge_schema_migration::LeaseHolderId::new("type-bridge-cli").map_err(display)?;
1461 let directory = migration_directory.directory();
1462
1463 match action {
1464 ConnectedAction::Apply { .. } => {
1465 let approvals = prepared_approvals
1466 .as_deref()
1467 .ok_or_else(|| "internal apply approvals were not retained".to_owned())?;
1468 let outcome = runner
1469 .apply_in(
1470 directory,
1471 &type_bridge_schema_migration::MigrationApplyTarget::DefaultHead,
1472 &holder,
1473 approvals,
1474 )
1475 .await
1476 .map_err(display)?;
1477 match outcome {
1478 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::UpToDate => {
1479 println!("applied ledger already reaches the committed head");
1480 Ok(())
1481 }
1482 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
1483 type_bridge_schema_migration::MigrationExecutionOutcome::Applied { .. },
1484 ) => {
1485 println!("applied the committed chain");
1486 Ok(())
1487 }
1488 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
1489 outcome,
1490 ) => Err(sanitize_migration_execution_outcome(
1491 "apply did not complete",
1492 outcome,
1493 )),
1494 }
1495 }
1496 ConnectedAction::Rollback { removals, .. } => {
1497 let approvals = prepared_approvals
1498 .as_deref()
1499 .ok_or_else(|| "internal rollback approvals were not retained".to_owned())?;
1500 match runner
1501 .rollback_in(directory, &removals, &holder, approvals)
1502 .await
1503 .map_err(display)?
1504 {
1505 type_bridge_schema_migration_typedb::MigrationDirectoryRollbackOutcome::UpToDate => {
1506 println!("requested migrations are already absent from the applied ledger");
1507 Ok(())
1508 }
1509 type_bridge_schema_migration_typedb::MigrationDirectoryRollbackOutcome::Executed(
1510 type_bridge_schema_migration::MigrationRollbackOutcome::RolledBack { .. },
1511 ) => {
1512 println!("rolled back the requested migrations");
1513 Ok(())
1514 }
1515 type_bridge_schema_migration_typedb::MigrationDirectoryRollbackOutcome::Executed(
1516 outcome,
1517 ) => Err(sanitize_migration_rollback_outcome(outcome)),
1518 }
1519 }
1520 ConnectedAction::Verify => {
1521 let report = runner
1522 .verify_in(directory, Some(workspace.declared_schema()))
1523 .await
1524 .map_err(display)?;
1525 if report.is_clean() {
1526 println!(
1527 "migration state is coherent\n applied frontier: {}",
1528 report
1529 .applied_frontier()
1530 .iter()
1531 .map(|id| format!("{}/{}", id.app_label().as_str(), id.name().as_str()))
1532 .collect::<Vec<_>>()
1533 .join(", "),
1534 );
1535 Ok(())
1536 } else {
1537 for finding in report.findings() {
1538 eprintln!("drift: {finding:?}");
1539 }
1540 Err(format!("{} drift finding(s)", report.findings().len()))
1541 }
1542 }
1543 ConnectedAction::Adopt { .. } => {
1544 let bridge_display_path = adoption_files
1545 .ok_or_else(|| "internal adoption preflight state was not retained".to_owned())?;
1546 let prepared = prepared_adoption
1547 .as_ref()
1548 .ok_or_else(|| "internal adoption authority was not retained".to_owned())?;
1549 let outcome = runner
1550 .import_verified_legacy_frontier_in(
1551 &prepared.history,
1552 &prepared.reconstructed,
1553 directory,
1554 &holder,
1555 )
1556 .await;
1557 match outcome {
1558 Ok(
1559 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::UpToDate,
1560 ) => {
1561 println!("archive history is already adopted; the bridged ledger is current");
1562 Ok(())
1563 }
1564 Ok(
1565 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
1566 type_bridge_schema_migration::MigrationExecutionOutcome::Applied { .. },
1567 ),
1568 ) => {
1569 println!(
1570 "adopted the archive history\n genesis: {}\n bridge: {}",
1571 migration_directory
1572 .display_path()
1573 .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
1574 .display(),
1575 bridge_display_path.display(),
1576 );
1577 Ok(())
1578 }
1579 Ok(
1580 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
1581 outcome,
1582 ),
1583 ) => Err(sanitize_migration_execution_outcome(
1584 "adoption checkpoint did not complete",
1585 outcome,
1586 )),
1587 Err(error) => Err(display(error)),
1588 }
1589 }
1590 }
1591}
1592
1593struct PreparedArchiveAdoption {
1594 history: type_bridge_migration::LegacyAdoptionHistory,
1595 reconstructed: type_bridge_migration::VerifiedLegacyHead,
1596 authority: type_bridge_schema_compat::AdoptedGenesisAuthority,
1597 bridge: type_bridge_schema_migration::VerifiedSchemaMigrationManifest,
1598 bridge_name: String,
1599 bridge_bytes: Vec<u8>,
1600}
1601
1602fn prepare_archive_adoption(
1605 workspace: &TypeBridgeWorkspace,
1606 archive_directory: &std::path::Path,
1607 name: &str,
1608) -> Result<PreparedArchiveAdoption, String> {
1609 let migration_name =
1612 type_bridge_contract::migration::MigrationName::new(name.to_owned()).map_err(display)?;
1613 let bridge_name = format!("{}.tbmigration.json", migration_name.as_str());
1614 let history =
1615 type_bridge_migration::load_adoption_history(archive_directory).map_err(|error| {
1616 format!("archive migration directory failed the checked adoption loader: {error}")
1617 })?;
1618 let reconstructed = type_bridge_migration::reconstruct_legacy_head(&history)
1619 .map_err(|error| format!("archive head reconstruction failed: {error}"))?;
1620 let authority = type_bridge_schema_compat::parse_adopted_genesis_authority(
1621 type_bridge_contract::schema::DocumentId::new("legacy-head-snapshot.typeql")
1622 .map_err(display)?,
1623 reconstructed.schema_typeql(),
1624 )
1625 .map_err(display)?;
1626 let frontier = type_bridge_schema_migration_typedb::extract_legacy_frontier(history.graph())
1627 .map_err(display)?;
1628 let applied_set =
1629 type_bridge_schema_migration_typedb::extract_legacy_applied_set_digest(history.graph())
1630 .map_err(display)?;
1631 let id = type_bridge_contract::migration::MigrationId::from_components(
1632 type_bridge_contract::migration::MigrationAppLabel::new(
1633 workspace.config().app_label().as_str().to_owned(),
1634 )
1635 .map_err(display)?,
1636 migration_name,
1637 );
1638 let bridge = type_bridge_schema_migration::build_legacy_frontier_bridge(
1639 id,
1640 frontier,
1641 applied_set,
1642 authority.declared(),
1643 workspace.delta_context(),
1644 )
1645 .map_err(display)?;
1646 let bridge_bytes =
1647 type_bridge_schema_migration::encode_verified_manifest(&bridge).map_err(display)?;
1648 history
1649 .require_unchanged_head(&reconstructed)
1650 .map_err(|error| {
1651 format!("archive migration directory changed during adoption preparation: {error}")
1652 })?;
1653 Ok(PreparedArchiveAdoption {
1654 history,
1655 reconstructed,
1656 authority,
1657 bridge,
1658 bridge_name,
1659 bridge_bytes,
1660 })
1661}
1662
1663async fn verify_prepared_adoption_live(
1666 managed: &type_bridge_orm::Database,
1667 prepared: &PreparedArchiveAdoption,
1668) -> Result<(), String> {
1669 let export = managed
1670 .schema_text()
1671 .await
1672 .map_err(sanitize_schema_export_error)?;
1673 prepared
1674 .history
1675 .require_unchanged_head(&prepared.reconstructed)
1676 .map_err(|error| format!("archive adoption history changed during live export: {error}"))?;
1677 let expected_internal = type_bridge_schema_compat::released_typeql_to_declared_projection(
1678 type_bridge_contract::schema::DocumentId::new("managed-fence-schema.typeql")
1679 .map_err(display)?,
1680 type_bridge_schema_migration_typedb::MANAGED_FENCE_SCHEMA_TYPEQL,
1681 )
1682 .map_err(display)?;
1683 let live = type_bridge_schema_compat::parse_adopted_genesis_authority_with_internal(
1684 type_bridge_contract::schema::DocumentId::new("legacy-live-head.typeql")
1685 .map_err(display)?,
1686 &export,
1687 Some(&expected_internal),
1688 )
1689 .map_err(display)?;
1690 if live.legacy_identity() != prepared.authority.legacy_identity()
1691 || live.declared().declared_identity_fingerprint()
1692 != prepared
1693 .authority
1694 .declared()
1695 .declared_identity_fingerprint()
1696 || live.released_extension_identity() != prepared.authority.released_extension_identity()
1697 {
1698 return Err(
1699 "live managed schema differs from the independently verified archive-head snapshot"
1700 .to_owned(),
1701 );
1702 }
1703 Ok(())
1704}
1705
1706fn publish_prepared_adoption(
1713 workspace: &TypeBridgeWorkspace,
1714 migration_directory: &type_bridge_workspace::MigrationDirectoryAuthority,
1715 prepared: &PreparedArchiveAdoption,
1716) -> Result<PathBuf, String> {
1717 publish_prepared_adoption_with_after_bridge(workspace, migration_directory, prepared, || {})
1718}
1719
1720fn publish_prepared_adoption_with_after_bridge<F>(
1721 workspace: &TypeBridgeWorkspace,
1722 migration_directory: &type_bridge_workspace::MigrationDirectoryAuthority,
1723 prepared: &PreparedArchiveAdoption,
1724 after_bridge: F,
1725) -> Result<PathBuf, String>
1726where
1727 F: FnOnce(),
1728{
1729 let directory = migration_directory.directory();
1730 let _lock = directory.try_acquire_authoring_lock().map_err(|error| {
1731 if error.kind() == std::io::ErrorKind::WouldBlock {
1732 "migration adoption conflicts with another canonical history publisher".to_owned()
1733 } else {
1734 format!("cannot lock canonical migration publication: {error}")
1735 }
1736 })?;
1737 let genesis_name = type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME;
1738 let genesis_bytes = prepared.reconstructed.schema_typeql().as_bytes();
1739 let mut bridge_created = false;
1740 let mut genesis_created = false;
1741 let mut after_bridge = Some(after_bridge);
1742
1743 let publication = (|| -> Result<(), String> {
1744 if let Some(existing) = read_existing_authority(directory, genesis_name)?
1745 && existing != genesis_bytes
1746 {
1747 return Err(format!(
1748 "{genesis_name} already exists but differs from the verified archive-head snapshot"
1749 ));
1750 }
1751 let bridge_already_published =
1752 if let Some(existing) = read_existing_authority(directory, &prepared.bridge_name)? {
1753 if existing != prepared.bridge_bytes {
1754 return Err(format!(
1755 "{} already exists with different authority bytes",
1756 prepared.bridge_name
1757 ));
1758 }
1759 true
1760 } else {
1761 false
1762 };
1763
1764 let (current, evidence) =
1765 type_bridge_schema_migration::discover_verified_migration_chain_with_evidence_in(
1766 directory,
1767 prepared.authority.declared(),
1768 workspace.delta_context(),
1769 )
1770 .map_err(display)?;
1771 let prospective = if current.manifest(prepared.bridge.id()).is_some() {
1772 current
1773 } else {
1774 let manifests = current
1775 .manifests()
1776 .map(|(_, manifest)| manifest.clone())
1777 .chain(std::iter::once(prepared.bridge.clone()))
1778 .collect::<Vec<_>>();
1779 type_bridge_schema_migration::MigrationHistoryGraph::from_verified(manifests)
1780 .map_err(display)?
1781 };
1782 type_bridge_schema_migration::require_adoption_authority_pair(&prospective, true)
1783 .map_err(display)?;
1784 evidence.require_unchanged(directory).map_err(display)?;
1785 prepared
1786 .history
1787 .require_unchanged_head(&prepared.reconstructed)
1788 .map_err(|error| {
1789 format!("archive adoption history changed before pair publication: {error}")
1790 })?;
1791
1792 if !bridge_already_published {
1793 bridge_created =
1794 publish_authority(directory, &prepared.bridge_name, &prepared.bridge_bytes)?;
1795 }
1796 if let Some(after_bridge) = after_bridge.take() {
1797 after_bridge();
1798 }
1799 prepared
1800 .history
1801 .require_unchanged_head(&prepared.reconstructed)
1802 .map_err(|error| {
1803 format!("archive adoption history changed before genesis publication: {error}")
1804 })?;
1805 genesis_created = publish_authority(directory, genesis_name, genesis_bytes)?;
1806 prepared
1807 .history
1808 .require_unchanged_head(&prepared.reconstructed)
1809 .map_err(|error| {
1810 format!("archive adoption history changed after pair publication: {error}")
1811 })?;
1812 workspace
1813 .discover_migrations_in(migration_directory)
1814 .map_err(display)?;
1815 Ok(())
1816 })();
1817
1818 if let Err(error) = publication {
1819 return Err(rollback_adoption_publication(
1820 directory,
1821 &prepared.bridge_name,
1822 bridge_created,
1823 genesis_created,
1824 error,
1825 ));
1826 }
1827 Ok(migration_directory
1828 .display_path()
1829 .join(&prepared.bridge_name))
1830}
1831
1832fn rollback_adoption_publication(
1833 directory: &type_bridge_schema_migration::MigrationDirectory,
1834 bridge_name: &str,
1835 bridge_created: bool,
1836 genesis_created: bool,
1837 primary: String,
1838) -> String {
1839 let mut cleanup_errors = Vec::new();
1840 if genesis_created
1841 && let Err(error) =
1842 directory.remove_file(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME.as_ref())
1843 {
1844 cleanup_errors.push(format!("cannot remove newly published genesis: {error}"));
1845 }
1846 if bridge_created && let Err(error) = directory.remove_file(bridge_name.as_ref()) {
1847 cleanup_errors.push(format!("cannot remove newly published bridge: {error}"));
1848 }
1849 if (bridge_created || genesis_created)
1850 && let Err(error) = directory.sync_all()
1851 {
1852 cleanup_errors.push(format!("cannot flush adoption rollback: {error}"));
1853 }
1854 if cleanup_errors.is_empty() {
1855 primary
1856 } else {
1857 format!(
1858 "{primary}; adoption publication rollback failed: {}",
1859 cleanup_errors.join("; ")
1860 )
1861 }
1862}
1863
1864fn publish_authority(
1873 directory: &type_bridge_schema_migration::MigrationDirectory,
1874 name: &str,
1875 bytes: &[u8],
1876) -> Result<bool, String> {
1877 use std::io::Write;
1878 if let Some(existing) = read_existing_authority(directory, name)? {
1879 if existing == bytes {
1880 return Ok(false);
1881 }
1882 return Err(format!(
1883 "{name} already exists with different authority bytes"
1884 ));
1885 }
1886 let mut temporary = None;
1887 for attempt in 0..128_u64 {
1888 let candidate = unique_authority_temporary_name(name, attempt);
1889 match directory.create_new(candidate.as_ref()) {
1890 Ok(mut file) => {
1891 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
1892 let _ = directory.remove_file(candidate.as_ref());
1893 return Err(format!("cannot write {candidate}: {error}"));
1894 }
1895 temporary = Some(candidate);
1896 break;
1897 }
1898 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1899 Err(error) => {
1900 return Err(format!("cannot create {candidate}: {error}"));
1901 }
1902 }
1903 }
1904 let temporary = temporary.ok_or_else(|| {
1905 format!("cannot allocate a unique temporary authority file beside {name}")
1906 })?;
1907 let publication = directory.hard_link(temporary.as_ref(), name.as_ref());
1908 match publication {
1909 Ok(()) => {
1910 if let Err(error) = directory.sync_all() {
1911 let _ = directory.remove_file(temporary.as_ref());
1912 return Err(format!("cannot flush migration directory: {error}"));
1913 }
1914 let _ = directory.remove_file(temporary.as_ref());
1915 directory
1916 .sync_all()
1917 .map_err(|error| format!("cannot flush migration directory: {error}"))?;
1918 Ok(true)
1919 }
1920 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1921 let _ = directory.remove_file(temporary.as_ref());
1922 let existing = read_existing_authority(directory, name)?
1923 .ok_or_else(|| format!("{name} disappeared during no-replace publication"))?;
1924 if existing == bytes {
1925 Ok(false)
1926 } else {
1927 Err(format!(
1928 "{name} was concurrently published with different authority bytes"
1929 ))
1930 }
1931 }
1932 Err(error) => {
1933 let _ = directory.remove_file(temporary.as_ref());
1934 Err(format!("cannot publish {name}: {error}"))
1935 }
1936 }
1937}
1938
1939fn read_existing_authority(
1940 directory: &type_bridge_schema_migration::MigrationDirectory,
1941 name: &str,
1942) -> Result<Option<Vec<u8>>, String> {
1943 use std::io::Read;
1944 let file = match directory.open_regular_readonly(name.as_ref()) {
1945 Ok(file) => file,
1946 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1947 Err(error) => return Err(format!("cannot read {name}: {error}")),
1948 };
1949 let limit = type_bridge_contract::limits::MAX_CANONICAL_BYTES;
1950 let mut bytes = Vec::new();
1951 file.take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1))
1952 .read_to_end(&mut bytes)
1953 .map_err(|error| format!("cannot read {name}: {error}"))?;
1954 if bytes.len() > limit {
1955 return Err(format!("{name} exceeds the 16 MiB authority ceiling"));
1956 }
1957 Ok(Some(bytes))
1958}
1959
1960fn unique_authority_temporary_name(name: &str, attempt: u64) -> String {
1961 use std::sync::atomic::{AtomicU64, Ordering};
1962 static NEXT_AUTHORITY_TEMPORARY: AtomicU64 = AtomicU64::new(1);
1963 let nonce = NEXT_AUTHORITY_TEMPORARY.fetch_add(1, Ordering::Relaxed);
1964 format!(".{name}.{}.{}.{}.tmp", std::process::id(), nonce, attempt)
1965}
1966
1967fn resolve_credential(
1968 reference: &type_bridge_workspace::SecretReference,
1969) -> Result<String, String> {
1970 std::env::var(reference.environment_variable()).map_err(|_| {
1971 format!(
1972 "credential environment variable {:?} is not set",
1973 reference.environment_variable()
1974 )
1975 })
1976}
1977
1978#[cfg(test)]
1979mod credential_error_redaction_tests {
1980 use super::*;
1981 use type_bridge_contract::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCode};
1982 use type_bridge_typedb_runtime::RuntimeError;
1983
1984 const PROVIDER_TEXT: &str =
1985 "TB_ADDRESS_SECRET TB_USERNAME_SECRET TB_PASSWORD_SECRET TB_PROVIDER_SECRET";
1986 const SECRETS: [&str; 4] = [
1987 "TB_ADDRESS_SECRET",
1988 "TB_USERNAME_SECRET",
1989 "TB_PASSWORD_SECRET",
1990 "TB_PROVIDER_SECRET",
1991 ];
1992
1993 fn hostile_secure_error() -> type_bridge_orm::SecureConnectError {
1994 type_bridge_orm::SecureConnectError::Runtime(RuntimeError::Connection(
1995 PROVIDER_TEXT.to_owned(),
1996 ))
1997 }
1998
1999 #[test]
2000 fn connected_lifecycle_contexts_drop_hostile_provider_text() {
2001 for (context, code) in [
2002 (
2003 "cannot check database \"managed\"",
2004 "typedb_database_exists_failed",
2005 ),
2006 (
2007 "cannot ensure database \"managed\"",
2008 "typedb_database_ensure_failed",
2009 ),
2010 (
2011 "cannot connect the managed database",
2012 "typedb_database_connect_failed",
2013 ),
2014 ] {
2015 let sanitized =
2016 sanitize_connected_error(context.to_owned(), code, hostile_secure_error());
2017 let rendered = format!("{sanitized}\n{sanitized:?}");
2018 for secret in SECRETS {
2019 assert!(!rendered.contains(secret), "{secret}: {rendered}");
2020 }
2021 assert!(rendered.contains(context), "{rendered}");
2022 assert!(rendered.contains(code), "{rendered}");
2023 }
2024 }
2025
2026 #[test]
2027 fn connected_orm_lifecycle_contexts_drop_hostile_provider_text() {
2028 for (context, code) in [
2029 (
2030 "cannot check database \"managed\"",
2031 "typedb_database_exists_failed",
2032 ),
2033 (
2034 "cannot ensure database \"managed\"",
2035 "typedb_database_ensure_failed",
2036 ),
2037 ] {
2038 let sanitized = sanitize_connected_orm_error(
2039 context.to_owned(),
2040 code,
2041 type_bridge_orm::OrmError::Connection(PROVIDER_TEXT.to_owned()),
2042 );
2043 let rendered = format!("{sanitized}\n{sanitized:?}");
2044 for secret in SECRETS {
2045 assert!(!rendered.contains(secret), "{secret}: {rendered}");
2046 }
2047 assert!(rendered.contains(context), "{rendered}");
2048 assert!(rendered.contains(code), "{rendered}");
2049 }
2050 }
2051
2052 #[test]
2053 fn connected_lifecycle_preserves_only_typed_safe_diagnostics() {
2054 let sanitized = sanitize_connected_error(
2055 "cannot connect the managed database".to_owned(),
2056 "typedb_database_connect_failed",
2057 type_bridge_orm::SecureConnectError::DriverTlsConfiguration { band: 9 },
2058 );
2059 assert!(
2060 sanitized.contains("tls_driver_lowering_failed"),
2061 "{sanitized}"
2062 );
2063 assert!(sanitized.contains("driver band 9"), "{sanitized}");
2064 assert!(
2065 !sanitized.contains("typedb_database_connect_failed"),
2066 "{sanitized}"
2067 );
2068 }
2069
2070 #[test]
2071 fn schema_export_drops_hostile_orm_text_and_source() {
2072 let sanitized = sanitize_schema_export_error(type_bridge_orm::OrmError::Connection(
2073 PROVIDER_TEXT.into(),
2074 ));
2075 let rendered = format!("{sanitized}\n{sanitized:?}");
2076 for secret in SECRETS {
2077 assert!(!rendered.contains(secret), "{secret}: {rendered}");
2078 }
2079 assert!(
2080 rendered.contains("typedb_schema_export_failed"),
2081 "{rendered}"
2082 );
2083 }
2084
2085 #[test]
2086 fn migration_runner_display_omits_provider_details() {
2087 let diagnostic = Diagnostic::new(
2088 DiagnosticCategory::InvalidContract,
2089 DiagnosticCode::new("migration_provider_test_failed").expect("static code"),
2090 "migration provider operation failed",
2091 )
2092 .with_detail("provider", PROVIDER_TEXT);
2093 let error = type_bridge_schema_migration_typedb::MigrationDirectoryApplyError::Diagnostic(
2094 diagnostic,
2095 );
2096
2097 let rendered = display(error);
2098 for secret in SECRETS {
2099 assert!(!rendered.contains(secret), "{secret}: {rendered}");
2100 }
2101 assert!(
2102 rendered.contains("migration_provider_test_failed"),
2103 "{rendered}"
2104 );
2105 }
2106
2107 #[test]
2108 fn migration_outcome_projection_omits_provider_details_for_apply_and_adopt() {
2109 use type_bridge_contract::migration::MigrationId;
2110 use type_bridge_schema_migration::{MigrationExecutionOutcome, MigrationExecutionPosition};
2111
2112 let diagnostic = || {
2113 Diagnostic::new(
2114 DiagnosticCategory::InvalidContract,
2115 DiagnosticCode::new("migration_provider_test_failed").expect("static code"),
2116 "migration provider operation failed",
2117 )
2118 .with_detail("provider", PROVIDER_TEXT)
2119 };
2120 for context in [
2121 "apply did not complete",
2122 "adoption checkpoint did not complete",
2123 ] {
2124 for (outcome, expected_state, expected_position) in [
2125 (
2126 MigrationExecutionOutcome::RetrySafe {
2127 migration_id: MigrationId::new("example", "0001_initial")
2128 .expect("migration id"),
2129 position: MigrationExecutionPosition::TransactionGroup(7),
2130 diagnostic: diagnostic(),
2131 },
2132 "retry-safe",
2133 "transaction group 7",
2134 ),
2135 (
2136 MigrationExecutionOutcome::RequiresExplicitRecovery {
2137 migration_id: MigrationId::new("example", "0001_initial")
2138 .expect("migration id"),
2139 position: MigrationExecutionPosition::ManifestCheckpoint,
2140 diagnostic: diagnostic(),
2141 },
2142 "explicit recovery required",
2143 "manifest checkpoint",
2144 ),
2145 ] {
2146 let rendered = sanitize_migration_execution_outcome(context, outcome);
2147 for secret in SECRETS {
2148 assert!(!rendered.contains(secret), "{secret}: {rendered}");
2149 }
2150 for expected in [
2151 context,
2152 expected_state,
2153 "example/0001_initial",
2154 expected_position,
2155 "migration_provider_test_failed",
2156 "migration provider operation failed",
2157 ] {
2158 assert!(rendered.contains(expected), "{expected}: {rendered}");
2159 }
2160 }
2161 }
2162 }
2163}
2164
2165#[cfg(all(test, unix))]
2166mod output_authority_tests {
2167 use super::*;
2168 use std::os::unix::fs::symlink;
2169
2170 #[test]
2171 fn retained_output_authority_survives_component_swap_without_redirecting() {
2172 let workspace = tempfile::tempdir().expect("workspace directory");
2173 let outside = tempfile::tempdir().expect("outside directory");
2174 fs::create_dir_all(workspace.path().join("generated/python")).expect("output directory");
2175 let authority = WorkspaceDirectoryAuthority::open(
2176 WorkspaceRoot::new(fs::canonicalize(workspace.path()).expect("canonical workspace"))
2177 .expect("workspace root"),
2178 )
2179 .expect("workspace authority");
2180 let root = authority.output_root().expect("output authority");
2181 let output = root
2182 .open_beneath(Path::new("generated/python"))
2183 .expect("output authority");
2184
2185 let held = workspace.path().join("generated/python-held");
2186 fs::rename(workspace.path().join("generated/python"), &held)
2187 .expect("move retained output directory");
2188 symlink(outside.path(), workspace.path().join("generated/python"))
2189 .expect("redirect configured output path");
2190
2191 output
2192 .write_atomic("_models.py".as_ref(), b"retained authority")
2193 .expect("publication remains handle-relative");
2194 assert_eq!(
2195 fs::read(held.join("_models.py")).expect("retained output reads"),
2196 b"retained authority"
2197 );
2198 assert!(
2199 !outside.path().join("_models.py").exists(),
2200 "component replacement redirected output outside the workspace"
2201 );
2202 }
2203
2204 #[test]
2205 fn retained_output_root_survives_root_entry_swap_without_redirecting() {
2206 let workspace = tempfile::tempdir().expect("workspace directory");
2207 let outside = tempfile::tempdir().expect("outside directory");
2208 fs::create_dir_all(workspace.path().join("generated/python")).expect("output directory");
2209 let authority = WorkspaceDirectoryAuthority::open(
2210 WorkspaceRoot::new(fs::canonicalize(workspace.path()).expect("canonical workspace"))
2211 .expect("workspace root"),
2212 )
2213 .expect("workspace authority");
2214 let root = authority.output_root().expect("output authority");
2215 let held = workspace
2216 .path()
2217 .parent()
2218 .expect("temporary parent")
2219 .join(format!(
2220 "{}-retained",
2221 workspace
2222 .path()
2223 .file_name()
2224 .expect("temporary name")
2225 .to_string_lossy()
2226 ));
2227 fs::rename(workspace.path(), &held).expect("workspace root moves after validation");
2228 symlink(outside.path(), workspace.path()).expect("workspace name redirects outside");
2229
2230 let output = root
2231 .open_beneath(Path::new("generated/python"))
2232 .expect("output opens through retained root");
2233 output
2234 .write_atomic("_models.py".as_ref(), b"retained root authority")
2235 .expect("publication remains rooted in the retained handle");
2236 assert_eq!(
2237 fs::read(held.join("generated/python/_models.py")).expect("retained output reads"),
2238 b"retained root authority"
2239 );
2240 assert!(
2241 !outside.path().join("generated/python/_models.py").exists(),
2242 "root replacement redirected output outside the workspace"
2243 );
2244
2245 fs::remove_file(workspace.path()).expect("replacement symlink removes");
2246 fs::rename(&held, workspace.path()).expect("workspace restores for cleanup");
2247 }
2248}
2249
2250fn bind_approvals(
2251 graph: &type_bridge_schema_migration::MigrationHistoryGraph,
2252 approvals: &[String],
2253) -> Result<Vec<type_bridge_schema_migration::MigrationApplyApproval>, String> {
2254 if approvals.is_empty() {
2255 return Ok(Vec::new());
2256 }
2257 approvals
2258 .iter()
2259 .map(|compound| {
2260 let (app_label, name) = compound
2261 .split_once('/')
2262 .ok_or_else(|| format!("approval {compound:?} must be app-label/name"))?;
2263 let id = type_bridge_contract::migration::MigrationId::from_components(
2264 type_bridge_contract::migration::MigrationAppLabel::new(app_label.to_owned())
2265 .map_err(display)?,
2266 type_bridge_contract::migration::MigrationName::new(name.to_owned())
2267 .map_err(display)?,
2268 );
2269 let manifest = graph.manifest(&id).ok_or_else(|| {
2270 format!("approval target {compound:?} is not in the committed history")
2271 })?;
2272 type_bridge_schema_migration::MigrationApplyApproval::for_manifest(manifest)
2273 .map_err(display)
2274 })
2275 .collect()
2276}
2277
2278fn parse_migration_ids(
2279 graph: &type_bridge_schema_migration::MigrationHistoryGraph,
2280 values: &[String],
2281 kind: &str,
2282) -> Result<BTreeSet<type_bridge_contract::migration::MigrationId>, String> {
2283 let mut ids = BTreeSet::new();
2284 for compound in values {
2285 let (app_label, name) = compound
2286 .split_once('/')
2287 .ok_or_else(|| format!("{kind} {compound:?} must be app-label/name"))?;
2288 let id = type_bridge_contract::migration::MigrationId::from_components(
2289 type_bridge_contract::migration::MigrationAppLabel::new(app_label.to_owned())
2290 .map_err(display)?,
2291 type_bridge_contract::migration::MigrationName::new(name.to_owned())
2292 .map_err(display)?,
2293 );
2294 if graph.manifest(&id).is_none() {
2295 return Err(format!(
2296 "{kind} target {compound:?} is not in the committed history"
2297 ));
2298 }
2299 if !ids.insert(id) {
2300 return Err(format!("{kind} target {compound:?} is duplicated"));
2301 }
2302 }
2303 Ok(ids)
2304}
2305
2306fn bind_rollback_approvals(
2307 graph: &type_bridge_schema_migration::MigrationHistoryGraph,
2308 approvals: &[String],
2309) -> Result<Vec<type_bridge_schema_migration::MigrationApplyApproval>, String> {
2310 let ids = parse_migration_ids(graph, approvals, "rollback approval")?;
2311 let mut bound = Vec::new();
2312 for id in ids {
2313 let manifest = graph
2314 .manifest(&id)
2315 .ok_or_else(|| "internal rollback approval target disappeared".to_owned())?;
2316 for safety in [
2317 type_bridge_schema::SafetyClass::FormalOnly,
2318 type_bridge_schema::SafetyClass::SchemaMetadata,
2319 type_bridge_schema::SafetyClass::Additive,
2320 type_bridge_schema::SafetyClass::Conditional,
2321 type_bridge_schema::SafetyClass::Destructive,
2322 type_bridge_schema::SafetyClass::Opaque,
2323 ] {
2324 bound.push(
2325 type_bridge_schema_migration::MigrationApplyApproval::for_rollback(
2326 manifest, safety,
2327 )
2328 .map_err(display)?,
2329 );
2330 }
2331 }
2332 Ok(bound)
2333}
2334
2335#[cfg(test)]
2336mod transport_option_tests {
2337 use super::*;
2338
2339 fn environment(policy: WorkspaceTransportPolicy) -> WorkspaceEnvironment {
2340 WorkspaceEnvironment::new(
2341 "typedb.example:1729",
2342 "example",
2343 SecretReference::environment("TYPEBRIDGE_TEST_USERNAME").expect("username reference"),
2344 SecretReference::environment("TYPEBRIDGE_TEST_PASSWORD").expect("password reference"),
2345 )
2346 .expect("environment")
2347 .with_transport_policy(policy)
2348 }
2349
2350 fn custom_root_workspace(root_bytes: &[u8]) -> (tempfile::TempDir, TypeBridgeWorkspace) {
2351 let directory = tempfile::tempdir().expect("workspace directory");
2352 fs::create_dir_all(directory.path().join("schema/fragments")).expect("schema directory");
2353 fs::create_dir_all(directory.path().join("migrations/v2")).expect("migration directory");
2354 fs::create_dir_all(directory.path().join("certs")).expect("certificate directory");
2355 fs::write(
2356 directory.path().join("schema/schema.yaml"),
2357 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
2358 )
2359 .expect("schema set writes");
2360 fs::write(
2361 directory.path().join("schema/fragments/model.yaml"),
2362 "format: typebridge.schema/v2\nentities: {person: {}}\n",
2363 )
2364 .expect("schema writes");
2365 fs::write(directory.path().join("certs/root.pem"), root_bytes).expect("certificate writes");
2366 let manifest = directory.path().join("typebridge.yaml");
2367 fs::write(
2368 &manifest,
2369 "format: typebridge.workspace/v1\n\
2370 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: tls-test\n\
2371 compatibility:\n semantic-profile: typedb-3.12.1/v1\n\
2372 migrations:\n directory: migrations/v2\n app-label: tlstest\n\
2373 environments:\n dev:\n database: tls_test\n uri: never-contact.invalid:1729\n \
2374 tls: 'true'\n tls-root-ca: certs/root.pem\n credential:\n username: \
2375 env:TYPEBRIDGE_TEST_USERNAME\n password: env:TYPEBRIDGE_TEST_PASSWORD\n",
2376 )
2377 .expect("manifest writes");
2378 let workspace = load_workspace(&manifest).expect("custom-root workspace loads");
2379 (directory, workspace)
2380 }
2381
2382 #[test]
2383 fn workspace_transport_policy_maps_without_changing_plaintext_defaults() {
2384 let defaults = type_bridge_orm::SecureConnectOptions::default();
2385 let disabled = secure_connect_options(&environment(WorkspaceTransportPolicy::Disabled));
2386 assert_eq!(disabled.tls_mode, type_bridge_orm::TlsMode::Disabled);
2387 assert_eq!(disabled.http_port, defaults.http_port);
2388 assert_eq!(disabled.server_version, defaults.server_version);
2389
2390 let native = secure_connect_options(
2391 &environment(WorkspaceTransportPolicy::NativeRoots).with_http_port(9443),
2392 );
2393 assert_eq!(native.tls_mode, type_bridge_orm::TlsMode::NativeRoots);
2394 assert_eq!(native.http_port, 9443);
2395 assert_eq!(native.server_version, defaults.server_version);
2396 }
2397
2398 #[test]
2399 fn custom_root_mapping_preserves_the_validated_canonical_path() {
2400 let directory = tempfile::tempdir().expect("workspace directory");
2401 let canonical = fs::canonicalize(directory.path()).expect("canonical workspace");
2402 fs::create_dir_all(canonical.join("certs")).expect("certificate directory");
2403 fs::write(
2404 canonical.join("certs/root.pem"),
2405 b"not parsed at workspace boundary\n",
2406 )
2407 .expect("certificate writes");
2408 let root = WorkspaceRoot::new(canonical.clone()).expect("workspace root");
2409 let root_ca = type_bridge_workspace::WorkspaceRootCa::new(
2410 &root,
2411 "certs/root.pem",
2412 &SystemSchemaSourceService,
2413 )
2414 .expect("confined root CA");
2415
2416 let options = secure_connect_options(
2417 &environment(WorkspaceTransportPolicy::CustomRootCa(root_ca)).with_http_port(8443),
2418 );
2419 assert_eq!(
2420 options.tls_mode,
2421 type_bridge_orm::TlsMode::CustomRootCa(canonical.join("certs/root.pem"))
2422 );
2423 assert_eq!(options.http_port, 8443);
2424 }
2425
2426 #[test]
2427 fn malformed_custom_root_fails_transport_preflight_before_credentials_are_needed() {
2428 let (_directory, workspace) = custom_root_workspace(b"definitely not a certificate\n");
2429
2430 let error = preflight_secure_connect_options(&workspace, "dev")
2431 .expect_err("PEM parsing must happen before credential resolution");
2432 assert!(error.contains("tls_custom_root_ca_invalid_pem"), "{error}");
2433 assert!(!error.contains("TYPEBRIDGE_TEST_USERNAME"), "{error}");
2434 assert!(!error.contains("TYPEBRIDGE_TEST_PASSWORD"), "{error}");
2435 }
2436
2437 #[cfg(unix)]
2438 #[test]
2439 fn workspace_root_swap_to_outside_symlink_is_rejected_at_transport_preflight() {
2440 use std::os::unix::fs::symlink;
2441
2442 let (directory, workspace) = custom_root_workspace(b"initial regular root\n");
2443 let outside = tempfile::tempdir().expect("outside directory");
2444 let configured = directory.path().join("certs/root.pem");
2445
2446 let outside_root = outside.path().join("malicious.pem");
2447 fs::write(
2448 &outside_root,
2449 include_bytes!("../../core/tests/fixtures/valid-root.pem"),
2450 )
2451 .expect("write outside replacement root");
2452 fs::remove_file(&configured).expect("remove validated confined root");
2453 symlink(&outside_root, &configured).expect("install outside symlink after validation");
2454
2455 let error = preflight_secure_connect_options(&workspace, "dev")
2456 .expect_err("retained workspace paths must never follow a replacement symlink");
2457 assert!(error.contains("tls_custom_root_ca_unreadable"), "{error}");
2458 assert!(!error.contains("tls_custom_root_ca_invalid_pem"), "{error}");
2459 }
2460
2461 #[cfg(unix)]
2462 #[test]
2463 fn real_directory_root_replacement_cannot_substitute_custom_trust() {
2464 let (directory, workspace) =
2465 custom_root_workspace(include_bytes!("../../core/tests/fixtures/valid-root.pem"));
2466 let configured_root = directory.path().to_path_buf();
2467 let held_root = configured_root.with_extension("retained-custom-root-ca");
2468 fs::rename(&configured_root, &held_root).expect("move retained workspace root");
2469 fs::create_dir_all(configured_root.join("certs")).expect("replacement root creates");
2470 fs::write(
2471 configured_root.join("certs/root.pem"),
2472 b"attacker-controlled replacement is not a certificate\n",
2473 )
2474 .expect("replacement root writes");
2475
2476 let preflight = preflight_secure_connect_options(&workspace, "dev");
2477
2478 fs::remove_dir_all(&configured_root).expect("replacement root removes");
2479 fs::rename(&held_root, &configured_root).expect("retained root restores");
2480 preflight.expect("transport must use the CA under the retained original root");
2481 }
2482}
2483
2484#[cfg(test)]
2485mod rollback_cli_contract_tests {
2486 use super::*;
2487
2488 #[test]
2489 fn rollback_grammar_requires_environment_and_explicit_removal() {
2490 assert!(Cli::try_parse_from(["type-bridge", "migration", "rollback"]).is_err());
2491 assert!(
2492 Cli::try_parse_from([
2493 "type-bridge",
2494 "migration",
2495 "rollback",
2496 "--environment",
2497 "live",
2498 ])
2499 .is_err()
2500 );
2501 let cli = Cli::try_parse_from([
2502 "type-bridge",
2503 "migration",
2504 "rollback",
2505 "--environment",
2506 "live",
2507 "--remove",
2508 "example/0002_contract",
2509 "--remove",
2510 "example/0001_expand",
2511 "--approve",
2512 "example/0002_contract",
2513 "--output",
2514 "json",
2515 ])
2516 .expect("explicit preview grammar parses");
2517 let Command::Migration {
2518 command:
2519 MigrationCommand::Rollback {
2520 environment,
2521 removals,
2522 approvals,
2523 execute,
2524 output,
2525 },
2526 } = cli.command
2527 else {
2528 panic!("rollback command parsed into another command")
2529 };
2530 assert_eq!(environment, "live");
2531 assert_eq!(removals.len(), 2);
2532 assert_eq!(approvals, ["example/0002_contract"]);
2533 assert!(!execute, "preview is the non-mutating default");
2534 assert_eq!(output, RollbackOutput::Json);
2535 }
2536
2537 #[test]
2538 fn rollback_execution_requires_an_explicit_flag() {
2539 let cli = Cli::try_parse_from([
2540 "type-bridge",
2541 "migration",
2542 "rollback",
2543 "--environment",
2544 "live",
2545 "--remove",
2546 "example/0002_contract",
2547 "--execute",
2548 ])
2549 .expect("explicit execution grammar parses");
2550 assert!(matches!(
2551 cli.command,
2552 Command::Migration {
2553 command: MigrationCommand::Rollback { execute: true, .. }
2554 }
2555 ));
2556 }
2557}
2558
2559#[cfg(test)]
2560mod migration_command_tests {
2561 use super::*;
2562
2563 fn write_workspace_manifest(root: &Path, semantic_profile: &str) -> PathBuf {
2564 fs::create_dir_all(root.join("schema/fragments")).expect("schema directory");
2565 fs::write(
2566 root.join("schema/schema.yaml"),
2567 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
2568 )
2569 .expect("schema set writes");
2570 fs::write(
2571 root.join("schema/fragments/model.yaml"),
2572 "format: typebridge.schema/v2\nentities: {person: {}}\n",
2573 )
2574 .expect("schema writes");
2575 let manifest = root.join("typebridge.yaml");
2576 fs::write(
2577 &manifest,
2578 format!(
2579 "format: typebridge.workspace/v1\n\
2580 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: command-test\n\
2581 compatibility:\n semantic-profile: {semantic_profile}\n\
2582 migrations:\n directory: migrations/v2\n app-label: commandtest\n\
2583 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"
2584 ),
2585 )
2586 .expect("manifest writes");
2587 manifest
2588 }
2589
2590 #[test]
2591 fn migration_make_creates_its_missing_authoring_directory() {
2592 let directory = tempfile::tempdir().expect("workspace directory");
2593 let manifest = write_workspace_manifest(directory.path(), "typedb-3.11.5/v1");
2594 let migration_directory = directory.path().join("migrations/v2");
2595 assert!(!migration_directory.exists());
2596
2597 run(&Cli {
2598 manifest,
2599 command: Command::Migration {
2600 command: MigrationCommand::Make {
2601 name: "initial".to_owned(),
2602 backfill_intent: None,
2603 },
2604 },
2605 })
2606 .expect("migration make creates and publishes into its authoring directory");
2607
2608 assert!(
2609 migration_directory
2610 .join("0001_initial.tbmigration.json")
2611 .is_file()
2612 );
2613 assert!(migration_directory.join("0001_initial.typeql").is_file());
2614 }
2615
2616 #[test]
2617 fn migration_make_accepts_a_confined_backfill_intent() {
2618 let directory = tempfile::tempdir().expect("workspace directory");
2619 let manifest = write_workspace_manifest(directory.path(), "typedb-3.11.5/v1");
2620 fs::write(
2621 directory.path().join("schema/fragments/model.yaml"),
2622 "format: typebridge.schema/v2\nattributes:\n display-name: { value: string }\n legacy-name: { value: string }\n person-id: { value: string }\nentities:\n person:\n owns:\n display-name: {}\n legacy-name: {}\n person-id: { key: true }\n",
2623 )
2624 .expect("backfill schema writes");
2625 let initial = Cli {
2626 manifest: manifest.clone(),
2627 command: Command::Migration {
2628 command: MigrationCommand::Make {
2629 name: "initial".to_owned(),
2630 backfill_intent: None,
2631 },
2632 },
2633 };
2634 run(&initial).expect("initial migration");
2635 fs::write(
2636 directory.path().join("migrations/v2/copy-name.backfill.yaml"),
2637 "format: typebridge.migration-backfill-intent/v1\ncopy-attribute:\n owner-kind: entity\n owner: person\n source: legacy-name\n destination: display-name\n partition-key: person-id\n batch-rows: 128\n reverse: remove-equal-copied-destination\n",
2638 )
2639 .expect("backfill intent writes");
2640
2641 run(&Cli {
2642 manifest,
2643 command: Command::Migration {
2644 command: MigrationCommand::Make {
2645 name: "copy-name".to_owned(),
2646 backfill_intent: Some(PathBuf::from("copy-name.backfill.yaml")),
2647 },
2648 },
2649 })
2650 .expect("backfill migration authors");
2651
2652 assert!(
2653 directory
2654 .path()
2655 .join("migrations/v2/0002_copy-name.tbmigration.json")
2656 .is_file()
2657 );
2658 }
2659
2660 #[test]
2661 fn unsupported_execution_profile_rejects_before_credentials_or_filesystem_mutation() {
2662 let directory = tempfile::tempdir().expect("workspace directory");
2663 let manifest = write_workspace_manifest(directory.path(), "typedb-3.11.5/v1");
2664 let workspace = load_workspace(&manifest).expect("workspace loads");
2665
2666 for action in [
2667 ConnectedAction::Apply {
2668 approvals: Vec::new(),
2669 },
2670 ConnectedAction::Verify,
2671 ConnectedAction::Adopt {
2672 archive_directory: directory.path().join("missing-archive"),
2673 name: "0000_archive_frontier".to_owned(),
2674 },
2675 ] {
2676 let error = run_connected(&workspace, "dev", action)
2677 .expect_err("every connected migration operation uses the exact profile");
2678
2679 assert!(
2680 error.contains("migration_typedb_semantic_profile_unsupported"),
2681 "{error}"
2682 );
2683 assert!(error.contains("typedb-3.11.5/v1"), "{error}");
2684 assert!(error.contains("typedb-3.12.1/v1"), "{error}");
2685 assert!(
2686 !error.contains("credential environment variable")
2687 && !error.contains("cannot connect")
2688 && !error.contains("cannot check database"),
2689 "profile gate ran after external setup: {error}"
2690 );
2691 }
2692 assert!(
2693 !directory.path().join("migrations/v2").exists(),
2694 "profile rejection must not create the migration directory"
2695 );
2696 }
2697}
2698
2699#[cfg(test)]
2700mod adoption_file_tests {
2701 use super::*;
2702 use sha2::{Digest as _, Sha256};
2703
2704 const LEGACY_SCHEMA: &str = "define\nentity person;\n";
2705
2706 fn adoption_workspace() -> (tempfile::TempDir, TypeBridgeWorkspace) {
2707 let directory = tempfile::tempdir().expect("workspace directory");
2708 fs::create_dir_all(directory.path().join("schema/fragments")).expect("schema directory");
2709 fs::write(
2710 directory.path().join("schema/schema.yaml"),
2711 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
2712 )
2713 .expect("schema set writes");
2714 fs::write(
2715 directory.path().join("schema/fragments/model.yaml"),
2716 "format: typebridge.schema/v2\nentities: {person: {}}\n",
2717 )
2718 .expect("schema writes");
2719 let manifest = directory.path().join("typebridge.yaml");
2720 fs::write(
2721 &manifest,
2722 "format: typebridge.workspace/v1\n\
2723 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: adoption-test\n\
2724 compatibility:\n semantic-profile: typedb-3.12.1/v1\n\
2725 migrations:\n directory: migrations/v2\n app-label: smoke\n\
2726 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",
2727 )
2728 .expect("manifest writes");
2729 let workspace = load_workspace(&manifest).expect("workspace loads");
2730 (directory, workspace)
2731 }
2732
2733 fn write_legacy_fixture(root: &Path) -> PathBuf {
2734 let directory = root.join("migrations/legacy");
2735 fs::create_dir_all(&directory).expect("legacy directory");
2736 let name = "0001_initial";
2737 let python_source = "class Migration:\n operations = []\n";
2738 let checksum = type_bridge_migration::migration_file_checksum(python_source);
2739 let source_sha256 = format!("{:x}", Sha256::digest(python_source.as_bytes()));
2740 let schema_hash = format!("{:x}", Sha256::digest(LEGACY_SCHEMA.as_bytes()));
2741 fs::write(directory.join(format!("{name}.py")), python_source)
2742 .expect("legacy source writes");
2743 let adoption = type_bridge_migration::LegacyAdoptionMetadata::new(
2744 "legacy",
2745 name,
2746 Vec::new(),
2747 checksum,
2748 source_sha256,
2749 type_bridge_migration::LegacySchemaEffect::Snapshot,
2750 type_bridge_migration::MigrationDependencySpec {
2751 app_label: "legacy".to_owned(),
2752 migration_name: name.to_owned(),
2753 },
2754 schema_hash.clone(),
2755 )
2756 .expect("legacy adoption metadata");
2757 fs::write(
2758 directory.join(format!("{name}.adoption.json")),
2759 serde_json::to_vec_pretty(&adoption).expect("metadata encodes"),
2760 )
2761 .expect("metadata writes");
2762 let snapshot = directory.join("snapshots/v0001");
2763 fs::create_dir_all(&snapshot).expect("snapshot directory");
2764 fs::write(snapshot.join("schema.tql"), LEGACY_SCHEMA).expect("snapshot schema writes");
2765 fs::write(
2766 snapshot.join("snapshot.json"),
2767 serde_json::to_vec_pretty(&serde_json::json!({
2768 "version": "v0001",
2769 "source_migration": name,
2770 "schema_hash": schema_hash,
2771 "file_hashes": {"schema.tql": schema_hash},
2772 "type_bridge_version": "1.5.11",
2773 "type_bridge_core_version": "1.5.11"
2774 }))
2775 .expect("snapshot manifest encodes"),
2776 )
2777 .expect("snapshot manifest writes");
2778 directory
2779 }
2780
2781 #[test]
2782 fn authority_publication_is_atomic_no_replace_and_resumable() {
2783 let directory = tempfile::tempdir().expect("directory");
2784 let authority =
2785 type_bridge_schema_migration::MigrationDirectory::open_ambient(directory.path())
2786 .expect("directory authority");
2787 let name = "adopted-genesis.typeql";
2788 let path = directory.path().join("adopted-genesis.typeql");
2789 assert!(
2790 publish_authority(&authority, name, b"define\nentity person;\n")
2791 .expect("first publish")
2792 );
2793 assert!(
2794 !publish_authority(&authority, name, b"define\nentity person;\n")
2795 .expect("identical recovery")
2796 );
2797 assert!(publish_authority(&authority, name, b"define\nentity company;\n").is_err());
2798 assert_eq!(
2799 fs::read(&path).expect("authority reads"),
2800 b"define\nentity person;\n"
2801 );
2802 assert!(
2803 fs::read_dir(directory.path())
2804 .expect("directory reads")
2805 .all(|entry| !entry
2806 .expect("entry")
2807 .file_name()
2808 .to_string_lossy()
2809 .ends_with(".tmp"))
2810 );
2811 }
2812
2813 #[cfg(unix)]
2814 #[test]
2815 fn authority_publication_rejects_final_symlink() {
2816 use std::os::unix::fs::symlink;
2817
2818 let directory = tempfile::tempdir().expect("directory");
2819 let outside = directory.path().join("outside");
2820 fs::write(&outside, b"untouched").expect("outside writes");
2821 let path = directory.path().join("adopted-genesis.typeql");
2822 symlink(&outside, &path).expect("symlink");
2823 let authority =
2824 type_bridge_schema_migration::MigrationDirectory::open_ambient(directory.path())
2825 .expect("directory authority");
2826
2827 assert!(publish_authority(&authority, "adopted-genesis.typeql", b"replacement").is_err());
2828 assert_eq!(fs::read(&outside).expect("outside reads"), b"untouched");
2829 }
2830
2831 #[test]
2832 fn invalid_adoption_name_creates_no_canonical_directory() {
2833 let (directory, workspace) = adoption_workspace();
2834 let missing_archive = directory.path().join("missing-legacy");
2835 let error = run_connected(
2836 &workspace,
2837 "dev",
2838 ConnectedAction::Adopt {
2839 archive_directory: missing_archive,
2840 name: String::new(),
2841 },
2842 )
2843 .expect_err("invalid name fails before history or network access");
2844 assert!(error.contains("migration"), "{error}");
2845 assert!(
2846 !directory.path().join("migrations/v2").exists(),
2847 "bad-name validation must not create canonical filesystem state"
2848 );
2849 }
2850
2851 #[test]
2852 fn invalid_apply_approvals_fail_before_credentials_or_network() {
2853 let (directory, workspace) = adoption_workspace();
2854 fs::create_dir_all(directory.path().join("migrations/v2")).expect("canonical directory");
2855
2856 for (approval, expected) in [
2857 ("not-a-compound-id", "must be app-label/name"),
2858 ("smoke/0001_missing", "is not in the committed history"),
2859 ] {
2860 let error = run_connected(
2861 &workspace,
2862 "dev",
2863 ConnectedAction::Apply {
2864 approvals: vec![approval.to_owned()],
2865 },
2866 )
2867 .expect_err("invalid approval is rejected by local authority");
2868 assert!(error.contains(expected), "{approval}: {error}");
2869 assert!(
2870 !error.contains("credential")
2871 && !error.contains("connect")
2872 && !error.contains("database"),
2873 "approval validation ran after external setup: {error}"
2874 );
2875 }
2876 }
2877
2878 #[test]
2879 fn adoption_retry_completes_either_exact_orphan_direction() {
2880 for orphan in ["genesis", "bridge"] {
2881 let (directory, workspace) = adoption_workspace();
2882 let legacy = write_legacy_fixture(directory.path());
2883 let prepared = prepare_archive_adoption(&workspace, &legacy, "0000_archive_frontier")
2884 .expect("adoption prepares");
2885 let migration_directory = workspace
2886 .ensure_migration_directory()
2887 .expect("canonical directory");
2888 match orphan {
2889 "genesis" => {
2890 publish_authority(
2891 migration_directory.directory(),
2892 type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME,
2893 prepared.reconstructed.schema_typeql().as_bytes(),
2894 )
2895 .expect("genesis orphan publishes");
2896 }
2897 "bridge" => {
2898 publish_authority(
2899 migration_directory.directory(),
2900 &prepared.bridge_name,
2901 &prepared.bridge_bytes,
2902 )
2903 .expect("bridge orphan publishes");
2904 }
2905 _ => unreachable!(),
2906 }
2907
2908 publish_prepared_adoption(&workspace, &migration_directory, &prepared)
2909 .expect("adoption retry completes the exact orphan");
2910 workspace
2911 .discover_migrations_in(&migration_directory)
2912 .expect("completed adoption pair discovers");
2913 assert!(
2914 migration_directory
2915 .display_path()
2916 .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
2917 .is_file()
2918 );
2919 assert!(
2920 migration_directory
2921 .display_path()
2922 .join(&prepared.bridge_name)
2923 .is_file()
2924 );
2925 }
2926 }
2927
2928 #[test]
2929 fn legacy_history_race_after_bridge_rolls_back_new_publication() {
2930 let (directory, workspace) = adoption_workspace();
2931 let legacy = write_legacy_fixture(directory.path());
2932 let prepared = prepare_archive_adoption(&workspace, &legacy, "0000_archive_frontier")
2933 .expect("adoption prepares");
2934 let migration_directory = workspace
2935 .ensure_migration_directory()
2936 .expect("canonical directory");
2937 let legacy_source = legacy.join("0001_initial.py");
2938
2939 let error = publish_prepared_adoption_with_after_bridge(
2940 &workspace,
2941 &migration_directory,
2942 &prepared,
2943 || {
2944 fs::write(
2945 &legacy_source,
2946 "class Migration:\n operations = ['changed']\n",
2947 )
2948 .expect("race mutation writes");
2949 },
2950 )
2951 .expect_err("legacy authority race aborts pair publication");
2952 assert!(error.contains("changed"), "{error}");
2953 assert!(
2954 !migration_directory
2955 .display_path()
2956 .join(&prepared.bridge_name)
2957 .exists(),
2958 "new bridge is rolled back"
2959 );
2960 assert!(
2961 !migration_directory
2962 .display_path()
2963 .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
2964 .exists(),
2965 "genesis is never published after the race"
2966 );
2967 }
2968}