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}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::mocks::mock_table_source::MockTableSource;
138    use serde_json::json;
139    use vantage_dataset::prelude::{InsertableValueSet, ReadableValueSet, WritableValueSet};
140
141    type MockTable = Table<MockTableSource, EmptyEntity>;
142
143    fn looks_like_timestamp(v: &serde_json::Value) -> bool {
144        v.as_str()
145            .is_some_and(|s| s.len() >= 20 && s.ends_with('Z') && s.contains('T'))
146    }
147
148    #[tokio::test]
149    async fn stamps_created_and_updated_on_insert() {
150        let src = MockTableSource::new().with_data("t", vec![]).await;
151        let table = MockTable::new("t", src).with_timestamps();
152
153        let id = table
154            .insert_return_id_value(&Record::from(json!({"id": "1", "name": "a"})))
155            .await
156            .unwrap();
157        let row = table.get_value(id).await.unwrap().unwrap();
158        assert!(looks_like_timestamp(&row["created_at"]), "{row:?}");
159        assert!(looks_like_timestamp(&row["updated_at"]), "{row:?}");
160    }
161
162    #[tokio::test]
163    async fn keeps_caller_supplied_created_at() {
164        let src = MockTableSource::new().with_data("t", vec![]).await;
165        let table = MockTable::new("t", src).with_timestamps();
166
167        let id = table
168            .insert_return_id_value(&Record::from(
169                json!({"id": "1", "name": "a", "created_at": "2000-01-01T00:00:00Z"}),
170            ))
171            .await
172            .unwrap();
173        let row = table.get_value(id).await.unwrap().unwrap();
174        // The explicit created_at survives; updated_at is still stamped fresh.
175        assert_eq!(row["created_at"], json!("2000-01-01T00:00:00Z"));
176        assert!(looks_like_timestamp(&row["updated_at"]));
177    }
178
179    #[tokio::test]
180    async fn updates_only_updated_at_on_patch() {
181        let src = MockTableSource::new()
182            .with_data(
183                "t",
184                vec![json!({"id": "1", "name": "a", "created_at": "2000-01-01T00:00:00Z"})],
185            )
186            .await;
187        let table = MockTable::new("t", src).with_timestamps();
188
189        table
190            .patch_value("1", &Record::from(json!({"name": "b"})))
191            .await
192            .unwrap();
193        let row = table.get_value("1".to_string()).await.unwrap().unwrap();
194        // created_at untouched by the update; updated_at freshly stamped.
195        assert_eq!(row["created_at"], json!("2000-01-01T00:00:00Z"));
196        assert!(looks_like_timestamp(&row["updated_at"]));
197        assert_eq!(row["name"], json!("b"));
198    }
199
200    #[tokio::test]
201    async fn custom_column_names() {
202        let src = MockTableSource::new().with_data("t", vec![]).await;
203        let table = MockTable::new("t", src)
204            .with_audit(Timestamps::new().created("inserted").updated("touched"));
205
206        let id = table
207            .insert_return_id_value(&Record::from(json!({"id": "1"})))
208            .await
209            .unwrap();
210        let row = table.get_value(id).await.unwrap().unwrap();
211        assert!(looks_like_timestamp(&row["inserted"]));
212        assert!(looks_like_timestamp(&row["touched"]));
213    }
214}