Skip to main content

vantage_table/table/
audit.rs

1//! Auto-stamping of audit timestamps on write.
2//!
3//! [`Table::with_timestamps`] fills `created_at`/`updated_at` columns from the
4//! wall clock using the before-write hook machinery (see [`crate::table::hooks`]):
5//! `created_at` once, when a row is first inserted and only if the caller left it
6//! empty; `updated_at` on every insert and update. Values are RFC 3339 UTC
7//! strings (e.g. `2026-06-25T12:00:00Z`).
8//!
9//! Like [`crate::table::id_generator`], it leans on the same `From<String>`
10//! value bound, so it works against any backend whose value type can carry a
11//! string — the column itself can be plain `TEXT`.
12
13use std::future::Future;
14use std::pin::Pin;
15use std::sync::Arc;
16
17use vantage_core::Result;
18use vantage_types::{EmptyEntity, Entity, InvariantValue, Record};
19
20use crate::table::{BeforeFn, Hook, Phase, Table};
21use crate::traits::table_source::TableSource;
22
23/// Which columns the audit stamps write. Defaults to `created_at` / `updated_at`.
24#[derive(Clone, Debug)]
25pub struct Timestamps {
26    created_column: String,
27    updated_column: String,
28}
29
30impl Default for Timestamps {
31    fn default() -> Self {
32        Self {
33            created_column: "created_at".to_string(),
34            updated_column: "updated_at".to_string(),
35        }
36    }
37}
38
39impl Timestamps {
40    /// The default `created_at` / `updated_at` pair.
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Override the created-timestamp column name.
46    pub fn created(mut self, column: impl Into<String>) -> Self {
47        self.created_column = column.into();
48        self
49    }
50
51    /// Override the updated-timestamp column name.
52    pub fn updated(mut self, column: impl Into<String>) -> Self {
53        self.updated_column = column.into();
54        self
55    }
56}
57
58impl<T: TableSource, E: Entity<T::Value>> Table<T, E>
59where
60    T::Value: InvariantValue + From<String>,
61{
62    /// Stamp `created_at` on insert and `updated_at` on every write, using the
63    /// default column names.
64    ///
65    /// `created_at` is set only when the inserted record carries none (a
66    /// caller-supplied value is kept, so a backfilled import keeps its real
67    /// timestamps); `updated_at` is always (re)written. Both are RFC 3339 UTC
68    /// strings. The columns need only be nullable `TEXT`.
69    pub fn with_timestamps(self) -> Self {
70        self.with_audit(Timestamps::default())
71    }
72
73    /// [`Self::with_timestamps`] with custom column names.
74    pub fn with_audit(self, columns: Timestamps) -> Self {
75        self.with_hook(Hook::BeforeInsert(
76            Phase::Populate,
77            created_hook::<T>(columns.created_column),
78        ))
79        .with_hook(Hook::BeforeSave(
80            Phase::Populate,
81            updated_hook::<T>(columns.updated_column),
82        ))
83    }
84}
85
86/// Before-insert hook: set `column` to the current time only if it is absent or
87/// null (an explicit value survives).
88fn created_hook<T: TableSource>(column: String) -> BeforeFn<T>
89where
90    T::Value: InvariantValue + From<String>,
91{
92    Arc::new(
93        move |rec: &mut Record<T::Value>,
94              _table: &Table<T, EmptyEntity>|
95              -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
96            let column = column.clone();
97            Box::pin(async move {
98                let absent_or_null = match rec.get(column.as_str()) {
99                    None => true,
100                    Some(v) => v.is_null(),
101                };
102                if absent_or_null {
103                    rec.insert(column, T::Value::from(now_rfc3339()));
104                }
105                Ok(())
106            })
107        },
108    )
109}
110
111/// Before-save hook (insert + update): always set `column` to the current time.
112fn updated_hook<T: TableSource>(column: String) -> BeforeFn<T>
113where
114    T::Value: InvariantValue + From<String>,
115{
116    Arc::new(
117        move |rec: &mut Record<T::Value>,
118              _table: &Table<T, EmptyEntity>|
119              -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
120            let column = column.clone();
121            Box::pin(async move {
122                rec.insert(column, T::Value::from(now_rfc3339()));
123                Ok(())
124            })
125        },
126    )
127}
128
129/// Current UTC time as a second-precision RFC 3339 string (`…Z`).
130fn now_rfc3339() -> String {
131    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
132}