1use crate::error::Result;
2use crate::types::CursorState;
3
4use super::StateStore;
5
6impl StateStore {
12 pub fn get(&self, export_name: &str) -> Result<CursorState> {
13 Ok(self
14 .query_opt(
15 "SELECT last_cursor_value, last_run_at FROM export_state WHERE export_name = ?1",
16 &[export_name.into()],
17 |r| CursorState {
18 export_name: export_name.to_string(),
19 last_cursor_value: r.opt_text(0),
20 last_run_at: r.opt_text(1),
21 },
22 )?
23 .unwrap_or_else(|| CursorState {
24 export_name: export_name.to_string(),
25 last_cursor_value: None,
26 last_run_at: None,
27 }))
28 }
29
30 pub fn update(&self, export_name: &str, cursor_value: &str) -> Result<()> {
31 let now = chrono::Utc::now().to_rfc3339();
32 let sql = "INSERT INTO export_state (export_name, last_cursor_value, last_run_at)
33 VALUES (?1, ?2, ?3)
34 ON CONFLICT(export_name) DO UPDATE SET
35 last_cursor_value = excluded.last_cursor_value,
36 last_run_at = excluded.last_run_at";
37 self.execute(sql, &[export_name.into(), cursor_value.into(), now.into()])?;
38 Ok(())
39 }
40
41 pub fn set_resume_run_id(&self, export_name: &str, run_id: &str) -> Result<()> {
46 let now = chrono::Utc::now().to_rfc3339();
47 let sql = "INSERT INTO export_state (export_name, resume_run_id, last_run_at)
48 VALUES (?1, ?2, ?3)
49 ON CONFLICT(export_name) DO UPDATE SET resume_run_id = excluded.resume_run_id";
50 self.execute(sql, &[export_name.into(), run_id.into(), now.into()])?;
51 Ok(())
52 }
53
54 pub fn get_resume_run_id(&self, export_name: &str) -> Result<Option<String>> {
56 let sql = "SELECT resume_run_id FROM export_state WHERE export_name = ?1";
57 Ok(self
58 .query_opt(sql, &[export_name.into()], |r| r.opt_text(0))?
59 .flatten())
60 }
61
62 pub fn clear_resume_run_id(&self, export_name: &str) -> Result<()> {
64 self.execute(
65 "UPDATE export_state SET resume_run_id = NULL WHERE export_name = ?1",
66 &[export_name.into()],
67 )?;
68 Ok(())
69 }
70
71 pub fn clear_cursor_value(&self, export_name: &str) -> Result<()> {
84 self.execute(
85 "UPDATE export_state SET last_cursor_value = NULL WHERE export_name = ?1",
86 &[export_name.into()],
87 )?;
88 Ok(())
89 }
90
91 pub fn reset(&self, export_name: &str) -> Result<()> {
98 self.execute(
99 "DELETE FROM export_state WHERE export_name = ?1",
100 &[export_name.into()],
101 )?;
102 self.delete_progression(export_name)?;
103 Ok(())
104 }
105
106 pub fn list_all(&self) -> Result<Vec<CursorState>> {
107 self.query(
108 "SELECT export_name, last_cursor_value, last_run_at FROM export_state ORDER BY export_name",
109 &[],
110 |r| CursorState {
111 export_name: r.text(0),
112 last_cursor_value: r.opt_text(1),
113 last_run_at: r.opt_text(2),
114 },
115 )
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 fn store() -> StateStore {
124 StateStore::open_in_memory().expect("in-memory store")
125 }
126
127 #[test]
128 fn get_unknown_returns_empty_state() {
129 let s = store();
130 let state = s.get("nonexistent").unwrap();
131 assert!(state.last_cursor_value.is_none());
132 }
133
134 #[test]
135 fn update_then_get_returns_stored_cursor() {
136 let s = store();
137 s.update("orders", "2024-06-01").unwrap();
138 assert_eq!(
139 s.get("orders").unwrap().last_cursor_value.as_deref(),
140 Some("2024-06-01")
141 );
142 }
143
144 #[test]
145 fn update_overwrites_previous_cursor() {
146 let s = store();
147 s.update("orders", "100").unwrap();
148 s.update("orders", "200").unwrap();
149 assert_eq!(
150 s.get("orders").unwrap().last_cursor_value.as_deref(),
151 Some("200")
152 );
153 }
154
155 #[test]
156 fn reset_clears_cursor_state() {
157 let s = store();
158 s.update("orders", "100").unwrap();
159 s.reset("orders").unwrap();
160 assert!(s.get("orders").unwrap().last_cursor_value.is_none());
161 }
162
163 #[test]
164 fn clear_cursor_value_nulls_the_cursor_but_keeps_the_resume_run_id() {
165 let s = store();
170 s.update("orders", "9000000").unwrap();
171 s.set_resume_run_id("orders", "run_2").unwrap();
172 s.clear_cursor_value("orders").unwrap();
173 assert!(
174 s.get("orders").unwrap().last_cursor_value.is_none(),
175 "cursor must be nulled"
176 );
177 assert_eq!(
178 s.get_resume_run_id("orders").unwrap().as_deref(),
179 Some("run_2"),
180 "resume_run_id must survive"
181 );
182 }
183
184 #[test]
185 fn list_all_on_empty_store_returns_empty() {
186 assert!(store().list_all().unwrap().is_empty());
187 }
188
189 #[test]
190 fn list_all_returns_entries_sorted_by_name() {
191 let s = store();
192 s.update("gamma", "3").unwrap();
193 s.update("alpha", "1").unwrap();
194 s.update("beta", "2").unwrap();
195 let all = s.list_all().unwrap();
196 assert_eq!(all[0].export_name, "alpha");
197 assert_eq!(all[2].export_name, "gamma");
198 }
199
200 #[test]
211 fn duplicate_cursor_values_are_stored_as_written() {
212 let s = store();
213 s.update("orders", "2024-06-01T00:00:00Z").unwrap();
214 s.update("orders", "2024-06-01T00:00:00Z").unwrap();
215 assert_eq!(
216 s.get("orders").unwrap().last_cursor_value.as_deref(),
217 Some("2024-06-01T00:00:00Z")
218 );
219 }
220
221 #[test]
225 fn high_precision_timestamp_is_preserved_byte_for_byte() {
226 let s = store();
227 let ts = "2024-06-01T12:34:56.123456789+02:00";
228 s.update("events", ts).unwrap();
229 assert_eq!(
230 s.get("events").unwrap().last_cursor_value.as_deref(),
231 Some(ts)
232 );
233 }
234
235 #[test]
238 fn unicode_and_binary_like_cursor_values_round_trip() {
239 let s = store();
240 let values = [
241 "2024-06-01",
242 "018f1c0b-7a34-7b54-8e16-1c5a9b3f1c2d", "ελληνικά 🚀 cursor",
244 "v\n\t with whitespace",
245 "",
246 ];
247 for v in values {
248 s.update("t", v).unwrap();
249 assert_eq!(
250 s.get("t").unwrap().last_cursor_value.as_deref(),
251 Some(v),
252 "cursor value {v:?} must round-trip exactly"
253 );
254 }
255 }
256
257 #[test]
260 fn reset_clears_cursor_state_completely() {
261 let s = store();
262 s.update("orders", "2024-06-01").unwrap();
263 s.reset("orders").unwrap();
264 let after = s.get("orders").unwrap();
265 assert!(after.last_cursor_value.is_none());
266 assert!(
267 after.last_run_at.is_none(),
268 "reset must clear last_run_at as well"
269 );
270 }
271
272 #[test]
276 fn reset_clears_committed_progression() {
277 let s = store();
278 s.update("orders", "100").unwrap();
279 s.record_committed_incremental("orders", "100", "run-1")
280 .unwrap();
281 s.record_committed_incremental("users", "9", "run-u")
283 .unwrap();
284
285 s.reset("orders").unwrap();
286
287 let p = s.get_progression("orders").unwrap();
288 assert!(
289 p.committed.is_none() && p.verified.is_none(),
290 "reset must clear the export's committed/verified boundary"
291 );
292 assert!(
293 s.get_progression("users").unwrap().committed.is_some(),
294 "reset must not touch another export's progression"
295 );
296 }
297}