Skip to main content

teamcraft/services/
references.rs

1//! Reference tracking besides the BOM:
2//!
3//! * **Item references** – a drawing documents a part, a specification
4//!   applies to an item, a model references another one. They belong to the
5//!   revision of the referencing item (like BOM lines): changed only while it
6//!   is checked out, copied by Revise and frozen at release.
7//! * **File references** – the file names found inside CAD files
8//!   (`cadrefs`): an assembly file names its part files, a drawing its model.
9//!   They are matched against the files of the latest revisions in the vault,
10//!   so the revision page shows which item a referenced file belongs to and
11//!   which referenced files are not in TeamCraft yet.
12
13use std::collections::{BTreeMap, HashMap};
14
15use serde::Serialize;
16use serde_json::json;
17use sqlx::{PgConnection, PgPool};
18use uuid::Uuid;
19
20use crate::{
21    auth::Actor,
22    cadformat,
23    domain::{self, ReferenceKind},
24    error::{AppError, AppResult},
25    models::{ItemReference, Referrer},
26    services::{access, audit, items, revisions},
27};
28
29const REFERENCE_SELECT: &str = "
30    SELECT ir.id, ir.from_revision_id, ir.to_item_id, i.number AS to_number, i.item_type AS to_type,
31           ir.kind, ir.note, lr.id AS to_latest_revision_id, lr.code AS to_latest_code,
32           lr.state AS to_latest_state, lr.name AS to_name,
33           ir.released_to_revision_id, rr.code AS released_to_code, ir.created_at,
34           i.project_id AS to_project_id
35    FROM item_references ir
36    JOIN items i ON i.id = ir.to_item_id
37    CROSS JOIN LATERAL (
38        SELECT r.id, r.code, r.state, r.name FROM revisions r
39        WHERE r.item_id = i.id ORDER BY r.seq DESC LIMIT 1
40    ) lr
41    LEFT JOIN revisions rr ON rr.id = ir.released_to_revision_id";
42
43/// The references of a revision to other items; references to items the
44/// actor may not see are placeholders.
45pub async fn outgoing(
46    pool: &PgPool,
47    actor: &Actor,
48    revision_id: Uuid,
49) -> AppResult<Vec<ItemReference>> {
50    let mut rows = sqlx::query_as::<_, ItemReference>(&format!(
51        "{REFERENCE_SELECT} WHERE ir.from_revision_id = $1 ORDER BY ir.kind, i.number"
52    ))
53    .bind(revision_id)
54    .fetch_all(pool)
55    .await?;
56    for r in &mut rows {
57        if !actor.can_see_project(r.to_project_id) {
58            r.mask();
59        }
60    }
61    // placeholders last in their kind: their position would bound the
62    // hidden number
63    rows.sort_by(|a, b| (&a.kind, a.restricted).cmp(&(&b.kind, b.restricted)));
64    Ok(rows)
65}
66
67/// A reference of a revision the actor may read (hidden target = placeholder).
68pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ItemReference> {
69    Ok(masked_for(actor, get_unmasked(pool, actor, id).await?))
70}
71
72/// [`get`] without the placeholder (for the audit trail of a write).
73async fn get_unmasked(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ItemReference> {
74    let r = sqlx::query_as::<_, ItemReference>(&format!("{REFERENCE_SELECT} WHERE ir.id = $1"))
75        .bind(id)
76        .fetch_optional(pool)
77        .await?
78        .ok_or_else(|| AppError::not_found("Reference not found."))?;
79    revisions::get(pool, actor, r.from_revision_id)
80        .await
81        .map_err(|_| AppError::not_found("Reference not found."))?;
82    Ok(r)
83}
84
85fn masked_for(actor: &Actor, mut r: ItemReference) -> ItemReference {
86    if !actor.can_see_project(r.to_project_id) {
87        r.mask();
88    }
89    r
90}
91
92/// Revisions (of other items the actor may see) that reference an item,
93/// newest revisions first.
94pub async fn incoming(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<Vec<Referrer>> {
95    Ok(sqlx::query_as::<_, Referrer>(&format!(
96        "SELECT ir.id AS reference_id, ir.kind, ir.note, r.id AS from_revision_id, i.id AS from_item_id,
97                i.number AS from_number, r.code AS from_code, r.name AS from_name, r.state AS from_state,
98                rr.code AS released_to_code
99         FROM item_references ir
100         JOIN revisions r ON r.id = ir.from_revision_id
101         JOIN items i ON i.id = r.item_id
102         LEFT JOIN revisions rr ON rr.id = ir.released_to_revision_id
103         WHERE ir.to_item_id = $1 AND {}
104         ORDER BY i.number, r.seq DESC",
105        access::item_visible("i", 2)
106    ))
107    .bind(item_id)
108    .bind(&actor.hidden_projects)
109    .fetch_all(pool)
110    .await?)
111}
112
113/// Number of referencing revisions the actor may not see.
114pub async fn incoming_hidden(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<i64> {
115    if actor.hidden_projects.is_empty() {
116        return Ok(0);
117    }
118    Ok(sqlx::query_scalar::<_, i64>(&format!(
119        "SELECT COUNT(*) FROM item_references ir
120         JOIN revisions r ON r.id = ir.from_revision_id
121         JOIN items i ON i.id = r.item_id
122         WHERE ir.to_item_id = $1 AND NOT {}",
123        access::item_visible("i", 2)
124    ))
125    .bind(item_id)
126    .bind(&actor.hidden_projects)
127    .fetch_one(pool)
128    .await?)
129}
130
131#[derive(Clone, Debug)]
132pub struct NewReference {
133    /// Item number of the referenced item.
134    pub to_number: String,
135    pub kind: ReferenceKind,
136    pub note: String,
137}
138
139/// Adds a reference to a revision that is checked out by the actor.
140///
141/// # Invariants
142///
143/// * **Permission:** the target item is visible to the actor ("not found"
144///   otherwise); the revision is visible, checked out by the actor (409
145///   otherwise) and editable (`revisions::ensure_editable`: in work, CAD
146///   editor of its group). An item cannot reference itself (400).
147/// * **Locks:** the revision row (`revisions::lock`).
148/// * **Audit:** `reference.add`.
149/// * **Notifications / webhooks:** none.
150/// * **Atomic:** one transaction; a duplicate reference (unique key) is a 409.
151pub async fn add(
152    pool: &PgPool,
153    actor: &Actor,
154    from_revision_id: Uuid,
155    input: NewReference,
156) -> AppResult<ItemReference> {
157    let note = domain::limit_text("Note", &input.note, 500)?;
158    let target = items::get_by_number(pool, actor, &input.to_number).await?;
159    let mut tx = pool.begin().await?;
160    let rev = revisions::lock(&mut tx, from_revision_id).await?;
161    revisions::ensure_editable(&rev, actor)?;
162    if target.id == rev.item_id {
163        return Err(AppError::BadRequest(
164            "An item cannot reference itself.".into(),
165        ));
166    }
167    let id = Uuid::new_v4();
168    sqlx::query(
169        "INSERT INTO item_references (id, from_revision_id, to_item_id, kind, note, created_by)
170         VALUES ($1, $2, $3, $4, $5, $6)",
171    )
172    .bind(id)
173    .bind(rev.id)
174    .bind(target.id)
175    .bind(input.kind.as_str())
176    .bind(&note)
177    .bind(actor.id)
178    .execute(&mut *tx)
179    .await
180    .map_err(|e| {
181        AppError::unique_violation(
182            e,
183            format!(
184                "{} already has this reference to {}.",
185                revisions::label(&rev),
186                target.number
187            ),
188        )
189    })?;
190    audit::record_related(
191        &mut tx,
192        Some(actor),
193        "reference.add",
194        "revision",
195        Some(rev.id),
196        Some(target.id),
197        format!(
198            "{}: {} {}",
199            revisions::label(&rev),
200            input.kind.label().to_lowercase(),
201            target.number
202        ),
203        json!({ "item_id": rev.item_id, "reference_id": id, "to_item_id": target.id,
204                "kind": input.kind.as_str() }),
205    )
206    .await?;
207    tx.commit().await?;
208    get(pool, actor, id).await
209}
210
211/// Removes a reference from a revision that is checked out by the actor.
212///
213/// # Invariants
214///
215/// * **Permission:** the reference's revision is visible to the actor ("not
216///   found" otherwise), checked out by the actor (409 otherwise) and editable
217///   (`revisions::ensure_editable`: in work, CAD editor of its group).
218/// * **Locks:** the revision row (`revisions::lock`).
219/// * **Audit:** `reference.remove`.
220/// * **Notifications / webhooks:** none.
221/// * **Atomic:** one transaction.
222pub async fn remove(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ItemReference> {
223    let existing = get_unmasked(pool, actor, id).await?;
224    let mut tx = pool.begin().await?;
225    let rev = revisions::lock(&mut tx, existing.from_revision_id).await?;
226    revisions::ensure_editable(&rev, actor)?;
227    sqlx::query("DELETE FROM item_references WHERE id = $1")
228        .bind(id)
229        .execute(&mut *tx)
230        .await?;
231    audit::record_related(
232        &mut tx,
233        Some(actor),
234        "reference.remove",
235        "revision",
236        Some(rev.id),
237        Some(existing.to_item_id),
238        format!(
239            "Removed the reference of {} to {}",
240            revisions::label(&rev),
241            existing.to_number
242        ),
243        json!({ "item_id": rev.item_id, "reference_id": id }),
244    )
245    .await?;
246    tx.commit().await?;
247    Ok(masked_for(actor, existing))
248}
249
250/// Copies the references of `from` to the new revision `to` (Revise).
251pub(crate) async fn copy_to_revision(
252    conn: &mut PgConnection,
253    from: Uuid,
254    to: Uuid,
255) -> AppResult<u64> {
256    Ok(sqlx::query(
257        "INSERT INTO item_references (id, from_revision_id, to_item_id, kind, note, created_by, created_at)
258         SELECT gen_random_uuid(), $2, to_item_id, kind, note, created_by, created_at
259         FROM item_references WHERE from_revision_id = $1",
260    )
261    .bind(from)
262    .bind(to)
263    .execute(&mut *conn)
264    .await?
265    .rows_affected())
266}
267
268// ---------------------------------------------------------------------------
269// File references (found inside CAD files)
270// ---------------------------------------------------------------------------
271
272/// A file in the vault that matches a referenced file name.
273#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
274pub struct FileMatch {
275    #[serde(skip)]
276    pub key: String,
277    pub item_id: Uuid,
278    pub item_number: String,
279    pub item_name: String,
280    pub revision_id: Uuid,
281    pub revision_code: String,
282    pub revision_state: String,
283    pub file_id: Uuid,
284    pub filename: String,
285    /// The file belongs to the revision that is being looked at.
286    pub same_revision: bool,
287    /// Matched by the name the STEP file was exported under, not by its own
288    /// file name – only where no file has the referenced name itself.
289    pub export_name: bool,
290}
291
292/// A referenced file name and where TeamCraft has it.
293#[derive(Clone, Debug, Serialize)]
294pub struct ReferencedName {
295    pub name: String,
296    /// How often the referencing file places it, where its format tells
297    /// (SOLIDWORKS 2015+ and NX assemblies, see `cadrefs::instance_counts`;
298    /// 0 = only instances excluded from the BOM); `None` = not known.
299    pub instances: Option<i32>,
300    pub matches: Vec<FileMatch>,
301}
302
303/// A file of a revision with the CAD files its current version references.
304#[derive(Clone, Debug, Serialize)]
305pub struct FileWithReferences {
306    pub file_id: Uuid,
307    pub filename: String,
308    pub format_kind: Option<cadformat::FormatKind>,
309    pub references: Vec<ReferencedName>,
310}
311
312/// The file references of a revision, matched against the files of the
313/// latest revisions of all items (and of the revision itself).
314pub async fn files_of_revision(
315    pool: &PgPool,
316    actor: &Actor,
317    revision_id: Uuid,
318) -> AppResult<Vec<FileWithReferences>> {
319    #[derive(sqlx::FromRow)]
320    struct Row {
321        file_id: Uuid,
322        filename: String,
323        format: String,
324        name: String,
325        instances: Option<i32>,
326    }
327    let rows = sqlx::query_as::<_, Row>(
328        "SELECT f.id AS file_id, f.filename, COALESCE(f.format, '') AS format, fr.name,
329                fr.instances
330         FROM files f
331         JOIN file_versions fv ON fv.file_id = f.id AND fv.version = f.current_version
332         JOIN file_references fr ON fr.file_version_id = fv.id
333         WHERE f.revision_id = $1
334         ORDER BY lower(f.filename), lower(fr.name)",
335    )
336    .bind(revision_id)
337    .fetch_all(pool)
338    .await?;
339    if rows.is_empty() {
340        return Ok(Vec::new());
341    }
342    let keys: Vec<String> = rows.iter().map(|r| r.name.to_lowercase()).collect();
343    // Candidates: the files of this revision and of the latest revisions of
344    // the other items the actor may see.
345    let scope = format!(
346        "(r.id = $2 OR (r.item_id <> (SELECT item_id FROM revisions WHERE id = $2)
347              AND r.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r.item_id)))
348         AND {}",
349        access::item_visible("i", 3)
350    );
351    let found = sqlx::query_as::<_, FileMatch>(&format!(
352        "SELECT lower(f.filename) AS key, i.id AS item_id, i.number AS item_number, r.name AS item_name,
353                r.id AS revision_id, r.code AS revision_code, r.state AS revision_state,
354                f.id AS file_id, f.filename, r.id = $2 AS same_revision, FALSE AS export_name
355         FROM files f
356         JOIN revisions r ON r.id = f.revision_id
357         JOIN items i ON i.id = r.item_id
358         WHERE lower(f.filename) = ANY($1) AND {scope}
359         ORDER BY i.number"
360    ))
361    .bind(&keys)
362    .bind(revision_id)
363    .bind(&actor.hidden_projects)
364    .fetch_all(pool)
365    .await?;
366    let mut by_key: HashMap<String, Vec<FileMatch>> = HashMap::new();
367    for m in found {
368        by_key.entry(m.key.clone()).or_default().push(m);
369    }
370    // A name no candidate has as its file name: the STEP files exported
371    // under it (the weaker match, never in place of a real one).
372    let unmatched: Vec<String> = keys
373        .iter()
374        .filter(|k| !by_key.contains_key(*k))
375        .cloned()
376        .collect::<std::collections::BTreeSet<_>>()
377        .into_iter()
378        .collect();
379    if !unmatched.is_empty() {
380        let weak = sqlx::query_as::<_, FileMatch>(&format!(
381            "SELECT lower(fv.export_name) AS key, i.id AS item_id, i.number AS item_number,
382                    r.name AS item_name, r.id AS revision_id, r.code AS revision_code,
383                    r.state AS revision_state, f.id AS file_id, f.filename,
384                    r.id = $2 AS same_revision, TRUE AS export_name
385             FROM files f
386             JOIN file_versions fv ON fv.file_id = f.id AND fv.version = f.current_version
387             JOIN revisions r ON r.id = f.revision_id
388             JOIN items i ON i.id = r.item_id
389             WHERE lower(fv.export_name) = ANY($1) AND {scope}
390             ORDER BY i.number, f.filename"
391        ))
392        .bind(&unmatched)
393        .bind(revision_id)
394        .bind(&actor.hidden_projects)
395        .fetch_all(pool)
396        .await?;
397        for m in weak {
398            by_key.entry(m.key.clone()).or_default().push(m);
399        }
400    }
401    let mut files: BTreeMap<(String, Uuid), FileWithReferences> = BTreeMap::new();
402    for row in rows {
403        let entry = files
404            .entry((row.filename.to_lowercase(), row.file_id))
405            .or_insert_with(|| FileWithReferences {
406                file_id: row.file_id,
407                filename: row.filename.clone(),
408                format_kind: cadformat::by_id(&row.format).map(|f| f.kind),
409                references: Vec::new(),
410            });
411        // (a STEP file is never its own match through its export name)
412        let matches = by_key
413            .get(&row.name.to_lowercase())
414            .map(|ms| {
415                ms.iter()
416                    .filter(|m| !(m.export_name && m.file_id == row.file_id))
417                    .cloned()
418                    .collect()
419            })
420            .unwrap_or_default();
421        entry.references.push(ReferencedName {
422            name: row.name,
423            instances: row.instances,
424            matches,
425        });
426    }
427    Ok(files.into_values().collect())
428}
429
430/// A file of another item whose CAD file references a file of this revision.
431#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
432pub struct FileReferrer {
433    pub item_id: Uuid,
434    pub item_number: String,
435    pub revision_id: Uuid,
436    pub revision_code: String,
437    pub revision_state: String,
438    pub item_name: String,
439    pub file_id: Uuid,
440    pub filename: String,
441    /// The referenced file of this revision.
442    pub referenced: String,
443    /// Referenced by the name the STEP file was exported under (see
444    /// [`FileMatch::export_name`]).
445    pub export_name: bool,
446}
447
448/// CAD files of the latest revisions of other items that reference one of
449/// the files of this revision (assemblies using the part file, drawings of it).
450/// Matches in groups the actor may not see are left out **without a count**:
451/// a file-name match is a heuristic and never crosses a restricted group's
452/// boundary (unlike explicit links, which are counted).
453pub async fn file_referrers(
454    pool: &PgPool,
455    actor: &Actor,
456    revision_id: Uuid,
457) -> AppResult<Vec<FileReferrer>> {
458    let all = file_referrers_unchecked(pool, revision_id).await?;
459    let ids: Vec<Uuid> = all.iter().map(|f| f.item_id).collect();
460    let visible: std::collections::HashSet<Uuid> = sqlx::query_scalar::<_, Uuid>(&format!(
461        "SELECT i.id FROM items i WHERE i.id = ANY($1) AND {}",
462        access::item_visible("i", 2)
463    ))
464    .bind(&ids)
465    .bind(&actor.hidden_projects)
466    .fetch_all(pool)
467    .await?
468    .into_iter()
469    .collect();
470    Ok(all
471        .into_iter()
472        .filter(|f| visible.contains(&f.item_id))
473        .collect())
474}
475
476/// [`file_referrers`] without the read check.
477pub(crate) async fn file_referrers_unchecked(
478    pool: &PgPool,
479    revision_id: Uuid,
480) -> AppResult<Vec<FileReferrer>> {
481    // By the file name, or by the STEP export name where no candidate of
482    // the referencing file (its revision, the latest revisions of the other
483    // items) has the referenced name itself – the same rule as in
484    // `files_of_revision`, without the read check (done by the caller).
485    Ok(sqlx::query_as::<_, FileReferrer>(
486        "SELECT DISTINCT ON (item_number, filename, referenced) *
487         FROM (
488             SELECT i.id AS item_id, i.number AS item_number, r.id AS revision_id,
489                    r.code AS revision_code, r.state AS revision_state, r.name AS item_name,
490                    df.id AS file_id, df.filename, sf.filename AS referenced, FALSE AS export_name
491             FROM files sf
492             JOIN revisions sr ON sr.id = sf.revision_id
493             JOIN file_references fr ON lower(fr.name) = lower(sf.filename)
494             JOIN file_versions dv ON dv.id = fr.file_version_id
495             JOIN files df ON df.id = dv.file_id AND dv.version = df.current_version
496             JOIN revisions r ON r.id = df.revision_id
497             JOIN items i ON i.id = r.item_id
498             WHERE sf.revision_id = $1
499               AND r.item_id <> sr.item_id
500               AND r.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r.item_id)
501             UNION ALL
502             SELECT i.id, i.number, r.id, r.code, r.state, r.name, df.id, df.filename,
503                    sf.filename, TRUE
504             FROM files sf
505             JOIN file_versions sv ON sv.file_id = sf.id AND sv.version = sf.current_version
506             JOIN revisions sr ON sr.id = sf.revision_id
507             JOIN file_references fr ON lower(fr.name) = lower(sv.export_name)
508             JOIN file_versions dv ON dv.id = fr.file_version_id
509             JOIN files df ON df.id = dv.file_id AND dv.version = df.current_version
510             JOIN revisions r ON r.id = df.revision_id
511             JOIN items i ON i.id = r.item_id
512             WHERE sf.revision_id = $1
513               AND sv.export_name IS NOT NULL
514               AND r.item_id <> sr.item_id
515               AND r.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r.item_id)
516               AND NOT EXISTS (
517                   SELECT 1 FROM files xf JOIN revisions xr ON xr.id = xf.revision_id
518                   WHERE lower(xf.filename) = lower(fr.name)
519                     AND (xr.id = r.id
520                          OR (xr.item_id <> r.item_id
521                              AND xr.seq = (SELECT MAX(y.seq) FROM revisions y
522                                            WHERE y.item_id = xr.item_id))))
523         ) m
524         ORDER BY item_number, filename, referenced, export_name",
525    )
526    .bind(revision_id)
527    .fetch_all(pool)
528    .await?)
529}
530
531/// What the file references suggest for the structure of a revision: items
532/// whose files an assembly file uses but which are not in the BOM, and items
533/// a drawing documents without a "drawing of" reference.
534#[derive(Clone, Debug, Default, Serialize)]
535pub struct Suggestions {
536    pub bom: Vec<SuggestedItem>,
537    pub drawing_of: Vec<SuggestedItem>,
538}
539
540#[derive(Clone, Debug, Serialize)]
541pub struct SuggestedItem {
542    pub item_number: String,
543    pub item_name: String,
544    /// The CAD file that references it and the referenced file.
545    pub via: String,
546}
547
548pub fn suggestions(
549    files: &[FileWithReferences],
550    bom_children: &[String],
551    references: &[ItemReference],
552) -> Suggestions {
553    let mut out = Suggestions::default();
554    let mut seen = std::collections::HashSet::new();
555    for file in files {
556        let kind = file.format_kind;
557        for name in &file.references {
558            for m in name.matches.iter().filter(|m| !m.same_revision) {
559                let via = format!("{} → {}", file.filename, m.filename);
560                let item = SuggestedItem {
561                    item_number: m.item_number.clone(),
562                    item_name: m.item_name.clone(),
563                    via,
564                };
565                match kind {
566                    Some(cadformat::FormatKind::Assembly | cadformat::FormatKind::Model)
567                        if !bom_children.contains(&m.item_number)
568                            && seen.insert(("bom", m.item_number.clone())) =>
569                    {
570                        out.bom.push(item)
571                    }
572                    Some(cadformat::FormatKind::Drawing)
573                        if !references.iter().any(|r| {
574                            r.to_number == m.item_number
575                                && r.kind == ReferenceKind::Drawing.as_str()
576                        }) && seen.insert(("drawing", m.item_number.clone())) =>
577                    {
578                        out.drawing_of.push(item)
579                    }
580                    _ => {}
581                }
582            }
583        }
584    }
585    out
586}
587
588/// Stores the STEP export name of a file version (`None` removes it).
589pub(crate) async fn store_export_name(
590    conn: &mut PgConnection,
591    file_version_id: Uuid,
592    export_name: Option<&str>,
593) -> AppResult<()> {
594    sqlx::query(
595        "UPDATE file_versions SET export_name = $2, export_name_scanned = TRUE WHERE id = $1",
596    )
597    .bind(file_version_id)
598    .bind(export_name)
599    .execute(&mut *conn)
600    .await?;
601    Ok(())
602}
603
604/// Stores the references found in a newly stored file version, with their
605/// instance counts where known.
606pub(crate) async fn store_file_references(
607    conn: &mut PgConnection,
608    file_version_id: Uuid,
609    refs: &[crate::cadrefs::FileRef],
610) -> AppResult<()> {
611    if !refs.is_empty() {
612        let names: Vec<&str> = refs.iter().map(|r| r.name.as_str()).collect();
613        let instances: Vec<Option<i32>> = refs
614            .iter()
615            .map(|r| r.instances.map(|n| i32::try_from(n).unwrap_or(i32::MAX)))
616            .collect();
617        sqlx::query(
618            "INSERT INTO file_references (file_version_id, name, instances)
619             SELECT $1, n, i FROM unnest($2::text[], $3::int4[]) AS t (n, i)
620             ON CONFLICT DO NOTHING",
621        )
622        .bind(file_version_id)
623        .bind(&names)
624        .bind(&instances)
625        .execute(&mut *conn)
626        .await?;
627    }
628    sqlx::query("UPDATE file_versions SET references_scanned = TRUE WHERE id = $1")
629        .bind(file_version_id)
630        .execute(&mut *conn)
631        .await?;
632    Ok(())
633}