use dashmap::DashMap;
use dk_core::Result;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub enum OverlayEntry {
Modified { content: Vec<u8>, hash: String },
Added { content: Vec<u8>, hash: String },
Deleted,
}
impl OverlayEntry {
pub fn content(&self) -> Option<&[u8]> {
match self {
Self::Modified { content, .. } | Self::Added { content, .. } => Some(content),
Self::Deleted => None,
}
}
pub fn hash(&self) -> Option<&str> {
match self {
Self::Modified { hash, .. } | Self::Added { hash, .. } => Some(hash),
Self::Deleted => None,
}
}
fn change_type_str(&self) -> &'static str {
match self {
Self::Modified { .. } => "modified",
Self::Added { .. } => "added",
Self::Deleted => "deleted",
}
}
}
pub struct FileOverlay {
entries: DashMap<String, OverlayEntry>,
workspace_id: Uuid,
db: PgPool,
}
impl FileOverlay {
pub fn new(workspace_id: Uuid, db: PgPool) -> Self {
Self {
entries: DashMap::new(),
workspace_id,
db,
}
}
pub fn get(&self, path: &str) -> Option<dashmap::mapref::one::Ref<'_, String, OverlayEntry>> {
self.entries.get(path)
}
pub fn contains(&self, path: &str) -> bool {
self.entries.contains_key(path)
}
pub async fn write(&self, path: &str, content: Vec<u8>, is_new: bool) -> Result<String> {
let hash = format!("{:x}", Sha256::digest(&content));
let entry = if is_new {
OverlayEntry::Added {
content: content.clone(),
hash: hash.clone(),
}
} else {
OverlayEntry::Modified {
content: content.clone(),
hash: hash.clone(),
}
};
let change_type = entry.change_type_str();
sqlx::query(
r#"
INSERT INTO session_overlay_files (workspace_id, file_path, content, content_hash, change_type)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (workspace_id, file_path) DO UPDATE
SET content = EXCLUDED.content,
content_hash = EXCLUDED.content_hash,
change_type = EXCLUDED.change_type,
updated_at = NOW()
"#,
)
.bind(self.workspace_id)
.bind(path)
.bind(&content)
.bind(&hash)
.bind(change_type)
.execute(&self.db)
.await?;
self.entries.insert(path.to_string(), entry);
Ok(hash)
}
pub async fn delete(&self, path: &str) -> Result<()> {
let entry = OverlayEntry::Deleted;
let change_type = entry.change_type_str();
sqlx::query(
r#"
INSERT INTO session_overlay_files (workspace_id, file_path, content, content_hash, change_type)
VALUES ($1, $2, '', '', $3)
ON CONFLICT (workspace_id, file_path) DO UPDATE
SET content = EXCLUDED.content,
content_hash = EXCLUDED.content_hash,
change_type = EXCLUDED.change_type,
updated_at = NOW()
"#,
)
.bind(self.workspace_id)
.bind(path)
.bind(change_type)
.execute(&self.db)
.await?;
self.entries.insert(path.to_string(), entry);
Ok(())
}
pub async fn revert(&self, path: &str) -> Result<()> {
self.entries.remove(path);
sqlx::query("DELETE FROM session_overlay_files WHERE workspace_id = $1 AND file_path = $2")
.bind(self.workspace_id)
.bind(path)
.execute(&self.db)
.await?;
Ok(())
}
pub fn list_changes(&self) -> Vec<(String, OverlayEntry)> {
self.entries
.iter()
.map(|r| (r.key().clone(), r.value().clone()))
.collect()
}
pub fn list_paths(&self) -> Vec<String> {
self.entries.iter().map(|r| r.key().clone()).collect()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn total_bytes(&self) -> usize {
self.entries
.iter()
.filter_map(|r| r.value().content().map(|c| c.len()))
.sum()
}
pub async fn restore_from_db(&self) -> Result<()> {
let rows: Vec<(String, Vec<u8>, String, String)> = sqlx::query_as(
r#"
SELECT file_path, content, content_hash, change_type
FROM session_overlay_files
WHERE workspace_id = $1
"#,
)
.bind(self.workspace_id)
.fetch_all(&self.db)
.await?;
for (path, content, hash, change_type) in rows {
let entry = match change_type.as_str() {
"added" => OverlayEntry::Added { content, hash },
"deleted" => OverlayEntry::Deleted,
_ => OverlayEntry::Modified { content, hash },
};
self.entries.insert(path, entry);
}
Ok(())
}
}
impl FileOverlay {
#[doc(hidden)]
pub fn new_inmemory(workspace_id: Uuid) -> Self {
let opts = sqlx::postgres::PgConnectOptions::new()
.host("__nsi_test_dummy__")
.port(1);
let pool = sqlx::PgPool::connect_lazy_with(opts);
Self {
entries: DashMap::new(),
workspace_id,
db: pool,
}
}
#[doc(hidden)]
pub fn write_local(&self, path: &str, content: Vec<u8>, is_new: bool) -> String {
let hash = format!("{:x}", Sha256::digest(&content));
let entry = if is_new {
OverlayEntry::Added {
content,
hash: hash.clone(),
}
} else {
OverlayEntry::Modified {
content,
hash: hash.clone(),
}
};
self.entries.insert(path.to_string(), entry);
hash
}
#[doc(hidden)]
pub fn delete_local(&self, path: &str) {
self.entries.insert(path.to_string(), OverlayEntry::Deleted);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overlay_entry_content_and_hash() {
let entry = OverlayEntry::Modified {
content: b"hello".to_vec(),
hash: "abc".into(),
};
assert_eq!(entry.content(), Some(b"hello".as_slice()));
assert_eq!(entry.hash(), Some("abc"));
let deleted = OverlayEntry::Deleted;
assert!(deleted.content().is_none());
assert!(deleted.hash().is_none());
}
#[test]
fn overlay_entry_change_type() {
assert_eq!(
OverlayEntry::Modified {
content: vec![],
hash: String::new()
}
.change_type_str(),
"modified"
);
assert_eq!(
OverlayEntry::Added {
content: vec![],
hash: String::new()
}
.change_type_str(),
"added"
);
assert_eq!(OverlayEntry::Deleted.change_type_str(), "deleted");
}
}