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 vantage_types::{Entity, InvariantValue};
14
15use crate::table::{BeforeCx, BeforeFn, Hook, Phase, Table, before_hook};
16use crate::traits::table_source::TableSource;
17
18/// Which columns the audit stamps write. Defaults to `created_at` / `updated_at`.
19#[derive(Clone, Debug)]
20pub struct Timestamps {
21    created_column: String,
22    updated_column: String,
23}
24
25impl Default for Timestamps {
26    fn default() -> Self {
27        Self {
28            created_column: "created_at".to_string(),
29            updated_column: "updated_at".to_string(),
30        }
31    }
32}
33
34impl Timestamps {
35    /// The default `created_at` / `updated_at` pair.
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Override the created-timestamp column name.
41    pub fn created(mut self, column: impl Into<String>) -> Self {
42        self.created_column = column.into();
43        self
44    }
45
46    /// Override the updated-timestamp column name.
47    pub fn updated(mut self, column: impl Into<String>) -> Self {
48        self.updated_column = column.into();
49        self
50    }
51}
52
53impl<T: TableSource, E: Entity<T::Value>> Table<T, E>
54where
55    T::Value: InvariantValue + From<String>,
56{
57    /// Stamp `created_at` on insert and `updated_at` on every write, using the
58    /// default column names.
59    ///
60    /// `created_at` is set only when the inserted record carries none (a
61    /// caller-supplied value is kept, so a backfilled import keeps its real
62    /// timestamps); `updated_at` is always (re)written. Both are RFC 3339 UTC
63    /// strings. The columns need only be nullable `TEXT`.
64    pub fn with_timestamps(self) -> Self {
65        self.with_audit(Timestamps::default())
66    }
67
68    /// [`Self::with_timestamps`] with custom column names.
69    pub fn with_audit(self, columns: Timestamps) -> Self {
70        self.with_hook(Hook::BeforeInsert(
71            Phase::Populate,
72            created_hook::<T>(columns.created_column),
73        ))
74        .with_hook(Hook::BeforeSave(
75            Phase::Populate,
76            updated_hook::<T>(columns.updated_column),
77        ))
78    }
79}
80
81/// Before-insert hook: set `column` to the current time only if it is absent or
82/// null (an explicit value survives).
83fn created_hook<T: TableSource>(column: String) -> BeforeFn<T>
84where
85    T::Value: InvariantValue + From<String>,
86{
87    before_hook(move |cx: BeforeCx<'_, T>| {
88        let column = column.clone();
89        Box::pin(async move {
90            let absent_or_null = match cx.record.get(column.as_str()) {
91                None => true,
92                Some(v) => v.is_null(),
93            };
94            if absent_or_null {
95                cx.record.insert(column, T::Value::from(now_rfc3339()));
96            }
97            Ok(())
98        })
99    })
100}
101
102/// Before-save hook (insert + update): always set `column` to the current time.
103fn updated_hook<T: TableSource>(column: String) -> BeforeFn<T>
104where
105    T::Value: InvariantValue + From<String>,
106{
107    before_hook(move |cx: BeforeCx<'_, T>| {
108        let column = column.clone();
109        Box::pin(async move {
110            cx.record.insert(column, T::Value::from(now_rfc3339()));
111            Ok(())
112        })
113    })
114}
115
116/// Current UTC time as a second-precision RFC 3339 string (`…Z`).
117fn now_rfc3339() -> String {
118    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
119}