1use rusqlite::{params, Connection, OptionalExtension, Row};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct BackupRow {
5 pub backup_id: String,
6 pub harness: String,
7 pub session_id: String,
8 pub project_key: String,
9 pub op_id: Option<String>,
10 pub order: u128,
11 pub file_path: String,
12 pub path_hash: String,
13 pub backup_path: Option<String>,
14 pub kind: String,
15 pub description: String,
16 pub created_at: i64,
17 pub is_tombstone: bool,
18 pub restore_meta: Option<String>,
19}
20
21pub fn upsert_backup(conn: &Connection, row: &BackupRow) -> rusqlite::Result<()> {
22 delete_backup_for_order(
23 conn,
24 &row.harness,
25 &row.session_id,
26 &row.path_hash,
27 row.order,
28 )?;
29 insert_backup(conn, row)
30}
31
32pub fn insert_backup(conn: &Connection, row: &BackupRow) -> rusqlite::Result<()> {
33 let order_blob = row.order.to_be_bytes();
34 conn.execute(
35 "INSERT INTO backups (
36 backup_id, harness, session_id, project_key, op_id, order_blob, file_path,
37 path_hash, backup_path, kind, description, created_at, is_tombstone, restore_meta
38 ) VALUES (
39 ?1, ?2, ?3, ?4, ?5, ?6, ?7,
40 ?8, ?9, ?10, ?11, ?12, ?13, ?14
41 )",
42 params![
43 row.backup_id,
44 row.harness,
45 row.session_id,
46 row.project_key,
47 row.op_id,
48 &order_blob[..],
49 row.file_path,
50 row.path_hash,
51 row.backup_path,
52 row.kind,
53 row.description,
54 row.created_at,
55 row.is_tombstone,
56 row.restore_meta,
57 ],
58 )?;
59
60 Ok(())
61}
62
63pub fn delete_backup_for_order(
64 conn: &Connection,
65 harness: &str,
66 session_id: &str,
67 path_hash: &str,
68 order: u128,
69) -> rusqlite::Result<usize> {
70 let order_blob = order.to_be_bytes();
71 conn.execute(
72 "DELETE FROM backups
73 WHERE harness = ?1 AND session_id = ?2 AND path_hash = ?3 AND order_blob = ?4",
74 params![harness, session_id, path_hash, &order_blob[..]],
75 )
76}
77
78pub fn get_latest_backup(
79 conn: &Connection,
80 harness: &str,
81 session_id: &str,
82 path_hash: &str,
83) -> rusqlite::Result<Option<BackupRow>> {
84 conn.query_row(
85 "SELECT backup_id, harness, session_id, project_key, op_id, order_blob, file_path,
86 path_hash, backup_path, kind, description, created_at, is_tombstone, restore_meta
87 FROM backups
88 WHERE harness = ?1 AND session_id = ?2 AND path_hash = ?3
89 ORDER BY order_blob DESC
90 LIMIT 1",
91 params![harness, session_id, path_hash],
92 map_backup_row,
93 )
94 .optional()
95}
96
97pub fn list_backups(
98 conn: &Connection,
99 harness: &str,
100 session_id: &str,
101 path_hash: &str,
102) -> rusqlite::Result<Vec<BackupRow>> {
103 let mut stmt = conn.prepare(
104 "SELECT backup_id, harness, session_id, project_key, op_id, order_blob, file_path,
105 path_hash, backup_path, kind, description, created_at, is_tombstone, restore_meta
106 FROM backups
107 WHERE harness = ?1 AND session_id = ?2 AND path_hash = ?3
108 ORDER BY order_blob ASC",
109 )?;
110
111 let rows = stmt
112 .query_map(params![harness, session_id, path_hash], map_backup_row)?
113 .collect();
114 rows
115}
116
117pub fn list_backups_by_op(
118 conn: &Connection,
119 harness: &str,
120 session_id: &str,
121 op_id: &str,
122) -> rusqlite::Result<Vec<BackupRow>> {
123 let mut stmt = conn.prepare(
124 "SELECT backup_id, harness, session_id, project_key, op_id, order_blob, file_path,
125 path_hash, backup_path, kind, description, created_at, is_tombstone, restore_meta
126 FROM backups
127 WHERE harness = ?1 AND session_id = ?2 AND op_id = ?3
128 ORDER BY file_path ASC, order_blob ASC",
129 )?;
130
131 let rows = stmt
132 .query_map(params![harness, session_id, op_id], map_backup_row)?
133 .collect();
134 rows
135}
136
137pub fn get_latest_operation_backup(
138 conn: &Connection,
139 harness: &str,
140 session_id: &str,
141) -> rusqlite::Result<Option<BackupRow>> {
142 conn.query_row(
143 "SELECT backup_id, harness, session_id, project_key, op_id, order_blob, file_path,
144 path_hash, backup_path, kind, description, created_at, is_tombstone, restore_meta
145 FROM backups
146 WHERE harness = ?1 AND session_id = ?2 AND op_id IS NOT NULL
147 ORDER BY order_blob DESC
148 LIMIT 1",
149 params![harness, session_id],
150 map_backup_row,
151 )
152 .optional()
153}
154
155pub fn delete_backups_for_path(
156 conn: &Connection,
157 harness: &str,
158 session_id: &str,
159 path_hash: &str,
160) -> rusqlite::Result<usize> {
161 conn.execute(
162 "DELETE FROM backups WHERE harness = ?1 AND session_id = ?2 AND path_hash = ?3",
163 params![harness, session_id, path_hash],
164 )
165}
166
167fn map_backup_row(row: &Row<'_>) -> rusqlite::Result<BackupRow> {
168 let order_blob: Vec<u8> = row.get(5)?;
169 let order = order_from_blob(&order_blob).unwrap_or_default();
170 Ok(BackupRow {
171 backup_id: row.get::<_, Option<String>>(0)?.unwrap_or_default(),
172 harness: row.get(1)?,
173 session_id: row.get(2)?,
174 project_key: row.get(3)?,
175 op_id: row.get(4)?,
176 order,
177 file_path: row.get(6)?,
178 path_hash: row.get(7)?,
179 backup_path: row.get(8)?,
180 kind: row.get(9)?,
181 description: row.get::<_, Option<String>>(10)?.unwrap_or_default(),
182 created_at: row.get(11)?,
183 is_tombstone: row.get::<_, i64>(12)? != 0,
184 restore_meta: row.get(13)?,
185 })
186}
187
188fn order_from_blob(blob: &[u8]) -> Option<u128> {
189 let bytes: [u8; 16] = blob.try_into().ok()?;
190 Some(u128::from_be_bytes(bytes))
191}