Skip to main content

type_bridge_migration/state/
typedb.rs

1//! TypeDB-backed [`MigrationStateStore`] implementation.
2//!
3//! [`TypeDbStateStore`] persists the established migration projection and run
4//! log over the ORM [`Database`][type_bridge_orm::Database] seam. Its per-type
5//! bootstrap definitions are rendered from the canonical migration-state
6//! [`SchemaInfo`][type_bridge_orm::schema::SchemaInfo], and its row queries use
7//! the same semantic label constants. Existing labels, value types, keys, and
8//! storage behavior remain unchanged.
9//!
10//! All work runs through [`Database::transaction_context`], mirroring the
11//! Phase-05 executor: open a context for the right [`TxType`], run the query,
12//! and commit write/schema contexts. No Python transaction crosses this
13//! boundary (invariant 4); only the public ORM `session` API is used
14//! (invariant 7).
15
16use std::sync::Arc;
17use std::sync::atomic::{AtomicBool, Ordering};
18
19use chrono::Utc;
20use type_bridge_orm::Database;
21use type_bridge_orm::OrmError;
22use type_bridge_orm::schema::{SchemaError, SchemaInfo};
23use type_bridge_orm::session::backend::{BoxFuture, QueryResult, TxType};
24
25use crate::state::schema::labels::{
26    APP_LABEL, APPLIED_AT, APPLIED_ENTITY, CHECKSUM, DIRECTION, ERROR, EXECUTOR_IP, EXECUTOR_MAC,
27    FINISHED_AT, MIGRATION_ID, NAME, RUN_ENTITY, RUN_ID, STARTED_AT, STATUS,
28};
29use crate::state::schema::migration_state_schema;
30use crate::state::{MigrationRunRecord, MigrationStateStore};
31use crate::{AppliedMigrationRecord, MigrationError, Result};
32
33/// Timestamp format mirroring Python's `strftime("%Y-%m-%dT%H:%M:%S.%f")`.
34///
35/// Python `%f` always emits exactly six digits (microseconds, zero-padded);
36/// chrono's `%6f` is the byte-identical equivalent. This is the format written
37/// into the `insert` TypeQL — `record_applied` (`state.py:250`).
38const APPLIED_AT_FORMAT: &str = "%Y-%m-%dT%H:%M:%S.%6f";
39
40/// TypeDB-backed migration state store over the ORM session seam.
41///
42/// Holds a shared [`Arc<Database>`] (the same handle the rest of the Rust ORM
43/// path uses) and opens its own transaction contexts per operation.
44pub struct TypeDbStateStore {
45    db: Arc<Database>,
46    /// Idempotency latch for [`ensure_schema`](Self::ensure_schema), mirroring
47    /// the Python `_schema_ensured` flag (`state.py:121`).
48    schema_ensured: AtomicBool,
49}
50
51impl TypeDbStateStore {
52    /// Construct a store bound to a shared database handle.
53    pub fn new(db: Arc<Database>) -> Self {
54        Self {
55            db,
56            schema_ensured: AtomicBool::new(false),
57        }
58    }
59
60    async fn type_exists(&self, type_name: &str) -> bool {
61        let check_query = format!(
62            "\n            match $t type {type_name};\n            fetch {{ \"exists\": true }};\n        "
63        );
64
65        match self.db.transaction_context(TxType::Read).await {
66            Ok(ctx) => match ctx.query(&check_query).await {
67                Ok(QueryResult::Documents(docs)) => !docs.is_empty(),
68                Ok(QueryResult::Rows(rows)) => !rows.is_empty(),
69                Ok(QueryResult::Ok) => false,
70                Err(_) => false,
71            },
72            Err(_) => false,
73        }
74    }
75
76    async fn ensure_type(&self, type_name: &str, define_typeql: &str) -> Result<()> {
77        if self.type_exists(type_name).await {
78            return Ok(());
79        }
80
81        let ctx = self
82            .db
83            .transaction_context(TxType::Schema)
84            .await
85            .map_err(map_orm_error)?;
86        match ctx.query(define_typeql).await {
87            Ok(_) => {
88                ctx.commit().await.map_err(map_orm_error)?;
89                Ok(())
90            }
91            Err(error) => {
92                let _ = ctx.rollback().await;
93                if self.type_exists(type_name).await {
94                    Ok(())
95                } else {
96                    Err(map_orm_error(error))
97                }
98            }
99        }
100    }
101
102    async fn query_documents(&self, query: &str) -> Result<Vec<serde_json::Value>> {
103        let ctx = self
104            .db
105            .transaction_context(TxType::Read)
106            .await
107            .map_err(map_orm_error)?;
108        let result = ctx.query(query).await.map_err(map_orm_error)?;
109        Ok(query_result_values(result))
110    }
111}
112
113/// Format the current UTC time as the Python-compatible applied-at string.
114///
115/// Isolated as a pure function (parameterised on the instant) so the
116/// `%Y-%m-%dT%H:%M:%S.%6f` parity with Python's `%f` is unit-testable without a
117/// live clock or TypeDB.
118fn format_applied_at(now: chrono::DateTime<Utc>) -> String {
119    now.format(APPLIED_AT_FORMAT).to_string()
120}
121
122fn query_result_values(result: QueryResult) -> Vec<serde_json::Value> {
123    match result {
124        QueryResult::Documents(docs) => docs,
125        QueryResult::Rows(rows) => rows,
126        QueryResult::Ok => Vec::new(),
127    }
128}
129
130fn typeql_string_literal(value: &str) -> String {
131    let escaped = value
132        .replace('\\', "\\\\")
133        .replace('"', "\\\"")
134        .replace('\n', "\\n")
135        .replace('\r', "\\r")
136        .replace('\t', "\\t");
137    format!("\"{escaped}\"")
138}
139
140/// Map an ORM-layer error into the migration error hierarchy.
141fn map_orm_error(error: OrmError) -> MigrationError {
142    MigrationError::State {
143        message: error.to_string(),
144    }
145}
146
147fn map_schema_error(error: SchemaError) -> MigrationError {
148    MigrationError::State {
149        message: error.to_string(),
150    }
151}
152
153/// Unwrap a single fetched field into its scalar string.
154///
155/// The Phase-05 ORM backend renders a `fetch { "k": $attr }` document so that
156/// each attribute-bound variable becomes a bare JSON scalar (the attribute's
157/// value), not a `{"value": ...}` wrapper (typedb-driver `json_value`,
158/// `concept_document.rs`). The original Python `_extract_value`
159/// (`state.py:221-237`) defensively handled BOTH shapes — a bare scalar and a
160/// `{"value": ...}` object — so this parser does the same: prefer the inner
161/// `"value"` when the field is an object carrying one, otherwise take the
162/// scalar directly. Returns `None` for a missing/null field or an object with
163/// no usable `"value"`.
164fn extract_value(doc: &serde_json::Value, key: &str) -> Option<String> {
165    let value = doc.get(key)?;
166    extract_scalar(value)
167}
168
169/// Reduce a fetched JSON value to its string form, unwrapping a `{"value":
170/// ...}` envelope when present (the `_extract_value` dict branch).
171fn extract_scalar(value: &serde_json::Value) -> Option<String> {
172    match value {
173        serde_json::Value::Null => None,
174        serde_json::Value::Object(map) => map.get("value").and_then(extract_scalar),
175        serde_json::Value::String(s) => Some(s.clone()),
176        serde_json::Value::Bool(b) => Some(b.to_string()),
177        serde_json::Value::Number(n) => Some(n.to_string()),
178        serde_json::Value::Array(_) => None,
179    }
180}
181
182/// Parse a list of TypeDB fetch documents into applied migration records.
183///
184/// Pure and TypeDB-free so the document-shape contract is unit-testable. Each
185/// document is the `fetch { "app": ..., "name": ..., "applied": ...,
186/// "checksum": ... }` shape from the `load_applied` query (ported from
187/// `state.py:179-191`). A document missing any of `app` / `name` / `checksum`
188/// is skipped, matching the Python `if all([...])` guard (`state.py:206`);
189/// `applied` is carried through as the optional ISO string.
190pub fn parse_applied_documents(
191    values: &[serde_json::Value],
192) -> Result<Vec<AppliedMigrationRecord>> {
193    let mut records = Vec::with_capacity(values.len());
194    for doc in values {
195        let (Some(app_label), Some(name), Some(checksum)) = (
196            extract_value(doc, "app"),
197            extract_value(doc, "name"),
198            extract_value(doc, "checksum"),
199        ) else {
200            // Skip incomplete rows (mirrors the Python `all([...])` guard).
201            continue;
202        };
203        let applied_at = extract_value(doc, "applied");
204        records.push(AppliedMigrationRecord {
205            app_label,
206            name,
207            checksum,
208            applied_at,
209        });
210    }
211    Ok(records)
212}
213
214/// Parse TypeDB fetch documents into migration run-log records.
215pub fn parse_run_documents(values: &[serde_json::Value]) -> Result<Vec<MigrationRunRecord>> {
216    let mut records = Vec::with_capacity(values.len());
217    for doc in values {
218        let (
219            Some(run_id),
220            Some(app_label),
221            Some(name),
222            Some(checksum),
223            Some(direction),
224            Some(status),
225            Some(started_at),
226        ) = (
227            extract_value(doc, "run_id"),
228            extract_value(doc, "app"),
229            extract_value(doc, "name"),
230            extract_value(doc, "checksum"),
231            extract_value(doc, "direction"),
232            extract_value(doc, "status"),
233            extract_value(doc, "started"),
234        )
235        else {
236            continue;
237        };
238        records.push(MigrationRunRecord {
239            run_id,
240            app_label,
241            name,
242            checksum,
243            direction,
244            status,
245            started_at,
246            finished_at: None,
247            error: None,
248            executor_ip: None,
249            executor_mac: None,
250        });
251    }
252    Ok(records)
253}
254
255fn optional_run_field_query(attribute: &str, alias: &str) -> String {
256    format!(
257        "\nmatch\n$r isa {RUN_ENTITY},\n    has {RUN_ID} $run_id,\n    has {attribute} ${alias};\nfetch {{\n    \"run_id\": $run_id,\n    \"{alias}\": ${alias}\n}};\n"
258    )
259}
260
261fn merge_optional_run_field(
262    runs: &mut [MigrationRunRecord],
263    docs: &[serde_json::Value],
264    target: &str,
265    source: &str,
266) {
267    for doc in docs {
268        let (Some(run_id), Some(value)) =
269            (extract_value(doc, "run_id"), extract_value(doc, source))
270        else {
271            continue;
272        };
273        let Some(run) = runs.iter_mut().find(|run| run.run_id == run_id) else {
274            continue;
275        };
276        match target {
277            "finished_at" => run.finished_at = Some(value),
278            "error" => run.error = Some(value),
279            "executor_ip" => run.executor_ip = Some(value),
280            "executor_mac" => run.executor_mac = Some(value),
281            _ => {}
282        }
283    }
284}
285
286fn run_insert_query(record: &MigrationRunRecord) -> String {
287    let mut fields = vec![
288        format!("    has {RUN_ID} {}", typeql_string_literal(&record.run_id)),
289        format!(
290            "    has {APP_LABEL} {}",
291            typeql_string_literal(&record.app_label)
292        ),
293        format!("    has {NAME} {}", typeql_string_literal(&record.name)),
294        format!(
295            "    has {CHECKSUM} {}",
296            typeql_string_literal(&record.checksum)
297        ),
298        format!(
299            "    has {DIRECTION} {}",
300            typeql_string_literal(&record.direction)
301        ),
302        format!("    has {STATUS} {}", typeql_string_literal(&record.status)),
303        format!("    has {STARTED_AT} {}", record.started_at),
304    ];
305
306    if let Some(finished_at) = &record.finished_at {
307        fields.push(format!("    has {FINISHED_AT} {finished_at}"));
308    }
309    if let Some(error) = &record.error {
310        fields.push(format!("    has {ERROR} {}", typeql_string_literal(error)));
311    }
312    if let Some(executor_ip) = &record.executor_ip {
313        fields.push(format!(
314            "    has {EXECUTOR_IP} {}",
315            typeql_string_literal(executor_ip)
316        ));
317    }
318    if let Some(executor_mac) = &record.executor_mac {
319        fields.push(format!(
320            "    has {EXECUTOR_MAC} {}",
321            typeql_string_literal(executor_mac)
322        ));
323    }
324
325    format!("\ninsert $r isa {RUN_ENTITY},\n{};\n", fields.join(",\n"))
326}
327
328impl MigrationStateStore for TypeDbStateStore {
329    fn ensure_schema(&self) -> BoxFuture<'_, Result<()>> {
330        Box::pin(async move {
331            if self.schema_ensured.load(Ordering::Acquire) {
332                return Ok(());
333            }
334
335            let state_schema = migration_state_schema();
336
337            for (name, attribute) in &state_schema.attributes {
338                let mut definition = SchemaInfo::default();
339                definition
340                    .attributes
341                    .insert(name.clone(), attribute.clone());
342                let define = definition.to_typeql().map_err(map_schema_error)?;
343                self.ensure_type(name, &define).await?;
344            }
345
346            for (name, entity) in &state_schema.entities {
347                let mut definition = SchemaInfo::default();
348                definition.entities.insert(name.clone(), entity.clone());
349                let define = definition.to_typeql().map_err(map_schema_error)?;
350                self.ensure_type(name, &define).await?;
351            }
352
353            for (name, relation) in &state_schema.relations {
354                let mut definition = SchemaInfo::default();
355                definition.relations.insert(name.clone(), relation.clone());
356                let define = definition.to_typeql().map_err(map_schema_error)?;
357                self.ensure_type(name, &define).await?;
358            }
359
360            self.schema_ensured.store(true, Ordering::Release);
361            Ok(())
362        })
363    }
364
365    fn load_applied(&self) -> BoxFuture<'_, Result<Vec<AppliedMigrationRecord>>> {
366        Box::pin(async move {
367            self.ensure_schema().await?;
368
369            // Ported verbatim from state.py:179-191.
370            let query = format!(
371                "\nmatch\n$m isa {APPLIED_ENTITY},\n    has {APP_LABEL} $app,\n    has {NAME} $name,\n    has {APPLIED_AT} $applied,\n    has {CHECKSUM} $checksum;\nfetch {{\n    \"app\": $app,\n    \"name\": $name,\n    \"applied\": $applied,\n    \"checksum\": $checksum\n}};\n"
372            );
373
374            let ctx = self
375                .db
376                .transaction_context(TxType::Read)
377                .await
378                .map_err(map_orm_error)?;
379            let result = ctx.query(&query).await.map_err(map_orm_error)?;
380
381            let values = query_result_values(result);
382            parse_applied_documents(&values)
383        })
384    }
385
386    fn load_runs(&self) -> BoxFuture<'_, Result<Vec<MigrationRunRecord>>> {
387        Box::pin(async move {
388            self.ensure_schema().await?;
389
390            let query = format!(
391                "\nmatch\n$r isa {RUN_ENTITY},\n    has {RUN_ID} $run_id,\n    has {APP_LABEL} $app,\n    has {NAME} $name,\n    has {CHECKSUM} $checksum,\n    has {DIRECTION} $direction,\n    has {STATUS} $status,\n    has {STARTED_AT} $started;\nfetch {{\n    \"run_id\": $run_id,\n    \"app\": $app,\n    \"name\": $name,\n    \"checksum\": $checksum,\n    \"direction\": $direction,\n    \"status\": $status,\n    \"started\": $started\n}};\n"
392            );
393            let mut runs = parse_run_documents(&self.query_documents(&query).await?)?;
394
395            let finished_query = optional_run_field_query(FINISHED_AT, "finished");
396            let finished_docs = self.query_documents(&finished_query).await?;
397            merge_optional_run_field(&mut runs, &finished_docs, "finished_at", "finished");
398
399            let error_query = optional_run_field_query(ERROR, "error");
400            let error_docs = self.query_documents(&error_query).await?;
401            merge_optional_run_field(&mut runs, &error_docs, "error", "error");
402
403            let ip_query = optional_run_field_query(EXECUTOR_IP, "executor_ip");
404            let ip_docs = self.query_documents(&ip_query).await?;
405            merge_optional_run_field(&mut runs, &ip_docs, "executor_ip", "executor_ip");
406
407            let mac_query = optional_run_field_query(EXECUTOR_MAC, "executor_mac");
408            let mac_docs = self.query_documents(&mac_query).await?;
409            merge_optional_run_field(&mut runs, &mac_docs, "executor_mac", "executor_mac");
410
411            Ok(runs)
412        })
413    }
414
415    fn record_applied(&self, record: AppliedMigrationRecord) -> BoxFuture<'_, Result<()>> {
416        Box::pin(async move {
417            self.ensure_schema().await?;
418
419            // Rust stamps applied_at when the record carries none, mirroring
420            // state.py:249-250 (`datetime.now(UTC).strftime(...)`).
421            let applied_at = record
422                .applied_at
423                .clone()
424                .unwrap_or_else(|| format_applied_at(Utc::now()));
425
426            let migration_id = format!("{}:{}", record.app_label, record.name);
427            let migration_id = typeql_string_literal(&migration_id);
428            let app = typeql_string_literal(&record.app_label);
429            let name = typeql_string_literal(&record.name);
430            let checksum = typeql_string_literal(&record.checksum);
431
432            // Idempotent replace: delete any existing row for this migration_id
433            // (the @key) before inserting, so re-recording an already-applied
434            // migration updates in place instead of failing the @key constraint.
435            // This gives the TypeDB store the same dedup semantics the in-memory
436            // store has behind the shared seam.
437            let delete_existing = format!(
438                "\nmatch\n$m isa {APPLIED_ENTITY},\n    has {MIGRATION_ID} {migration_id};\ndelete $m;\n"
439            );
440
441            // Field set + storage schema match state.py:253-260. `applied_at` is
442            // emitted unquoted (a TypeQL datetime literal); every other field quoted.
443            let insert = format!(
444                "\ninsert $m isa {APPLIED_ENTITY},\n    has {MIGRATION_ID} {migration_id},\n    has {APP_LABEL} {app},\n    has {NAME} {name},\n    has {APPLIED_AT} {applied_at},\n    has {CHECKSUM} {checksum};\n",
445            );
446
447            let ctx = self
448                .db
449                .transaction_context(TxType::Write)
450                .await
451                .map_err(map_orm_error)?;
452            ctx.query(&delete_existing).await.map_err(map_orm_error)?;
453            ctx.query(&insert).await.map_err(map_orm_error)?;
454            ctx.commit().await.map_err(map_orm_error)?;
455            Ok(())
456        })
457    }
458
459    fn record_unapplied<'a>(
460        &'a self,
461        app_label: &'a str,
462        name: &'a str,
463    ) -> BoxFuture<'a, Result<()>> {
464        Box::pin(async move {
465            self.ensure_schema().await?;
466
467            // Storage schema matches state.py:288-293; the delete clause uses the
468            // TypeDB 3.x form `delete $m;` (the older `delete $m isa <type>;` that
469            // the Python code carried is a parse error on TypeDB 3.x).
470            let app_label = typeql_string_literal(app_label);
471            let name = typeql_string_literal(name);
472            let query = format!(
473                "\nmatch\n$m isa {APPLIED_ENTITY},\n    has {APP_LABEL} {app_label},\n    has {NAME} {name};\ndelete $m;\n"
474            );
475
476            let ctx = self
477                .db
478                .transaction_context(TxType::Write)
479                .await
480                .map_err(map_orm_error)?;
481            ctx.query(&query).await.map_err(map_orm_error)?;
482            ctx.commit().await.map_err(map_orm_error)?;
483            Ok(())
484        })
485    }
486
487    fn record_run(&self, record: MigrationRunRecord) -> BoxFuture<'_, Result<()>> {
488        Box::pin(async move {
489            self.ensure_schema().await?;
490
491            let run_id = typeql_string_literal(&record.run_id);
492            let delete_existing =
493                format!("\nmatch\n$r isa {RUN_ENTITY},\n    has {RUN_ID} {run_id};\ndelete $r;\n");
494            let insert = run_insert_query(&record);
495
496            let ctx = self
497                .db
498                .transaction_context(TxType::Write)
499                .await
500                .map_err(map_orm_error)?;
501            ctx.query(&delete_existing).await.map_err(map_orm_error)?;
502            ctx.query(&insert).await.map_err(map_orm_error)?;
503            ctx.commit().await.map_err(map_orm_error)?;
504            Ok(())
505        })
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use chrono::{TimeZone, Timelike};
513
514    // ── document parsing (no TypeDB) ────────────────────────────────────────
515    //
516    // Feeds a hand-built fetch-document list in the REAL TypeDB shape: each
517    // attribute-bound variable renders as a bare JSON scalar (typedb-driver
518    // 3.8.1 `json_value`), NOT a `{"value": ...}` wrapper. This is the P0
519    // silent-failure guard — a shape mismatch yields empty state with no error.
520
521    #[test]
522    fn parse_applied_documents_extracts_bare_scalar_fields() {
523        let docs = vec![serde_json::json!({
524            "app": "myapp",
525            "name": "0001_initial",
526            "applied": "2026-06-05T00:00:00.000000000",
527            "checksum": "abc123"
528        })];
529
530        let records = parse_applied_documents(&docs).unwrap();
531        assert_eq!(records.len(), 1);
532        assert_eq!(records[0].app_label, "myapp");
533        assert_eq!(records[0].name, "0001_initial");
534        assert_eq!(records[0].checksum, "abc123");
535        assert_eq!(
536            records[0].applied_at.as_deref(),
537            Some("2026-06-05T00:00:00.000000000")
538        );
539    }
540
541    #[test]
542    fn parse_applied_documents_also_unwraps_value_envelope() {
543        // Defensive: the original `_extract_value` accepted a `{"value": ...}`
544        // object form too. Mixed shapes in one list must both parse.
545        let docs = vec![serde_json::json!({
546            "app": {"value": "myapp"},
547            "name": {"value": "0002_next"},
548            "applied": {"value": "2026-06-05T01:02:03.000000000"},
549            "checksum": {"value": "def456"}
550        })];
551
552        let records = parse_applied_documents(&docs).unwrap();
553        assert_eq!(records.len(), 1);
554        assert_eq!(records[0].app_label, "myapp");
555        assert_eq!(records[0].name, "0002_next");
556        assert_eq!(records[0].checksum, "def456");
557        assert_eq!(
558            records[0].applied_at.as_deref(),
559            Some("2026-06-05T01:02:03.000000000")
560        );
561    }
562
563    #[test]
564    fn parse_applied_documents_empty_list_is_empty() {
565        let records = parse_applied_documents(&[]).unwrap();
566        assert!(records.is_empty());
567    }
568
569    #[test]
570    fn parse_applied_documents_skips_incomplete_rows() {
571        // Missing `checksum` → skipped, mirroring the Python `all([...])` guard.
572        let docs = vec![
573            serde_json::json!({
574                "app": "myapp",
575                "name": "0001_initial",
576                "applied": "2026-06-05T00:00:00.000000000"
577            }),
578            serde_json::json!({
579                "app": "myapp",
580                "name": "0002_next",
581                "applied": "2026-06-05T00:00:00.000000000",
582                "checksum": "ok"
583            }),
584        ];
585
586        let records = parse_applied_documents(&docs).unwrap();
587        assert_eq!(records.len(), 1);
588        assert_eq!(records[0].name, "0002_next");
589    }
590
591    #[test]
592    fn parse_applied_documents_carries_missing_applied_as_none() {
593        let docs = vec![serde_json::json!({
594            "app": "myapp",
595            "name": "0001_initial",
596            "checksum": "abc123"
597        })];
598
599        let records = parse_applied_documents(&docs).unwrap();
600        assert_eq!(records.len(), 1);
601        assert!(records[0].applied_at.is_none());
602    }
603
604    #[test]
605    fn parse_run_documents_extracts_required_fields() {
606        let docs = vec![serde_json::json!({
607            "run_id": "run-1",
608            "app": "app",
609            "name": "0001_initial",
610            "checksum": "abc123",
611            "direction": "apply",
612            "status": "started",
613            "started": "2026-06-05T00:00:00.000000"
614        })];
615
616        let records = parse_run_documents(&docs).unwrap();
617
618        assert_eq!(records.len(), 1);
619        assert_eq!(records[0].run_id, "run-1");
620        assert_eq!(records[0].direction, "apply");
621        assert_eq!(records[0].status, "started");
622        assert_eq!(records[0].finished_at, None);
623    }
624
625    #[test]
626    fn merge_optional_run_field_updates_matching_record_only() {
627        let mut records = vec![MigrationRunRecord {
628            run_id: "run-1".to_string(),
629            app_label: "app".to_string(),
630            name: "0001_initial".to_string(),
631            checksum: "abc123".to_string(),
632            direction: "apply".to_string(),
633            status: "started".to_string(),
634            started_at: "2026-06-05T00:00:00.000000".to_string(),
635            finished_at: None,
636            error: None,
637            executor_ip: None,
638            executor_mac: None,
639        }];
640        let docs = vec![serde_json::json!({
641            "run_id": "run-1",
642            "finished": "2026-06-05T00:00:01.000000"
643        })];
644
645        merge_optional_run_field(&mut records, &docs, "finished_at", "finished");
646
647        assert_eq!(
648            records[0].finished_at.as_deref(),
649            Some("2026-06-05T00:00:01.000000")
650        );
651    }
652
653    #[test]
654    fn typeql_string_literal_escapes_user_controlled_text() {
655        assert_eq!(typeql_string_literal("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
656    }
657
658    #[test]
659    fn run_insert_query_includes_optional_fields_when_present() {
660        let record = MigrationRunRecord {
661            run_id: "run-1".to_string(),
662            app_label: "app".to_string(),
663            name: "0001_initial".to_string(),
664            checksum: "abc123".to_string(),
665            direction: "apply".to_string(),
666            status: "failed".to_string(),
667            started_at: "2026-06-05T00:00:00.000000".to_string(),
668            finished_at: Some("2026-06-05T00:00:01.000000".to_string()),
669            error: Some("quote: \"boom\"".to_string()),
670            executor_ip: Some("127.0.0.1".to_string()),
671            executor_mac: Some("00:11:22:33:44:55".to_string()),
672        };
673
674        let query = run_insert_query(&record);
675
676        assert!(query.contains("has migration_run_id \"run-1\""));
677        assert!(query.contains("has migration_finished_at 2026-06-05T00:00:01.000000"));
678        assert!(query.contains("has migration_error \"quote: \\\"boom\\\"\""));
679        assert!(query.contains("has migration_executor_ip \"127.0.0.1\""));
680        assert!(query.contains("has migration_executor_mac \"00:11:22:33:44:55\""));
681    }
682
683    // ── timestamp parity (no TypeDB) ────────────────────────────────────────
684    //
685    // Asserts the Rust format string is byte-identical to Python's
686    // `strftime("%Y-%m-%dT%H:%M:%S.%f")`: 6 fractional digits, zero-padded,
687    // no timezone suffix.
688
689    #[test]
690    fn format_applied_at_matches_python_strftime() {
691        // 2026-06-05 14:09:08.123456 UTC.
692        let dt = Utc
693            .with_ymd_and_hms(2026, 6, 5, 14, 9, 8)
694            .unwrap()
695            .with_nanosecond(123_456_000)
696            .unwrap();
697        assert_eq!(format_applied_at(dt), "2026-06-05T14:09:08.123456");
698    }
699
700    #[test]
701    fn format_applied_at_zero_pads_microseconds() {
702        // Python `%f` zero-pads to exactly 6 digits; a sub-microsecond value
703        // truncates and pads identically under chrono `%6f`.
704        let dt = Utc
705            .with_ymd_and_hms(2026, 1, 2, 3, 4, 5)
706            .unwrap()
707            .with_nanosecond(7_000)
708            .unwrap();
709        // 7000 ns = 7 microseconds → ".000007".
710        assert_eq!(format_applied_at(dt), "2026-01-02T03:04:05.000007");
711    }
712}