View raw

1 open Lwt.Infix 2 open Hito_app 3 4 exception Corrupt of Codec.error 5 exception Import_corrupt of Import_codec.error 6 7 type t = { pool : (Caqti_lwt.connection, Caqti_error.t) Caqti_lwt_unix.Pool.t } 8 9 (* --- request definitions --- *) 10 11 module Q = struct 12 open Caqti_request.Infix 13 open Caqti_type.Std 14 15 let insert_trainee = 16 (t3 string string string ->. unit) 17 "INSERT INTO trainee (id, username, credential) VALUES (?, ?, ?)" 18 19 let trainee_by_username = 20 (string ->? t3 string string string) 21 "SELECT id, username, credential FROM trainee WHERE username = ?" 22 23 let trainee_by_id = 24 (string ->? t3 string string string) 25 "SELECT id, username, credential FROM trainee WHERE id = ?" 26 27 let next_trainee = 28 (unit ->! int) 29 "SELECT COALESCE(MAX(CAST(SUBSTR(id, 2) AS INTEGER)), 0) + 1 FROM \ 30 trainee WHERE id GLOB 't[0-9]*'" 31 32 let update_username = 33 (t2 string string ->. unit) "UPDATE trainee SET username = ? WHERE id = ?" 34 35 let update_credential = 36 (t2 string string ->. unit) "UPDATE trainee SET credential = ? WHERE id = ?" 37 38 let get_active = 39 (string ->? string) 40 "SELECT routine_id FROM active_routine WHERE trainee_id = ?" 41 42 let set_active = 43 (t2 string string ->. unit) 44 "INSERT INTO active_routine (trainee_id, routine_id) VALUES (?, ?) ON \ 45 CONFLICT (trainee_id) DO UPDATE SET routine_id = excluded.routine_id" 46 47 let get_in_progress = 48 (string ->? string) "SELECT encoded FROM in_progress WHERE trainee_id = ?" 49 50 let set_in_progress = 51 (t2 string string ->. unit) 52 "INSERT INTO in_progress (trainee_id, encoded) VALUES (?, ?) ON CONFLICT \ 53 (trainee_id) DO UPDATE SET encoded = excluded.encoded" 54 55 let clear_in_progress = 56 (string ->. unit) "DELETE FROM in_progress WHERE trainee_id = ?" 57 58 let next_seq = 59 (string ->! int) 60 "SELECT COALESCE(MAX(seq), 0) + 1 FROM workout WHERE trainee_id = ?" 61 62 let insert_workout = 63 (t4 string string int string ->. unit) 64 "INSERT INTO workout (id, trainee_id, seq, encoded) VALUES (?, ?, ?, ?)" 65 66 let workout_by_id = 67 (t2 string string ->? string) 68 "SELECT encoded FROM workout WHERE trainee_id = ? AND id = ?" 69 70 let update_workout = 71 (t3 string string string ->. unit) 72 "UPDATE workout SET encoded = ? WHERE trainee_id = ? AND id = ?" 73 74 let delete_workout = 75 (t2 string string ->. unit) 76 "DELETE FROM workout WHERE trainee_id = ? AND id = ?" 77 78 let history = 79 (string ->* t2 string string) 80 "SELECT id, encoded FROM workout WHERE trainee_id = ? ORDER BY seq DESC" 81 82 let next_feedback_seq = 83 (string ->! int) 84 "SELECT COALESCE(MAX(seq), 0) + 1 FROM feedback WHERE trainee_id = ?" 85 86 let insert_feedback = 87 (t3 string int string ->. unit) 88 "INSERT INTO feedback (trainee_id, seq, encoded) VALUES (?, ?, ?)" 89 90 let feedback = 91 (string ->* string) 92 "SELECT encoded FROM feedback WHERE trainee_id = ? ORDER BY seq DESC" 93 94 let next_app_feedback_seq = 95 (string ->! int) 96 "SELECT COALESCE(MAX(seq), 0) + 1 FROM app_feedback WHERE trainee_id = ?" 97 98 let insert_app_feedback = 99 (t4 string int string string ->. unit) 100 "INSERT INTO app_feedback (trainee_id, seq, submitted_at, message) \ 101 VALUES (?, ?, ?, ?)" 102 103 let app_feedback = 104 (unit ->* t7 string int string string string int int) 105 "SELECT f.trainee_id, f.seq, t.username, f.submitted_at, f.message, \ 106 (SELECT COUNT(*) FROM app_feedback c WHERE c.trainee_id = \ 107 f.trainee_id), (SELECT COUNT(*) FROM app_feedback_vote v WHERE \ 108 v.feedback_trainee_id = f.trainee_id AND v.feedback_seq = f.seq) FROM \ 109 app_feedback f JOIN trainee t ON t.id = f.trainee_id ORDER BY 7 DESC, \ 110 f.submitted_at DESC, f.trainee_id, f.seq DESC" 111 112 let viewer_votes = 113 (string ->* t2 string int) 114 "SELECT feedback_trainee_id, feedback_seq FROM app_feedback_vote WHERE \ 115 voter_id = ?" 116 117 let feedback_owner = 118 (t2 string int ->? string) 119 "SELECT trainee_id FROM app_feedback WHERE trainee_id = ? AND seq = ?" 120 121 let vote_exists = 122 (t3 string int string ->? int) 123 "SELECT 1 FROM app_feedback_vote WHERE feedback_trainee_id = ? AND \ 124 feedback_seq = ? AND voter_id = ?" 125 126 let insert_vote = 127 (t3 string int string ->. unit) 128 "INSERT INTO app_feedback_vote (feedback_trainee_id, feedback_seq, \ 129 voter_id) VALUES (?, ?, ?)" 130 131 let update_app_feedback = 132 (t3 string string int ->. unit) 133 "UPDATE app_feedback SET message = ? WHERE trainee_id = ? AND seq = ?" 134 135 let delete_feedback_votes = 136 (t2 string int ->. unit) 137 "DELETE FROM app_feedback_vote WHERE feedback_trainee_id = ? AND \ 138 feedback_seq = ?" 139 140 let delete_app_feedback = 141 (t2 string int ->. unit) 142 "DELETE FROM app_feedback WHERE trainee_id = ? AND seq = ?" 143 144 (* --- workout import --- *) 145 146 let upsert_import_batch = 147 (t6 string string string string string string ->. unit) 148 "INSERT INTO import_batch (trainee_id, batch_id, imported_at, source, \ 149 fingerprint, snapshot) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT \ 150 (trainee_id, batch_id) DO UPDATE SET imported_at = \ 151 excluded.imported_at, source = excluded.source, fingerprint = \ 152 excluded.fingerprint, snapshot = excluded.snapshot" 153 154 let import_batch_by_id = 155 (t2 string string ->? string) 156 "SELECT snapshot FROM import_batch WHERE trainee_id = ? AND batch_id = ?" 157 158 let import_batches = 159 (string ->* string) 160 "SELECT snapshot FROM import_batch WHERE trainee_id = ? ORDER BY \ 161 imported_at DESC, batch_id DESC" 162 163 let delete_import_batch = 164 (t2 string string ->. unit) 165 "DELETE FROM import_batch WHERE trainee_id = ? AND batch_id = ?" 166 167 let import_batch_exists = 168 (t2 string string ->? int) 169 "SELECT 1 FROM import_batch WHERE trainee_id = ? AND batch_id = ?" 170 171 let upsert_imported_record = 172 (t2 (t4 string string string int) (t4 string string string string) ->. unit) 173 "INSERT INTO imported_record (trainee_id, workout_id, batch_id, \ 174 revision, title, started_at, ended_at, snapshot) VALUES (?, ?, ?, ?, ?, \ 175 ?, ?, ?) ON CONFLICT (trainee_id, workout_id) DO UPDATE SET batch_id = \ 176 excluded.batch_id, revision = excluded.revision, title = \ 177 excluded.title, started_at = excluded.started_at, ended_at = \ 178 excluded.ended_at, snapshot = excluded.snapshot" 179 180 let imported_records = 181 (string ->* string) 182 "SELECT snapshot FROM imported_record WHERE trainee_id = ? ORDER BY \ 183 ended_at DESC" 184 end 185 186 (* --- pool helper --- *) 187 188 let run t f = Caqti_lwt_unix.Pool.use f t.pool >>= Caqti_lwt.or_fail 189 190 let q_exec t query data = 191 run t (fun (module Db : Caqti_lwt.CONNECTION) -> Db.exec query data) 192 193 let q_find t query data = 194 run t (fun (module Db : Caqti_lwt.CONNECTION) -> Db.find query data) 195 196 let q_find_opt t query data = 197 run t (fun (module Db : Caqti_lwt.CONNECTION) -> Db.find_opt query data) 198 199 let q_collect_list t query data = 200 run t (fun (module Db : Caqti_lwt.CONNECTION) -> Db.collect_list query data) 201 202 (* SQLite admits one writer at a time. Without a busy timeout a second writer 203 fails at once with SQLITE_BUSY; with one it waits its turn. The driver reads 204 [busy_timeout] (milliseconds) from the URI, so a caller who sets it keeps 205 their value and everyone else gets a five-second wait, which serialises 206 overlapping transactions instead of failing them. *) 207 let default_busy_timeout_ms = 5_000 208 209 let with_busy_timeout uri = 210 match Uri.get_query_param uri "busy_timeout" with 211 | Some _ -> uri 212 | None -> 213 Uri.add_query_param' uri 214 ("busy_timeout", string_of_int default_busy_timeout_ms) 215 216 let connect ?pool_config uri = 217 match 218 Caqti_lwt_unix.connect_pool ?pool_config 219 (with_busy_timeout (Uri.of_string uri)) 220 with 221 | Error e -> Lwt.return (Error (e :> Caqti_error.t)) 222 | Ok pool -> 223 let open Lwt_result.Syntax in 224 let* () = Caqti_lwt_unix.Pool.use Migrations.apply pool in 225 Lwt_result.return { pool } 226 227 (* --- decoding stored workouts --- *) 228 229 let find_routine_by_name = Catalog.find_by_name 230 231 (* A stored row the codec refuses is corrupt: a storage invariant failed, not a 232 user error. A targeted read of one record raises [Corrupt], because the caller 233 asked for that record and silently answering "not found" would lose it. A 234 list read quarantines the row instead: it is skipped and reported here, so 235 one bad row never hides a trainee's whole history. *) 236 let warn_corrupt ~what ~id detail = 237 Printf.eprintf "hito: corrupt %s %S skipped: %s\n%!" what id detail 238 239 let decode encoded = 240 match Codec.decode_workout ~find_routine:find_routine_by_name encoded with 241 | Ok workout -> workout 242 | Error e -> raise (Corrupt e) 243 244 (* Decode one history row, or quarantine it. *) 245 let decode_history_row (wid, encoded) = 246 match Codec.decode_workout ~find_routine:find_routine_by_name encoded with 247 | Ok workout -> Some { Repository.id = Repository.workout_id wid; workout } 248 | Error e -> 249 warn_corrupt ~what:"workout" ~id:wid 250 (Format.asprintf "%a" Codec.pp_error e); 251 None 252 253 (* --- accounts --- *) 254 255 (* Create the account in one transaction: check the name is free, take the next 256 id, and insert, all under SQLite's write lock. The id comes from the table 257 itself on every call, never from a process-local counter, so two concurrent 258 registrations cannot mint the same id and a file edited outside this process 259 cannot drift from an in-memory count. The UNIQUE constraint on username is 260 the final arbiter; the transaction makes the check-then-insert atomic. *) 261 let create_trainee t ~username ~credential = 262 let username_s = Trainee.username_to_string username in 263 let credential_s = Trainee.credential_to_hash credential in 264 run t (fun (module Db : Caqti_lwt.CONNECTION) -> 265 let open Lwt_result.Syntax in 266 Db.with_transaction (fun () -> 267 let* existing = Db.find_opt Q.trainee_by_username username_s in 268 match existing with 269 | Some _ -> Lwt_result.return (Error Repository.Username_taken) 270 | None -> 271 let* next = Db.find Q.next_trainee () in 272 let id = Printf.sprintf "t%d" next in 273 let* () = 274 Db.exec Q.insert_trainee (id, username_s, credential_s) 275 in 276 Lwt_result.return 277 (Ok { Trainee.id = Trainee.id id; username; credential }))) 278 279 let trainee_of_row (id, username, credential) = 280 match Trainee.username username with 281 | Ok username -> 282 Some 283 { 284 Trainee.id = Trainee.id id; 285 username; 286 credential = Trainee.credential_of_hash credential; 287 } 288 | Error _ -> None 289 290 let find_trainee_by_username t username = 291 q_find_opt t Q.trainee_by_username (Trainee.username_to_string username) 292 >|= function 293 | Some row -> trainee_of_row row 294 | None -> None 295 296 let find_trainee t id = 297 q_find_opt t Q.trainee_by_id (Trainee.id_to_string id) >|= function 298 | Some row -> trainee_of_row row 299 | None -> None 300 301 (* Rename in one transaction: check the name is free for anyone else, then 302 update. Both statements run on the same connection, so the check and the 303 write cannot race another rename. The trainee's own current name is a no-op. 304 *) 305 let update_username t id (username : Trainee.username) = 306 let trainee = Trainee.id_to_string id in 307 let username_s = Trainee.username_to_string username in 308 run t (fun (module Db : Caqti_lwt.CONNECTION) -> 309 let open Lwt_result.Syntax in 310 Db.with_transaction (fun () -> 311 let* holder = Db.find_opt Q.trainee_by_username username_s in 312 match holder with 313 | Some (holder_id, _, _) when not (String.equal holder_id trainee) -> 314 Lwt_result.return (Error Repository.Username_taken) 315 | _ -> 316 let* () = Db.exec Q.update_username (username_s, trainee) in 317 let* row = Db.find_opt Q.trainee_by_id trainee in 318 Lwt_result.return 319 (match Option.bind row trainee_of_row with 320 | Some updated -> Ok updated 321 | None -> Error Repository.Username_taken))) 322 323 let update_credential t id credential = 324 let trainee = Trainee.id_to_string id in 325 q_exec t Q.update_credential (Trainee.credential_to_hash credential, trainee) 326 >>= fun () -> find_trainee t id 327 328 (* --- catalog --- *) 329 330 let list_routines _ = Catalog.routines 331 let find_routine _ id = Catalog.find id 332 333 (* The routine name a workout's prescription belongs to, for encoding. Only the 334 ideal routine exists, but resolve it honestly rather than hard-code. *) 335 let routine_name_of workout = 336 let workout_name = 337 Prescription.Workout.name (Evidence.Workout.prescription workout) 338 in 339 List.find_map 340 (fun (_, routine) -> 341 if 342 List.exists 343 (fun w -> String.equal (Prescription.Workout.name w) workout_name) 344 (Prescription.Routine.workouts routine) 345 then Some (Prescription.Routine.name routine) 346 else None) 347 Catalog.routines 348 |> Option.value ~default:"" 349 350 let encode workout = 351 Codec.encode_workout ~routine_name:(routine_name_of workout) workout 352 353 (* --- per-trainee selection and workout in progress --- *) 354 355 let active_routine t id = 356 q_find_opt t Q.get_active (Trainee.id_to_string id) 357 >|= Option.map Repository.routine_id 358 359 let set_active_routine t id (routine : Repository.routine_id) = 360 q_exec t Q.set_active (Trainee.id_to_string id, (routine :> string)) 361 362 let in_progress t id = 363 q_find_opt t Q.get_in_progress (Trainee.id_to_string id) >|= function 364 | None -> None 365 | Some encoded -> Some (decode encoded) 366 367 let set_in_progress t id workout = 368 let trainee = Trainee.id_to_string id in 369 match workout with 370 | None -> q_exec t Q.clear_in_progress trainee 371 | Some workout -> q_exec t Q.set_in_progress (trainee, encode workout) 372 373 (* --- history --- *) 374 375 (* Save a finished workout and clear the in-progress slot in one transaction, 376 so the two rows never disagree after a crash. Both statements run on the same 377 connection, checked out once, inside [with_transaction]. *) 378 let finish_workout t id workout = 379 let trainee = Trainee.id_to_string id in 380 let encoded = encode workout in 381 run t (fun (module Db : Caqti_lwt.CONNECTION) -> 382 let open Lwt_result.Syntax in 383 Db.with_transaction (fun () -> 384 let* seq = Db.find Q.next_seq trainee in 385 let wid = Printf.sprintf "%s:w%d" trainee seq in 386 let* () = Db.exec Q.insert_workout (wid, trainee, seq, encoded) in 387 let* () = Db.exec Q.clear_in_progress trainee in 388 Lwt_result.return 389 { Repository.id = Repository.workout_id wid; workout })) 390 391 let find t id (wid : Repository.workout_id) = 392 q_find_opt t Q.workout_by_id (Trainee.id_to_string id, (wid :> string)) 393 >|= function 394 | None -> None 395 | Some encoded -> Some { Repository.id = wid; workout = decode encoded } 396 397 let replace t id record = 398 let trainee = Trainee.id_to_string id in 399 q_find_opt t Q.workout_by_id (trainee, (record.Repository.id :> string)) 400 >>= function 401 | None -> Lwt.return false 402 | Some _ -> 403 q_exec t Q.update_workout 404 ( encode record.Repository.workout, 405 trainee, 406 (record.Repository.id :> string) ) 407 >|= fun () -> true 408 409 let delete_workout t id (wid : Repository.workout_id) = 410 let trainee = Trainee.id_to_string id in 411 q_find_opt t Q.workout_by_id (trainee, (wid :> string)) >>= function 412 | None -> Lwt.return false 413 | Some _ -> 414 q_exec t Q.delete_workout (trainee, (wid :> string)) >|= fun () -> true 415 416 let history t id = 417 q_collect_list t Q.history (Trainee.id_to_string id) 418 >|= List.filter_map decode_history_row 419 420 let log t id = 421 history t id >|= fun records -> 422 List.fold_left 423 (fun log record -> Evidence.Log.add log record.Repository.workout) 424 Evidence.Log.empty (List.rev records) 425 426 (* --- subjective feedback --- *) 427 428 let save_feedback t id report = 429 let trainee = Trainee.id_to_string id in 430 let encoded = Codec.encode_feedback report in 431 q_find t Q.next_feedback_seq trainee >>= fun seq -> 432 q_exec t Q.insert_feedback (trainee, seq, encoded) 433 434 (* Quarantine a corrupt row in a list read, naming its position. *) 435 let quarantine ~what ~pp_error decode rows = 436 List.mapi (fun i encoded -> (i + 1, decode encoded)) rows 437 |> List.filter_map (fun (position, decoded) -> 438 match decoded with 439 | Ok v -> Some v 440 | Error e -> 441 warn_corrupt ~what ~id:(string_of_int position) 442 (Format.asprintf "%a" pp_error e); 443 None) 444 445 let feedback t id = 446 q_collect_list t Q.feedback (Trainee.id_to_string id) 447 >|= quarantine ~what:"feedback report" ~pp_error:Codec.pp_error 448 Codec.decode_feedback 449 450 let save_app_feedback t id ~submitted_at ~message = 451 let trainee = Trainee.id_to_string id in 452 find_trainee t id >>= function 453 | None -> 454 Lwt.fail_with "cannot save application feedback for an unknown trainee" 455 | Some author -> 456 q_find t Q.next_app_feedback_seq trainee >>= fun seq -> 457 q_exec t Q.insert_app_feedback 458 (trainee, seq, Timestamp.encode submitted_at, message) 459 >|= fun () -> 460 Repository. 461 { 462 feedback_id = app_feedback_id (Printf.sprintf "%s:%d" trainee seq); 463 author = Trainee.username_to_string author.Trainee.username; 464 contributions = seq; 465 submitted_at; 466 message; 467 upvotes = 0; 468 viewer_upvoted = false; 469 viewer_owns = true; 470 } 471 472 let app_feedback t ~viewer = 473 q_collect_list t Q.app_feedback () >>= fun rows -> 474 q_collect_list t Q.viewer_votes (Trainee.id_to_string viewer) >|= fun voted -> 475 List.map 476 (fun (trainee, seq, author, submitted_at, message, contributions, upvotes) 477 -> 478 let submitted_at = 479 match Timestamp.decode submitted_at with 480 | Ok timestamp -> timestamp 481 | Error _ -> 482 raise (Corrupt (Codec.Malformed "application feedback timestamp")) 483 in 484 Repository. 485 { 486 feedback_id = app_feedback_id (Printf.sprintf "%s:%d" trainee seq); 487 author; 488 contributions; 489 submitted_at; 490 message; 491 upvotes; 492 viewer_upvoted = List.mem (trainee, seq) voted; 493 viewer_owns = String.equal trainee (Trainee.id_to_string viewer); 494 }) 495 rows 496 497 let split_app_feedback_id id = 498 let raw = Repository.app_feedback_id_to_string id in 499 match String.rindex_opt raw ':' with 500 | None -> None 501 | Some separator -> 502 let trainee = String.sub raw 0 separator in 503 let seq_start = separator + 1 in 504 let seq_length = String.length raw - seq_start in 505 Option.map 506 (fun seq -> (trainee, seq)) 507 (int_of_string_opt (String.sub raw seq_start seq_length)) 508 509 let upvote_app_feedback t ~voter id = 510 match split_app_feedback_id id with 511 | None -> Lwt.return false 512 | Some (trainee, seq) -> ( 513 let voter = Trainee.id_to_string voter in 514 q_find_opt t Q.feedback_owner (trainee, seq) >>= function 515 | None -> Lwt.return false 516 | Some owner when String.equal owner voter -> Lwt.return false 517 | Some _ -> ( 518 q_find_opt t Q.vote_exists (trainee, seq, voter) >>= function 519 | Some _ -> Lwt.return false 520 | None -> 521 q_exec t Q.insert_vote (trainee, seq, voter) >|= fun () -> true)) 522 523 let update_app_feedback t ~author id ~message = 524 match split_app_feedback_id id with 525 | None -> Lwt.return false 526 | Some (trainee, seq) -> ( 527 q_find_opt t Q.feedback_owner (trainee, seq) >>= function 528 | Some owner when String.equal owner (Trainee.id_to_string author) -> 529 q_exec t Q.update_app_feedback (message, trainee, seq) >|= fun () -> 530 true 531 | _ -> Lwt.return false) 532 533 let delete_app_feedback t ~author id = 534 match split_app_feedback_id id with 535 | None -> Lwt.return false 536 | Some (trainee, seq) -> ( 537 q_find_opt t Q.feedback_owner (trainee, seq) >>= function 538 | Some owner when String.equal owner (Trainee.id_to_string author) -> 539 q_exec t Q.delete_feedback_votes (trainee, seq) >>= fun () -> 540 q_exec t Q.delete_app_feedback (trainee, seq) >|= fun () -> true 541 | _ -> Lwt.return false) 542 543 (* --- workout import --- *) 544 545 (* Snapshots resolve their exercise ids through the catalog. The id type is 546 private, so the lookup goes through the raw string. *) 547 let find_exercise (id : Exercise.id) = Exercise.find_id (id :> string) 548 549 let decode_batch encoded = 550 match Import_codec.decode_snapshot ~find_exercise encoded with 551 | Ok batch -> batch 552 | Error e -> raise (Import_corrupt e) 553 554 let save_import_batch t id batch = 555 let trainee = Trainee.id_to_string id in 556 let batch_id = 557 Workout_import.batch_id_to_string (Workout_import.batch_identity batch) 558 in 559 let imported_at = Timestamp.encode (Workout_import.batch_imported_at batch) in 560 let source = Workout_import.batch_source batch in 561 let fingerprint = Workout_import.batch_fingerprint batch in 562 let snapshot = Import_codec.encode_snapshot (Workout_import.snapshot batch) in 563 q_exec t Q.upsert_import_batch 564 (trainee, batch_id, imported_at, source, fingerprint, snapshot) 565 566 let find_import_batch t id batch_id = 567 q_find_opt t Q.import_batch_by_id 568 (Trainee.id_to_string id, Workout_import.batch_id_to_string batch_id) 569 >|= function 570 | None -> None 571 | Some encoded -> Some (decode_batch encoded) 572 573 let import_batches t id = 574 q_collect_list t Q.import_batches (Trainee.id_to_string id) 575 >|= quarantine ~what:"import batch" ~pp_error:Import_codec.pp_error 576 (Import_codec.decode_snapshot ~find_exercise) 577 578 (* Deleting a provisional batch leaves its promoted records: there is no foreign 579 key, so no cascade can reach them. *) 580 let delete_import_batch t id batch_id = 581 let trainee = Trainee.id_to_string id in 582 let batch_id = Workout_import.batch_id_to_string batch_id in 583 q_find_opt t Q.import_batch_exists (trainee, batch_id) >>= function 584 | None -> Lwt.return false 585 | Some _ -> 586 q_exec t Q.delete_import_batch (trainee, batch_id) >|= fun () -> true 587 588 let replace_imported_record t id (record : Repository.imported_record) = 589 let trainee = Trainee.id_to_string id in 590 let workout_id = Workout_import.workout_id_to_string record.workout_id in 591 let batch_id = Workout_import.batch_id_to_string record.batch_id in 592 let title = record.title in 593 let started_at = Timestamp.encode record.started_at in 594 let ended_at = Timestamp.encode record.ended_at in 595 let snapshot = Import_codec.encode_record record in 596 q_exec t Q.upsert_imported_record 597 ( (trainee, workout_id, batch_id, record.revision), 598 (title, started_at, ended_at, snapshot) ) 599 600 let imported_records t id = 601 q_collect_list t Q.imported_records (Trainee.id_to_string id) 602 >|= quarantine ~what:"imported record" ~pp_error:Import_codec.pp_error 603 Import_codec.decode_record 604