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