1use 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
33const APPLIED_AT_FORMAT: &str = "%Y-%m-%dT%H:%M:%S.%6f";
39
40pub struct TypeDbStateStore {
45 db: Arc<Database>,
46 schema_ensured: AtomicBool,
49}
50
51impl TypeDbStateStore {
52 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
113fn 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
140fn 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
153fn extract_value(doc: &serde_json::Value, key: &str) -> Option<String> {
165 let value = doc.get(key)?;
166 extract_scalar(value)
167}
168
169fn 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
182pub 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 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
214pub 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 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 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 let delete_existing = format!(
438 "\nmatch\n$m isa {APPLIED_ENTITY},\n has {MIGRATION_ID} {migration_id};\ndelete $m;\n"
439 );
440
441 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 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 #[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 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 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 #[test]
690 fn format_applied_at_matches_python_strftime() {
691 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 let dt = Utc
705 .with_ymd_and_hms(2026, 1, 2, 3, 4, 5)
706 .unwrap()
707 .with_nanosecond(7_000)
708 .unwrap();
709 assert_eq!(format_applied_at(dt), "2026-01-02T03:04:05.000007");
711 }
712}