use std::sync::atomic::Ordering;
use std::sync::Arc;
use chrono::Utc;
use gradatum_cache::CacheKey;
use gradatum_core::config::HistoryConfig;
use gradatum_core::error::GradatumError;
use gradatum_core::frontmatter::Frontmatter;
use gradatum_core::history::sha256_for_history;
use gradatum_core::identity::{ContentHash, NoteId, NoteVersion};
use gradatum_core::note::{EffectiveNote, Note, NoteBody};
use gradatum_core::status::NoteStatus;
use gradatum_core::DocumentStore as _;
use gradatum_storage::Storage as _;
use crate::error::VaultError;
use crate::registry::Vault;
pub const HISTORY_DIR_PREFIX: &str = ".history/";
impl Vault {
pub async fn write_note(
&self,
frontmatter: Frontmatter,
body: String,
) -> Result<Note, VaultError> {
self.write_note_inner(frontmatter, body, NoteId::new())
.await
}
pub async fn write_note_with_id(
&self,
frontmatter: Frontmatter,
body: String,
id: NoteId,
) -> Result<Note, VaultError> {
self.write_note_inner(frontmatter, body, id).await
}
pub(crate) async fn write_note_inner(
&self,
mut frontmatter: Frontmatter,
body: String,
id: NoteId,
) -> Result<Note, VaultError> {
let existing: Option<Note> = match self.read_note(id).await {
Ok(note) => Some(note),
Err(VaultError::Core(gradatum_core::error::GradatumError::NoteNotFound(_))) => None,
Err(other) => return Err(other),
};
if frontmatter.vault_id.0.is_empty() {
frontmatter.vault_id = self.tenant_id.clone();
}
frontmatter.updated = Some(Utc::now());
let body_obj = NoteBody { markdown: body };
let content_hash = ContentHash::compute(&frontmatter, &body_obj.markdown);
let note = Note {
id,
frontmatter,
body: body_obj,
version: NoteVersion::initial(),
content_hash,
integrity_signature: None,
};
let md_path = note_md_relative_path(¬e);
if let Some(ref prev) = existing {
if sha256_for_history(prev) != sha256_for_history(¬e) {
let ts_ms = prev
.frontmatter
.updated
.or(Some(prev.frontmatter.created))
.map(|dt| dt.timestamp_millis())
.unwrap_or_else(|| prev.id.timestamp_ms() as i64);
let tenant = prev.frontmatter.vault_id.as_str();
let id_str = prev.id.to_string();
let history_dir = format!("{}/.history/{}/", tenant, id_str);
let snapshot_path = format!("{}{}.md", history_dir, ts_ms);
let current_md_path = note_md_relative_path(prev);
match self.storage.read(¤t_md_path).await {
Ok(current_bytes) => {
if let Err(e) = self.storage.create_dir(&history_dir).await {
tracing::warn!(
id = %id_str,
history_dir = %history_dir,
err = %e,
"F-40 CoW : impossible de créer .history/ — snapshot ignoré"
);
} else {
if let Err(e) = self.storage.write(&snapshot_path, ¤t_bytes).await
{
tracing::warn!(
id = %id_str,
snapshot_path = %snapshot_path,
err = %e,
"F-40 CoW : écriture snapshot .history/ échouée — snapshot ignoré"
);
} else {
tracing::debug!(
id = %id_str,
snapshot_path = %snapshot_path,
"F-40 CoW : snapshot .history/ créé"
);
let now_ms = Utc::now().timestamp_millis().max(0) as u64;
self.trim_history_to_max(id, &id_str, tenant, now_ms).await;
}
}
}
Err(gradatum_storage::StorageError::NotFound(_)) => {
tracing::warn!(
id = %id_str,
current_md_path = %current_md_path,
"F-40 CoW : fichier source introuvable pour snapshot .history/ — skip"
);
}
Err(e) => {
tracing::warn!(
id = %id_str,
err = %e,
"F-40 CoW : erreur lecture fichier source — snapshot ignoré"
);
}
}
}
}
let md_content = gradatum_markdown::write(¬e)
.map_err(|e| GradatumError::Markdown(format!("sérialisation md: {e}")))?;
self.storage
.write(&md_path, md_content.as_bytes())
.await
.map_err(|e| VaultError::Storage(format!("write md {md_path}: {e}")))?;
self.index.write_note(¬e).await?;
Ok(note)
}
pub async fn read_note(&self, id: NoteId) -> Result<Note, VaultError> {
let vault_id = self.tenant_id.as_str();
let id_str = id.to_string();
let cache_key: CacheKey = (id, 0u64);
let index_for_validator = Arc::clone(&self.index);
let id_for_validator = id;
let cached = self
.cache
.get(cache_key, move |note_id| async move {
index_for_validator
.get_content_hash(note_id)
.await?
.ok_or(GradatumError::NoteNotFound(id_for_validator))
})
.await
.map_err(VaultError::Core)?;
if let Some(effective) = cached {
self.cache_hits.fetch_add(1, Ordering::Relaxed);
return Ok(effective_note_to_note(&effective, id));
}
let record = self
.index
.get_note(vault_id, &id_str)
.await
.map_err(VaultError::Core)?
.ok_or(VaultError::Core(GradatumError::NoteNotFound(id)))?;
let path_no_locus = format!("{}/{}.md", vault_id, id_str);
let path_with_section = format!("{}/{}/{}.md", vault_id, record.section, id_str);
let md_bytes = if self.storage.exists(&path_no_locus).await.unwrap_or(false) {
self.storage
.read(&path_no_locus)
.await
.map_err(|e| VaultError::Storage(format!("read .md {path_no_locus}: {e}")))?
} else {
self.storage
.read(&path_with_section)
.await
.map_err(|e| VaultError::Storage(format!("read .md {path_with_section}: {e}")))?
};
let md_str = String::from_utf8(md_bytes)
.map_err(|e| VaultError::Storage(format!("UTF-8 decode .md {id_str}: {e}")))?;
let parsed =
gradatum_markdown::parse(&md_str).map_err(|e| VaultError::Markdown(e.to_string()))?;
let note = Note {
id,
frontmatter: parsed.frontmatter,
body: parsed.body,
version: NoteVersion::initial(),
content_hash: parsed.content_hash,
integrity_signature: None,
};
let effective = Arc::new(note_to_effective_note(¬e));
self.cache
.insert(cache_key, effective, note.content_hash)
.await;
Ok(note)
}
pub async fn update_status(
&self,
id: NoteId,
target: NoteStatus,
reason: Option<String>,
) -> Result<(), VaultError> {
let note = self.read_note(id).await?;
let current = note.frontmatter.status;
if current == target {
return Ok(());
}
if !current.can_transition_to(target) {
return Err(VaultError::Core(GradatumError::InvalidStatusTransition {
from: current,
to: target,
}));
}
let mut new_fm = note.frontmatter.clone();
new_fm.status = target;
new_fm.status_reason = reason;
new_fm.status_changed = Some(Utc::now());
self.write_note_with_id(new_fm, note.body.markdown, id)
.await?;
Ok(())
}
pub async fn delete_note(&self, id: NoteId) -> Result<(), VaultError> {
let vault_id = self.tenant_id.as_str();
let id_str = id.to_string();
let record = self
.index
.get_note(vault_id, &id_str)
.await
.map_err(VaultError::Core)?
.ok_or(VaultError::Core(GradatumError::NoteNotFound(id)))?;
let path_no_locus = format!("{}/{}.md", vault_id, id_str);
let path_with_section = format!("{}/{}/{}.md", vault_id, record.section, id_str);
let md_path = if self.storage.exists(&path_no_locus).await.unwrap_or(false) {
path_no_locus
} else {
path_with_section
};
self.storage
.delete(&md_path)
.await
.map_err(|e| VaultError::Storage(format!("delete md {md_path}: {e}")))?;
self.purge_history_dir(id, &id_str, vault_id).await;
Ok(())
}
pub async fn history_versions(&self, id: NoteId) -> Result<Vec<i64>, VaultError> {
let tenant = self.tenant_id.as_str();
let id_str = id.to_string();
let history_dir = format!("{}/.history/{}/", tenant, id_str);
let entries = match self.storage.list(&history_dir).await {
Ok(entries) => entries,
Err(gradatum_storage::StorageError::NotFound(_)) => {
return Ok(Vec::new());
}
Err(e) => {
return Err(VaultError::Storage(format!(
"history_versions list {history_dir}: {e}"
)));
}
};
let mut timestamps: Vec<i64> = entries
.iter()
.filter(|e| !e.is_dir && e.path.ends_with(".md"))
.filter_map(|e| {
let basename = e.path.rsplit('/').next()?;
basename.strip_suffix(".md")?.parse::<i64>().ok()
})
.collect();
timestamps.sort_unstable();
Ok(timestamps)
}
pub async fn history_get(&self, id: NoteId, ts_ms: i64) -> Result<Note, VaultError> {
let tenant = self.tenant_id.as_str();
let id_str = id.to_string();
let snapshot_path = format!("{}/.history/{}/{}.md", tenant, id_str, ts_ms);
let bytes =
self.storage.read(&snapshot_path).await.map_err(|e| {
VaultError::Storage(format!("history_get read {snapshot_path}: {e}"))
})?;
let md_str = String::from_utf8(bytes).map_err(|e| {
VaultError::Storage(format!("history_get UTF-8 decode {snapshot_path}: {e}"))
})?;
let parsed =
gradatum_markdown::parse(&md_str).map_err(|e| VaultError::Markdown(e.to_string()))?;
let note = Note {
id,
frontmatter: parsed.frontmatter,
body: parsed.body,
version: NoteVersion::initial(),
content_hash: parsed.content_hash,
integrity_signature: None,
};
Ok(note)
}
async fn trim_history_to_max(&self, id: NoteId, id_str: &str, tenant: &str, now_ms: u64) {
self.apply_history_trim(id, id_str, tenant, &self.config.history, now_ms)
.await;
}
pub async fn apply_history_trim(
&self,
id: NoteId,
id_str: &str,
tenant: &str,
cfg: &HistoryConfig,
now_ms: u64,
) {
let mut versions = match self.history_versions(id).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(
id = %id_str,
err = %e,
"D1/F-32A rétention : impossible de lister .history/ pour bornage"
);
return;
}
};
if let Some(ttl_days) = cfg.ttl_days {
let ttl_ms = u64::from(ttl_days).saturating_mul(24 * 3600 * 1000);
let cutoff_ms = now_ms.saturating_sub(ttl_ms);
let expired: Vec<i64> = versions
.iter()
.copied()
.filter(|&ts| {
u64::try_from(ts).unwrap_or(0) < cutoff_ms
})
.collect();
if !expired.is_empty() {
tracing::debug!(
id = %id_str,
expired = expired.len(),
ttl_days = ttl_days,
"D1/F-32A TTL : suppression snapshots expirés"
);
for ts_ms in &expired {
let snapshot_path = format!("{}/.history/{}/{}.md", tenant, id_str, ts_ms);
if let Err(e) = self.storage.delete(&snapshot_path).await {
tracing::warn!(
id = %id_str,
snapshot_path = %snapshot_path,
err = %e,
"D1/F-32A TTL : échec suppression snapshot expiré — non fatal"
);
}
}
versions.retain(|ts| !expired.contains(ts));
}
}
let effective_max = cfg.max_versions.max(1);
if versions.len() <= effective_max {
return;
}
let to_delete_count = versions.len() - effective_max;
let to_delete = &versions[..to_delete_count];
tracing::debug!(
id = %id_str,
total = versions.len(),
deleting = to_delete_count,
max = effective_max,
"D1/F-32A cap count : suppression snapshots excédentaires"
);
for &ts_ms in to_delete {
let snapshot_path = format!("{}/.history/{}/{}.md", tenant, id_str, ts_ms);
if let Err(e) = self.storage.delete(&snapshot_path).await {
tracing::warn!(
id = %id_str,
snapshot_path = %snapshot_path,
err = %e,
"D1/F-32A cap count : échec suppression snapshot — non fatal"
);
}
}
}
async fn purge_history_dir(&self, _id: NoteId, id_str: &str, tenant: &str) {
let history_dir = format!("{}/.history/{}/", tenant, id_str);
let entries = match self.storage.list(&history_dir).await {
Ok(e) => e,
Err(gradatum_storage::StorageError::NotFound(_)) => {
return;
}
Err(e) => {
tracing::warn!(
id = %id_str,
history_dir = %history_dir,
err = %e,
"D1 purge : impossible de lister .history/ — non fatal"
);
return;
}
};
for entry in entries.iter().filter(|e| !e.is_dir) {
if let Err(e) = self.storage.delete(&entry.path).await {
tracing::warn!(
id = %id_str,
path = %entry.path,
err = %e,
"D1 purge : échec suppression fichier .history/ — non fatal"
);
}
}
if let Err(e) = self.storage.delete(&history_dir).await {
tracing::debug!(
id = %id_str,
history_dir = %history_dir,
err = %e,
"D1 purge : suppression répertoire .history/ — ignorée (peut rester vide)"
);
}
}
}
fn effective_note_to_note(effective: &EffectiveNote, id: NoteId) -> Note {
Note {
id,
frontmatter: effective.frontmatter.clone(),
body: effective.body.clone(),
version: effective.version,
content_hash: effective.content_hash,
integrity_signature: None,
}
}
fn note_to_effective_note(note: &Note) -> EffectiveNote {
EffectiveNote {
id: note.id,
frontmatter: note.frontmatter.clone(),
body: note.body.clone(),
version: note.version,
content_hash: note.content_hash,
}
}
fn note_md_relative_path(note: &Note) -> String {
let tenant = note.frontmatter.vault_id.as_str();
let id_str = note.id.to_string();
match note.frontmatter.locus.as_ref() {
Some(locus) => format!("{}/{}/{}.md", tenant, locus.as_str(), id_str),
None => format!("{}/{}.md", tenant, id_str),
}
}
#[cfg(test)]
mod tests {
use super::*;
use gradatum_core::scope::VaultId;
use gradatum_core::section::Section;
fn build_minimal_frontmatter() -> Frontmatter {
Frontmatter {
schema_version: 1,
vault_id: VaultId::new("main"),
locus: None,
section: Section::Decisions,
status: NoteStatus::Draft,
status_reason: None,
status_changed: None,
tags: Default::default(),
author: None,
created: Utc::now(),
updated: None,
extra: Default::default(),
provenance: None,
forgotten: None,
forgotten_at: None,
forgotten_by: None,
}
}
#[test]
fn note_md_relative_path_no_locus() {
let fm = build_minimal_frontmatter();
let body = NoteBody {
markdown: "test".into(),
};
let hash = ContentHash::compute(&fm, "test");
let id = NoteId::new();
let note = Note {
id,
frontmatter: fm,
body,
version: NoteVersion::initial(),
content_hash: hash,
integrity_signature: None,
};
let path = note_md_relative_path(¬e);
assert!(path.starts_with("main/"));
assert!(path.ends_with(".md"));
}
#[test]
fn note_md_relative_path_with_locus() {
use gradatum_core::scope::LocusId;
let mut fm = build_minimal_frontmatter();
fm.locus = Some(LocusId::new("my-locus"));
let body = NoteBody {
markdown: "test".into(),
};
let hash = ContentHash::compute(&fm, "test");
let id = NoteId::new();
let note = Note {
id,
frontmatter: fm,
body,
version: NoteVersion::initial(),
content_hash: hash,
integrity_signature: None,
};
let path = note_md_relative_path(¬e);
assert!(path.starts_with("main/my-locus/"));
assert!(path.ends_with(".md"));
}
}