Skip to main content

type_bridge_migration/state/
schema.rs

1//! Canonical TypeBridge migration-state schema contract.
2//!
3//! The migration ledger uses the ORM's existing [`SchemaInfo`] IR so schema
4//! bootstrap, Rust consumers, and language bindings all project from one
5//! structural definition. Labels remain exact-match infrastructure names; no
6//! prefix-based reservation is implied.
7
8use std::collections::BTreeMap;
9use std::sync::LazyLock;
10
11use serde::{Deserialize, Serialize};
12use type_bridge_orm::schema::SchemaInfo;
13use type_bridge_orm::schema::info::{AttributeSchemaEntry, EntitySchemaEntry, OwnedAttributeEntry};
14use type_bridge_orm::{Annotation, ValueType};
15
16/// Semantic labels used by the TypeDB-backed migration state store.
17///
18/// Keeping the storage queries and the canonical schema on these constants
19/// prevents either surface from acquiring a private copy of the label set.
20pub(crate) mod labels {
21    use type_bridge_contract::reserved::{
22        LEGACY_LEDGER_APP_LABEL, LEGACY_LEDGER_APPLIED_AT, LEGACY_LEDGER_APPLIED_ENTITY,
23        LEGACY_LEDGER_CHECKSUM, LEGACY_LEDGER_MIGRATION_ID, LEGACY_LEDGER_NAME,
24    };
25
26    /// Entity storing the applied-migration projection.
27    pub const APPLIED_ENTITY: &str = LEGACY_LEDGER_APPLIED_ENTITY;
28    /// Entity storing individual migration execution attempts.
29    pub const RUN_ENTITY: &str = "type_bridge_migration_run";
30
31    /// Composite applied-migration identifier attribute.
32    pub const MIGRATION_ID: &str = LEGACY_LEDGER_MIGRATION_ID;
33    /// Migration application/package label attribute.
34    pub const APP_LABEL: &str = LEGACY_LEDGER_APP_LABEL;
35    /// Migration name attribute.
36    pub const NAME: &str = LEGACY_LEDGER_NAME;
37    /// Applied timestamp attribute.
38    pub const APPLIED_AT: &str = LEGACY_LEDGER_APPLIED_AT;
39    /// Migration checksum attribute.
40    pub const CHECKSUM: &str = LEGACY_LEDGER_CHECKSUM;
41    /// Execution-attempt identifier attribute.
42    pub const RUN_ID: &str = "migration_run_id";
43    /// Execution direction attribute.
44    pub const DIRECTION: &str = "migration_direction";
45    /// Execution status attribute.
46    pub const STATUS: &str = "migration_status";
47    /// Execution start timestamp attribute.
48    pub const STARTED_AT: &str = "migration_started_at";
49    /// Execution finish timestamp attribute.
50    pub const FINISHED_AT: &str = "migration_finished_at";
51    /// Execution error attribute.
52    pub const ERROR: &str = "migration_error";
53    /// Executor IP address attribute.
54    pub const EXECUTOR_IP: &str = "migration_executor_ip";
55    /// Executor MAC address attribute.
56    pub const EXECUTOR_MAC: &str = "migration_executor_mac";
57}
58
59/// Kind of TypeDB schema object tested against the migration-state contract.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum MigrationStateSchemaKind {
63    /// An entity type label.
64    Entity,
65    /// A relation type label.
66    Relation,
67    /// An attribute type label.
68    Attribute,
69    /// A relation-qualified role label in `relation:role` form.
70    Role,
71}
72
73static MIGRATION_STATE_SCHEMA: LazyLock<SchemaInfo> = LazyLock::new(build_migration_state_schema);
74
75/// Return TypeBridge's canonical migration-state schema.
76///
77/// The returned [`SchemaInfo`] is immutable and process-global. It includes
78/// every TypeBridge-owned migration entity, relation, attribute, ownership,
79/// and role. Consumers should use [`is_migration_state_type`] when they only
80/// need membership checks.
81pub fn migration_state_schema() -> &'static SchemaInfo {
82    &MIGRATION_STATE_SCHEMA
83}
84
85/// Return the entity label for the applied-migration projection.
86///
87/// This semantic accessor keeps compatibility aliases in language bindings
88/// tied to the same label constant used by the canonical schema and TypeDB
89/// queries.
90pub fn applied_migration_entity_label() -> &'static str {
91    labels::APPLIED_ENTITY
92}
93
94/// Return whether `label` is owned by TypeBridge's migration-state schema.
95///
96/// Membership is exact and kind-sensitive. Role labels use the qualified
97/// `relation:role` form because TypeDB role names are scoped by their relation.
98pub fn is_migration_state_type(kind: MigrationStateSchemaKind, label: &str) -> bool {
99    let schema = migration_state_schema();
100    match kind {
101        MigrationStateSchemaKind::Entity => schema.entities.contains_key(label),
102        MigrationStateSchemaKind::Relation => schema.relations.contains_key(label),
103        MigrationStateSchemaKind::Attribute => schema.attributes.contains_key(label),
104        MigrationStateSchemaKind::Role => {
105            let Some((relation_label, role_label)) = label.split_once(':') else {
106                return false;
107            };
108            schema
109                .relations
110                .get(relation_label)
111                .is_some_and(|relation| {
112                    relation
113                        .roles
114                        .iter()
115                        .any(|role| role.role_name == role_label)
116                })
117        }
118    }
119}
120
121fn build_migration_state_schema() -> SchemaInfo {
122    use labels::*;
123
124    let attribute_specs = [
125        (MIGRATION_ID, ValueType::String),
126        (APP_LABEL, ValueType::String),
127        (NAME, ValueType::String),
128        (APPLIED_AT, ValueType::DateTime),
129        (CHECKSUM, ValueType::String),
130        (RUN_ID, ValueType::String),
131        (DIRECTION, ValueType::String),
132        (STATUS, ValueType::String),
133        (STARTED_AT, ValueType::DateTime),
134        (FINISHED_AT, ValueType::DateTime),
135        (ERROR, ValueType::String),
136        (EXECUTOR_IP, ValueType::String),
137        (EXECUTOR_MAC, ValueType::String),
138    ];
139
140    let attributes = attribute_specs
141        .into_iter()
142        .map(|(label, value_type)| {
143            (
144                label.to_string(),
145                AttributeSchemaEntry::new(label, value_type),
146            )
147        })
148        .collect();
149
150    let entities = [
151        (
152            APPLIED_ENTITY,
153            vec![
154                owned_attribute(MIGRATION_ID, ValueType::String, true),
155                owned_attribute(APP_LABEL, ValueType::String, false),
156                owned_attribute(NAME, ValueType::String, false),
157                owned_attribute(APPLIED_AT, ValueType::DateTime, false),
158                owned_attribute(CHECKSUM, ValueType::String, false),
159            ],
160        ),
161        (
162            RUN_ENTITY,
163            vec![
164                owned_attribute(RUN_ID, ValueType::String, true),
165                owned_attribute(APP_LABEL, ValueType::String, false),
166                owned_attribute(NAME, ValueType::String, false),
167                owned_attribute(CHECKSUM, ValueType::String, false),
168                owned_attribute(DIRECTION, ValueType::String, false),
169                owned_attribute(STATUS, ValueType::String, false),
170                owned_attribute(STARTED_AT, ValueType::DateTime, false),
171                owned_attribute(FINISHED_AT, ValueType::DateTime, false),
172                owned_attribute(ERROR, ValueType::String, false),
173                owned_attribute(EXECUTOR_IP, ValueType::String, false),
174                owned_attribute(EXECUTOR_MAC, ValueType::String, false),
175            ],
176        ),
177    ]
178    .into_iter()
179    .map(|(label, owned_attributes)| {
180        (
181            label.to_string(),
182            EntitySchemaEntry {
183                type_name: label.to_string(),
184                is_abstract: false,
185                parent_type: None,
186                owned_attributes,
187                plays_cardinalities: BTreeMap::new(),
188                doc: None,
189                meta: Default::default(),
190            },
191        )
192    })
193    .collect();
194
195    SchemaInfo {
196        entities,
197        relations: BTreeMap::new(),
198        attributes,
199    }
200}
201
202fn owned_attribute(label: &str, value_type: ValueType, is_key: bool) -> OwnedAttributeEntry {
203    OwnedAttributeEntry {
204        attr_name: label.to_string(),
205        value_type,
206        annotations: if is_key {
207            vec![Annotation::Key]
208        } else {
209            Vec::new()
210        },
211        is_ordered: false,
212        doc: None,
213        meta: Default::default(),
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::collections::BTreeSet;
221
222    #[test]
223    fn descriptor_contains_the_complete_current_state_schema() {
224        let schema = migration_state_schema();
225
226        assert_eq!(
227            schema
228                .entities
229                .keys()
230                .map(String::as_str)
231                .collect::<BTreeSet<_>>(),
232            BTreeSet::from([labels::APPLIED_ENTITY, labels::RUN_ENTITY])
233        );
234        assert_eq!(
235            schema
236                .attributes
237                .keys()
238                .map(String::as_str)
239                .collect::<BTreeSet<_>>(),
240            BTreeSet::from([
241                labels::MIGRATION_ID,
242                labels::APP_LABEL,
243                labels::NAME,
244                labels::APPLIED_AT,
245                labels::CHECKSUM,
246                labels::RUN_ID,
247                labels::DIRECTION,
248                labels::STATUS,
249                labels::STARTED_AT,
250                labels::FINISHED_AT,
251                labels::ERROR,
252                labels::EXECUTOR_IP,
253                labels::EXECUTOR_MAC,
254            ])
255        );
256        assert!(schema.relations.is_empty());
257        assert!(
258            schema
259                .entities
260                .contains_key(applied_migration_entity_label())
261        );
262    }
263
264    #[test]
265    fn descriptor_preserves_value_types_and_key_ownerships() {
266        let schema = migration_state_schema();
267
268        assert_eq!(
269            schema.attributes[labels::APPLIED_AT].value_type,
270            ValueType::DateTime
271        );
272        assert_eq!(
273            schema.attributes[labels::STARTED_AT].value_type,
274            ValueType::DateTime
275        );
276        assert_eq!(
277            schema.attributes[labels::FINISHED_AT].value_type,
278            ValueType::DateTime
279        );
280
281        let applied = &schema.entities[labels::APPLIED_ENTITY];
282        let applied_key = applied
283            .owned_attributes
284            .iter()
285            .find(|attribute| attribute.attr_name == labels::MIGRATION_ID)
286            .unwrap();
287        assert_eq!(applied_key.annotations, vec![Annotation::Key]);
288
289        let run = &schema.entities[labels::RUN_ENTITY];
290        let run_key = run
291            .owned_attributes
292            .iter()
293            .find(|attribute| attribute.attr_name == labels::RUN_ID)
294            .unwrap();
295        assert_eq!(run_key.annotations, vec![Annotation::Key]);
296    }
297
298    #[test]
299    fn predicate_is_exact_and_kind_sensitive() {
300        assert!(is_migration_state_type(
301            MigrationStateSchemaKind::Entity,
302            labels::APPLIED_ENTITY
303        ));
304        assert!(is_migration_state_type(
305            MigrationStateSchemaKind::Attribute,
306            labels::CHECKSUM
307        ));
308        assert!(!is_migration_state_type(
309            MigrationStateSchemaKind::Attribute,
310            labels::APPLIED_ENTITY
311        ));
312        assert!(!is_migration_state_type(
313            MigrationStateSchemaKind::Entity,
314            "type_bridge_migration_custom"
315        ));
316        assert!(!is_migration_state_type(
317            MigrationStateSchemaKind::Role,
318            "unqualified-role"
319        ));
320    }
321
322    #[test]
323    fn canonical_schema_generates_the_existing_typeql_shape() {
324        let typeql = migration_state_schema().to_typeql().unwrap();
325
326        assert!(typeql.contains("attribute migration_applied_at, value datetime;"));
327        assert!(typeql.contains("attribute migration_started_at, value datetime;"));
328        assert!(typeql.contains("entity type_bridge_migration,"));
329        assert!(typeql.contains("owns migration_id @key"));
330        assert!(typeql.contains("entity type_bridge_migration_run,"));
331        assert!(typeql.contains("owns migration_run_id @key"));
332    }
333}