Skip to main content

laterite_media/
record.rs

1//! The media record: a file's stable identity, pointing at a blob.
2//!
3//! The record is what everything else references, and it outlives any particular
4//! bytes. Replacing a file writes a new blob with a new hash, and the record's id
5//! and every link to it stay exactly where they were. That is the whole reason a
6//! hash is not used as the identity: a hash is an excellent name for bytes and a
7//! poor name for a thing.
8
9use chrono::{SecondsFormat, Utc};
10use laterite_core::strata::*;
11use laterite_core::Db;
12use sea_query::UpdateStatement;
13
14/// The media table's columns.
15#[derive(Iden)]
16pub enum Media {
17    Table,
18    Id,
19    /// The named disk the blob lives on. Stored per record, so a file's physical
20    /// location is knowable without consulting configuration that may have moved
21    /// on since it was written.
22    Disk,
23    /// BLAKE3 hex of the bytes. Indexed, and the blob path derives from it.
24    Hash,
25    Size,
26    /// Sniffed at ingest, never taken from the sender.
27    ContentType,
28    /// Display metadata. Never a path: the storage path is always the hash, so a
29    /// hostile filename cannot reach the filesystem.
30    OriginalName,
31    Title,
32    Alt,
33    /// Whether this file appears in the media library. A file stored for a
34    /// purpose of its own (an import payload, say) is stored but not browsed.
35    InLibrary,
36    CreatedBy,
37    CreatedAt,
38    UpdatedAt,
39    /// The library folder the record sits in; null is the root.
40    FolderId,
41    /// An image's pixel size, read on ingest; null for anything else.
42    Width,
43    Height,
44    /// A JSON bag: orientation, dominant colour, duration, whatever a reader found.
45    Meta,
46}
47
48/// A stored file, as the database holds it.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct MediaRecord {
51    pub id: i64,
52    pub disk: String,
53    pub hash: String,
54    pub size: i64,
55    pub content_type: String,
56    pub original_name: String,
57    pub title: Option<String>,
58    pub alt: Option<String>,
59    pub in_library: bool,
60    pub created_by: Option<i64>,
61    /// The library folder; `None` is the root.
62    pub folder_id: Option<i64>,
63    pub width: Option<i64>,
64    pub height: Option<i64>,
65    /// The meta bag as stored, `{}` when nothing was read.
66    pub meta: serde_json::Value,
67    pub created_at: String,
68}
69
70impl MediaRecord {
71    /// What the file is to a browser: `image`, `video`, `audio`, `document`
72    /// or `other`, from its sniffed content type.
73    pub fn kind(&self) -> crate::Kind {
74        crate::Kind::of(&self.content_type)
75    }
76
77    /// The name shown: the title when set, else the original file name.
78    pub fn display_name(&self) -> &str {
79        match &self.title {
80            Some(t) if !t.trim().is_empty() => t,
81            _ => &self.original_name,
82        }
83    }
84}
85
86/// What a caller supplies to record an ingested blob.
87#[derive(Debug, Clone)]
88pub struct NewMedia {
89    pub disk: String,
90    pub hash: String,
91    pub size: i64,
92    pub content_type: String,
93    /// The sender's filename, kept for display only.
94    pub original_name: String,
95    pub in_library: bool,
96    pub created_by: Option<i64>,
97}
98
99/// What a record may be created with beyond [`NewMedia`]: a folder and an
100/// image's size.
101#[derive(Debug, Clone, Default)]
102pub struct NewMediaExtra {
103    pub folder_id: Option<i64>,
104    pub width: Option<i64>,
105    pub height: Option<i64>,
106    pub meta: Option<serde_json::Value>,
107}
108
109/// Every column a record read selects.
110pub(crate) const COLUMNS: [Media; 15] = [
111    Media::Id,
112    Media::Disk,
113    Media::Hash,
114    Media::Size,
115    Media::ContentType,
116    Media::OriginalName,
117    Media::Title,
118    Media::Alt,
119    Media::InLibrary,
120    Media::CreatedBy,
121    Media::FolderId,
122    Media::Width,
123    Media::Height,
124    Media::Meta,
125    Media::CreatedAt,
126];
127
128/// A record from a row selected with [`COLUMNS`].
129pub(crate) fn from_row(r: &sqlx::any::AnyRow) -> Result<MediaRecord, sqlx::Error> {
130    let meta = r
131        .get_text_opt("meta")?
132        .and_then(|t| serde_json::from_str(&t).ok())
133        .unwrap_or_else(|| serde_json::json!({}));
134    Ok(MediaRecord {
135        id: r.get_int("id")?,
136        disk: r.get_text("disk")?,
137        hash: r.get_text("hash")?,
138        size: r.get_int("size")?,
139        content_type: r.get_text("content_type")?,
140        original_name: r.get_text("original_name")?,
141        title: r.get_text_opt("title")?,
142        alt: r.get_text_opt("alt")?,
143        in_library: r.get_bool("in_library")?,
144        created_by: r.get_int_opt("created_by")?,
145        folder_id: r.get_int_opt("folder_id")?,
146        width: r.get_int_opt("width")?,
147        height: r.get_int_opt("height")?,
148        meta,
149        created_at: r.get_text("created_at")?,
150    })
151}
152
153#[derive(Debug, thiserror::Error)]
154pub enum RecordError {
155    #[error("database error")]
156    Database(#[from] sqlx::Error),
157    #[error(transparent)]
158    Core(#[from] CoreError),
159}
160
161/// Inserts a media record and returns its id.
162///
163/// Two records may share a hash: the same bytes uploaded twice are one blob and
164/// two files, each with its own name, title and links. That is the intended
165/// outcome, not a duplicate to be collapsed.
166pub async fn create(db: &Db, new: NewMedia) -> Result<i64, RecordError> {
167    create_with(db, new, NewMediaExtra::default()).await
168}
169
170/// Creates a record with a folder and an image's size.
171pub async fn create_with(db: &Db, new: NewMedia, extra: NewMediaExtra) -> Result<i64, RecordError> {
172    let now = stamp();
173    let meta = extra
174        .meta
175        .unwrap_or_else(|| serde_json::json!({}))
176        .to_string();
177    let stmt = Query::insert()
178        .into_table(Media::Table)
179        .columns([
180            Media::Disk,
181            Media::Hash,
182            Media::Size,
183            Media::ContentType,
184            Media::OriginalName,
185            Media::InLibrary,
186            Media::CreatedBy,
187            Media::FolderId,
188            Media::Width,
189            Media::Height,
190            Media::Meta,
191            Media::CreatedAt,
192            Media::UpdatedAt,
193        ])
194        .values_panic([
195            new.disk.into(),
196            new.hash.into(),
197            new.size.into(),
198            new.content_type.into(),
199            new.original_name.into(),
200            // The column is the integer polyfill; a boolean would not bind to it
201            // on every backend.
202            i32::from(new.in_library).into(),
203            new.created_by.into(),
204            extra.folder_id.into(),
205            extra.width.into(),
206            extra.height.into(),
207            meta.into(),
208            now.clone().into(),
209            now.into(),
210        ])
211        .to_owned();
212    Ok(insert_returning_id(db, stmt, Media::Id).await?)
213}
214
215fn stamp() -> String {
216    Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true)
217}
218
219// Rendered before the future is made, so the statement builder (not `Send`)
220// never lives inside it and a handler can hold the future across an await.
221fn update_one(
222    db: &Db,
223    id: i64,
224    mut stmt: UpdateStatement,
225) -> impl std::future::Future<Output = Result<(), RecordError>> + Send + '_ {
226    stmt.value(Media::UpdatedAt, stamp())
227        .and_where(Expr::col(Media::Id).eq(id));
228    let (sql, values) = build(db.backend, stmt);
229    async move {
230        bind_values(sqlx::query(&sql), values)
231            .execute(&db.pool)
232            .await?;
233        Ok(())
234    }
235}
236
237/// Sets a record's title and alt text.
238pub async fn update_details(
239    db: &Db,
240    id: i64,
241    title: Option<&str>,
242    alt: Option<&str>,
243) -> Result<(), RecordError> {
244    let stmt = Query::update()
245        .table(Media::Table)
246        .value(Media::Title, title.map(str::to_string))
247        .value(Media::Alt, alt.map(str::to_string))
248        .to_owned();
249    update_one(db, id, stmt).await
250}
251
252/// Renames the file as operators see it; the blob and its URL do not change.
253pub async fn rename(db: &Db, id: i64, original_name: &str) -> Result<(), RecordError> {
254    let stmt = Query::update()
255        .table(Media::Table)
256        .value(Media::OriginalName, original_name)
257        .to_owned();
258    update_one(db, id, stmt).await
259}
260
261/// Points a record at other bytes: a crop or an edit that replaced the file.
262/// The hash, size, type and dimensions change; the metadata read from the
263/// old bytes is dropped, since the new ones carry none.
264pub async fn replace_blob(
265    db: &Db,
266    id: i64,
267    hash: &str,
268    size: i64,
269    content_type: &str,
270    width: Option<i64>,
271    height: Option<i64>,
272) -> Result<(), RecordError> {
273    let stmt = Query::update()
274        .table(Media::Table)
275        .value(Media::Hash, hash)
276        .value(Media::Size, size)
277        .value(Media::ContentType, content_type)
278        .value(Media::Width, width)
279        .value(Media::Height, height)
280        .value(Media::Meta, "{}")
281        .to_owned();
282    update_one(db, id, stmt).await
283}
284
285/// Moves a record into a folder (`None` for the root).
286pub async fn move_to_folder(db: &Db, id: i64, folder_id: Option<i64>) -> Result<(), RecordError> {
287    let stmt = Query::update()
288        .table(Media::Table)
289        .value(Media::FolderId, folder_id)
290        .to_owned();
291    update_one(db, id, stmt).await
292}
293
294/// Shows a record in the library, in `folder`, or takes it out.
295pub async fn set_in_library(
296    db: &Db,
297    id: i64,
298    in_library: bool,
299    folder_id: Option<i64>,
300) -> Result<(), RecordError> {
301    let stmt = Query::update()
302        .table(Media::Table)
303        .value(Media::InLibrary, i32::from(in_library))
304        .value(Media::FolderId, folder_id)
305        .to_owned();
306    update_one(db, id, stmt).await
307}
308
309/// Records an image's size, read after ingest or measured later.
310pub fn set_dimensions(
311    db: &Db,
312    id: i64,
313    width: Option<i64>,
314    height: Option<i64>,
315) -> impl std::future::Future<Output = Result<(), RecordError>> + Send + '_ {
316    let stmt = Query::update()
317        .table(Media::Table)
318        .value(Media::Width, width)
319        .value(Media::Height, height)
320        .to_owned();
321    update_one(db, id, stmt)
322}
323
324/// The record with this hash on this disk, if any.
325pub async fn find_by_hash(
326    db: &Db,
327    disk: &str,
328    hash: &str,
329) -> Result<Option<MediaRecord>, RecordError> {
330    let (sql, values) = build(
331        db.backend,
332        Query::select()
333            .columns(COLUMNS)
334            .from(Media::Table)
335            .and_where(Expr::col(Media::Disk).eq(disk))
336            .and_where(Expr::col(Media::Hash).eq(hash))
337            .limit(1)
338            .to_owned(),
339    );
340    let row = bind_values(sqlx::query(&sql), values)
341        .fetch_optional(&db.pool)
342        .await?;
343    Ok(row.as_ref().map(from_row).transpose()?)
344}
345
346/// Every record with this hash, on any disk.
347pub async fn find_all_by_hash(db: &Db, hash: &str) -> Result<Vec<MediaRecord>, RecordError> {
348    let (sql, values) = build(
349        db.backend,
350        Query::select()
351            .columns(COLUMNS)
352            .from(Media::Table)
353            .and_where(Expr::col(Media::Hash).eq(hash))
354            .to_owned(),
355    );
356    let rows = bind_values(sqlx::query(&sql), values)
357        .fetch_all(&db.pool)
358        .await?;
359    Ok(rows.iter().map(from_row).collect::<Result<_, _>>()?)
360}
361
362/// One record by id, or `None` if it is gone.
363pub async fn find(db: &Db, id: i64) -> Result<Option<MediaRecord>, RecordError> {
364    let (sql, values) = build(
365        db.backend,
366        Query::select()
367            .columns(COLUMNS)
368            .from(Media::Table)
369            .and_where(Expr::col(Media::Id).eq(id))
370            .to_owned(),
371    );
372    let row = bind_values(sqlx::query(&sql), values)
373        .fetch_optional(&db.pool)
374        .await?;
375    Ok(row.as_ref().map(from_row).transpose()?)
376}
377
378/// Deletes a record, returning whether its blob is now unreferenced.
379///
380/// The blob itself is not touched here: a hash may back several records, so
381/// removing bytes is a separate decision from removing a file. `true` means the
382/// caller may collect the blob.
383pub async fn delete(db: &Db, id: i64) -> Result<bool, RecordError> {
384    let Some(record) = find(db, id).await? else {
385        return Ok(false);
386    };
387    let (sql, values) = build(
388        db.backend,
389        Query::delete()
390            .from_table(Media::Table)
391            .and_where(Expr::col(Media::Id).eq(id))
392            .to_owned(),
393    );
394    bind_values(sqlx::query(&sql), values)
395        .execute(&db.pool)
396        .await?;
397
398    let (sql, values) = build(
399        db.backend,
400        Query::select()
401            .expr(Expr::col(Media::Id).count())
402            .from(Media::Table)
403            .and_where(Expr::col(Media::Hash).eq(record.hash))
404            .and_where(Expr::col(Media::Disk).eq(record.disk))
405            .to_owned(),
406    );
407    let remaining: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
408        .fetch_one(&db.pool)
409        .await?
410        .0;
411    Ok(remaining == 0)
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use laterite_core::testing::connect_test;
418
419    /// The guard has to outlive the test: dropping it tears the database down.
420    async fn db() -> (Db, laterite_core::testing::TestGuard) {
421        connect_test(&[crate::migrations::migrations()]).await
422    }
423
424    fn new(hash: &str, name: &str) -> NewMedia {
425        NewMedia {
426            disk: "private".to_string(),
427            hash: hash.to_string(),
428            size: 12,
429            content_type: "image/png".to_string(),
430            original_name: name.to_string(),
431            in_library: false,
432            created_by: None,
433        }
434    }
435
436    #[tokio::test]
437    async fn a_record_keeps_what_the_blob_cannot() {
438        let (db, _guard) = db().await;
439        let id = create(&db, new(&"a".repeat(64), "logo.png")).await.unwrap();
440        let found = find(&db, id).await.unwrap().unwrap();
441
442        assert_eq!(found.hash, "a".repeat(64));
443        assert_eq!(found.disk, "private");
444        // The filename is display metadata: it never reaches the filesystem,
445        // because the storage path is the hash.
446        assert_eq!(found.original_name, "logo.png");
447        assert!(!found.in_library);
448    }
449
450    /// The same bytes uploaded twice are one blob and two files, each with its
451    /// own name and its own links. That is the intended outcome of a stable id
452    /// pointing at a content-addressed blob, not a duplicate to collapse.
453    #[tokio::test]
454    async fn one_blob_can_back_several_records() {
455        let (db, _guard) = db().await;
456        let hash = "b".repeat(64);
457        let first = create(&db, new(&hash, "invoice.pdf")).await.unwrap();
458        let second = create(&db, new(&hash, "receipt.pdf")).await.unwrap();
459
460        assert_ne!(first, second);
461        assert_eq!(find(&db, first).await.unwrap().unwrap().hash, hash);
462        assert_eq!(
463            find(&db, second).await.unwrap().unwrap().original_name,
464            "receipt.pdf"
465        );
466    }
467
468    /// Removing a file is not removing its bytes. Only the last record holding a
469    /// hash releases the blob for collection.
470    #[tokio::test]
471    async fn only_the_last_record_releases_the_blob() {
472        let (db, _guard) = db().await;
473        let hash = "c".repeat(64);
474        let first = create(&db, new(&hash, "one")).await.unwrap();
475        let second = create(&db, new(&hash, "two")).await.unwrap();
476
477        assert!(
478            !delete(&db, first).await.unwrap(),
479            "another record still holds it"
480        );
481        assert!(
482            delete(&db, second).await.unwrap(),
483            "the last one releases it"
484        );
485    }
486
487    /// Dedup is scoped to a disk: identical bytes on a local disk and in a bucket
488    /// are separate physical copies, so deleting one must not free the other.
489    #[tokio::test]
490    async fn a_blob_on_another_disk_is_a_different_blob() {
491        let (db, _guard) = db().await;
492        let hash = "d".repeat(64);
493        let private = create(&db, new(&hash, "here")).await.unwrap();
494        let public = create(
495            &db,
496            NewMedia {
497                disk: "public".to_string(),
498                ..new(&hash, "there")
499            },
500        )
501        .await
502        .unwrap();
503
504        assert!(
505            delete(&db, private).await.unwrap(),
506            "its own disk had no other holder"
507        );
508        assert!(find(&db, public).await.unwrap().is_some());
509    }
510
511    #[tokio::test]
512    async fn deleting_a_record_that_is_gone_says_so() {
513        let (db, _guard) = db().await;
514        assert!(!delete(&db, 4242).await.unwrap());
515        assert!(find(&db, 4242).await.unwrap().is_none());
516    }
517}