Skip to main content

dk_engine/workspace/
overlay.rs

1//! In-memory file overlay with DashMap and async PostgreSQL sync.
2//!
3//! Every write is reflected in a `DashMap` for O(1) reads and is
4//! simultaneously persisted to the `session_overlay_files` table so that
5//! workspaces survive process restarts.
6
7use dashmap::DashMap;
8use dk_core::Result;
9use sha2::{Digest, Sha256};
10use sqlx::PgPool;
11use uuid::Uuid;
12
13// ── Overlay entry ────────────────────────────────────────────────────
14
15/// Represents a single file change within a session overlay.
16#[derive(Debug, Clone)]
17pub enum OverlayEntry {
18    /// File was modified (or newly created with content from the session).
19    Modified { content: Vec<u8>, hash: String },
20    /// File was added (did not exist in the base commit).
21    Added { content: Vec<u8>, hash: String },
22    /// File was deleted from the base tree.
23    Deleted,
24}
25
26impl OverlayEntry {
27    /// Return the content bytes if this entry carries data.
28    pub fn content(&self) -> Option<&[u8]> {
29        match self {
30            Self::Modified { content, .. } | Self::Added { content, .. } => Some(content),
31            Self::Deleted => None,
32        }
33    }
34
35    /// Return the content hash, or `None` for deletions.
36    pub fn hash(&self) -> Option<&str> {
37        match self {
38            Self::Modified { hash, .. } | Self::Added { hash, .. } => Some(hash),
39            Self::Deleted => None,
40        }
41    }
42
43    /// The SQL `change_type` label.
44    fn change_type_str(&self) -> &'static str {
45        match self {
46            Self::Modified { .. } => "modified",
47            Self::Added { .. } => "added",
48            Self::Deleted => "deleted",
49        }
50    }
51}
52
53// ── FileOverlay ──────────────────────────────────────────────────────
54
55/// Concurrent, overlay-based file store for a single workspace.
56///
57/// Reads are lock-free via `DashMap`. Writes are O(1) in memory and
58/// issue a single `INSERT … ON CONFLICT UPDATE` to PostgreSQL.
59pub struct FileOverlay {
60    entries: DashMap<String, OverlayEntry>,
61    workspace_id: Uuid,
62    db: PgPool,
63}
64
65impl FileOverlay {
66    /// Create a new, empty overlay for the given workspace.
67    pub fn new(workspace_id: Uuid, db: PgPool) -> Self {
68        Self {
69            entries: DashMap::new(),
70            workspace_id,
71            db,
72        }
73    }
74
75    /// Get a reference to an overlay entry by path.
76    pub fn get(&self, path: &str) -> Option<dashmap::mapref::one::Ref<'_, String, OverlayEntry>> {
77        self.entries.get(path)
78    }
79
80    /// Check whether the overlay contains an entry for `path`.
81    pub fn contains(&self, path: &str) -> bool {
82        self.entries.contains_key(path)
83    }
84
85    /// Write (or overwrite) a file in the overlay.
86    ///
87    /// `is_new` indicates whether the file did not previously exist in the
88    /// base tree — it controls whether the entry is `Added` vs `Modified`.
89    ///
90    /// The write is persisted to the database before returning.
91    pub async fn write(&self, path: &str, content: Vec<u8>, is_new: bool) -> Result<String> {
92        let hash = format!("{:x}", Sha256::digest(&content));
93
94        let entry = if is_new {
95            OverlayEntry::Added {
96                content: content.clone(),
97                hash: hash.clone(),
98            }
99        } else {
100            OverlayEntry::Modified {
101                content: content.clone(),
102                hash: hash.clone(),
103            }
104        };
105
106        let change_type = entry.change_type_str();
107
108        // Persist to DB
109        sqlx::query(
110            r#"
111            INSERT INTO session_overlay_files (workspace_id, file_path, content, content_hash, change_type)
112            VALUES ($1, $2, $3, $4, $5)
113            ON CONFLICT (workspace_id, file_path) DO UPDATE
114                SET content      = EXCLUDED.content,
115                    content_hash = EXCLUDED.content_hash,
116                    change_type  = EXCLUDED.change_type,
117                    updated_at   = NOW()
118            "#,
119        )
120        .bind(self.workspace_id)
121        .bind(path)
122        .bind(&content)
123        .bind(&hash)
124        .bind(change_type)
125        .execute(&self.db)
126        .await?;
127
128        self.entries.insert(path.to_string(), entry);
129        Ok(hash)
130    }
131
132    /// Mark a file as deleted in the overlay.
133    ///
134    /// The deletion is persisted to the database.
135    pub async fn delete(&self, path: &str) -> Result<()> {
136        let entry = OverlayEntry::Deleted;
137        let change_type = entry.change_type_str();
138
139        sqlx::query(
140            r#"
141            INSERT INTO session_overlay_files (workspace_id, file_path, content, content_hash, change_type)
142            VALUES ($1, $2, '', '', $3)
143            ON CONFLICT (workspace_id, file_path) DO UPDATE
144                SET content      = EXCLUDED.content,
145                    content_hash = EXCLUDED.content_hash,
146                    change_type  = EXCLUDED.change_type,
147                    updated_at   = NOW()
148            "#,
149        )
150        .bind(self.workspace_id)
151        .bind(path)
152        .bind(change_type)
153        .execute(&self.db)
154        .await?;
155
156        self.entries.insert(path.to_string(), entry);
157        Ok(())
158    }
159
160    /// Revert a file in the overlay, removing it from both memory and DB.
161    pub async fn revert(&self, path: &str) -> Result<()> {
162        self.entries.remove(path);
163
164        sqlx::query("DELETE FROM session_overlay_files WHERE workspace_id = $1 AND file_path = $2")
165            .bind(self.workspace_id)
166            .bind(path)
167            .execute(&self.db)
168            .await?;
169
170        Ok(())
171    }
172
173    /// Return a snapshot of all changed paths and their entries.
174    pub fn list_changes(&self) -> Vec<(String, OverlayEntry)> {
175        self.entries
176            .iter()
177            .map(|r| (r.key().clone(), r.value().clone()))
178            .collect()
179    }
180
181    /// Returns just the file paths in the overlay without cloning content.
182    pub fn list_paths(&self) -> Vec<String> {
183        self.entries.iter().map(|r| r.key().clone()).collect()
184    }
185
186    /// Number of entries (files touched) in the overlay.
187    pub fn len(&self) -> usize {
188        self.entries.len()
189    }
190
191    /// Whether the overlay is empty.
192    pub fn is_empty(&self) -> bool {
193        self.entries.is_empty()
194    }
195
196    /// Total bytes stored in the overlay (excluding deleted entries).
197    pub fn total_bytes(&self) -> usize {
198        self.entries
199            .iter()
200            .filter_map(|r| r.value().content().map(|c| c.len()))
201            .sum()
202    }
203
204    /// Restore overlay state from the database.
205    ///
206    /// Used when recovering a workspace after a process restart.
207    pub async fn restore_from_db(&self) -> Result<()> {
208        let rows: Vec<(String, Vec<u8>, String, String)> = sqlx::query_as(
209            r#"
210            SELECT file_path, content, content_hash, change_type
211            FROM session_overlay_files
212            WHERE workspace_id = $1
213            "#,
214        )
215        .bind(self.workspace_id)
216        .fetch_all(&self.db)
217        .await?;
218
219        for (path, content, hash, change_type) in rows {
220            let entry = match change_type.as_str() {
221                "added" => OverlayEntry::Added { content, hash },
222                "deleted" => OverlayEntry::Deleted,
223                _ => OverlayEntry::Modified { content, hash },
224            };
225            self.entries.insert(path, entry);
226        }
227
228        Ok(())
229    }
230}
231
232// ── Test helpers ─────────────────────────────────────────────────────
233
234impl FileOverlay {
235    /// Create an overlay backed only by an in-memory DashMap (no DB).
236    ///
237    /// Intended for unit/integration tests that do not have a PostgreSQL
238    /// connection. Writes via [`write_local`] go straight to the DashMap.
239    #[doc(hidden)]
240    pub fn new_inmemory(workspace_id: Uuid) -> Self {
241        // Build a PgPool that will never actually connect.  We use
242        // connect_lazy with a dummy DSN — it only errors when a query
243        // is executed, and write_local never touches the pool.
244        let opts = sqlx::postgres::PgConnectOptions::new()
245            .host("__nsi_test_dummy__")
246            .port(1);
247        let pool = sqlx::PgPool::connect_lazy_with(opts);
248        Self {
249            entries: DashMap::new(),
250            workspace_id,
251            db: pool,
252        }
253    }
254
255    /// Write a file to the in-memory overlay WITHOUT touching the database.
256    ///
257    /// This is the test-friendly counterpart of [`write`].
258    #[doc(hidden)]
259    pub fn write_local(&self, path: &str, content: Vec<u8>, is_new: bool) -> String {
260        let hash = format!("{:x}", Sha256::digest(&content));
261
262        let entry = if is_new {
263            OverlayEntry::Added {
264                content,
265                hash: hash.clone(),
266            }
267        } else {
268            OverlayEntry::Modified {
269                content,
270                hash: hash.clone(),
271            }
272        };
273
274        self.entries.insert(path.to_string(), entry);
275        hash
276    }
277
278    /// Mark a file as deleted in the in-memory overlay WITHOUT touching DB.
279    #[doc(hidden)]
280    pub fn delete_local(&self, path: &str) {
281        self.entries.insert(path.to_string(), OverlayEntry::Deleted);
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn overlay_entry_content_and_hash() {
291        let entry = OverlayEntry::Modified {
292            content: b"hello".to_vec(),
293            hash: "abc".into(),
294        };
295        assert_eq!(entry.content(), Some(b"hello".as_slice()));
296        assert_eq!(entry.hash(), Some("abc"));
297
298        let deleted = OverlayEntry::Deleted;
299        assert!(deleted.content().is_none());
300        assert!(deleted.hash().is_none());
301    }
302
303    #[test]
304    fn overlay_entry_change_type() {
305        assert_eq!(
306            OverlayEntry::Modified {
307                content: vec![],
308                hash: String::new()
309            }
310            .change_type_str(),
311            "modified"
312        );
313        assert_eq!(
314            OverlayEntry::Added {
315                content: vec![],
316                hash: String::new()
317            }
318            .change_type_str(),
319            "added"
320        );
321        assert_eq!(OverlayEntry::Deleted.change_type_str(), "deleted");
322    }
323}