1use crate::error::Result;
2use crate::types::CursorState;
3
4use super::{StateConn, StateStore, pg_sql};
5
6impl StateStore {
12 pub fn get(&self, export_name: &str) -> Result<CursorState> {
13 match &self.conn {
14 StateConn::Sqlite(c) => {
15 let mut stmt = c.prepare(
16 "SELECT last_cursor_value, last_run_at FROM export_state WHERE export_name = ?1",
17 )?;
18 let result = stmt.query_row([export_name], |row| {
19 Ok(CursorState {
20 export_name: export_name.to_string(),
21 last_cursor_value: row.get(0)?,
22 last_run_at: row.get(1)?,
23 })
24 });
25 match result {
26 Ok(state) => Ok(state),
27 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(CursorState {
28 export_name: export_name.to_string(),
29 last_cursor_value: None,
30 last_run_at: None,
31 }),
32 Err(e) => Err(e.into()),
33 }
34 }
35 StateConn::Postgres(client) => {
36 let mut c = client.borrow_mut();
37 match c.query_opt(
38 "SELECT last_cursor_value, last_run_at FROM export_state WHERE export_name = $1",
39 &[&export_name],
40 )? {
41 Some(row) => Ok(CursorState {
42 export_name: export_name.to_string(),
43 last_cursor_value: row.get(0),
44 last_run_at: row.get(1),
45 }),
46 None => Ok(CursorState {
47 export_name: export_name.to_string(),
48 last_cursor_value: None,
49 last_run_at: None,
50 }),
51 }
52 }
53 }
54 }
55
56 pub fn update(&self, export_name: &str, cursor_value: &str) -> Result<()> {
57 let now = chrono::Utc::now().to_rfc3339();
58 let sql = "INSERT INTO export_state (export_name, last_cursor_value, last_run_at)
59 VALUES (?1, ?2, ?3)
60 ON CONFLICT(export_name) DO UPDATE SET
61 last_cursor_value = excluded.last_cursor_value,
62 last_run_at = excluded.last_run_at";
63 match &self.conn {
64 StateConn::Sqlite(c) => {
65 c.execute(sql, rusqlite::params![export_name, cursor_value, now])?;
66 }
67 StateConn::Postgres(client) => {
68 let mut c = client.borrow_mut();
69 c.execute(&pg_sql(sql), &[&export_name, &cursor_value, &now])?;
70 }
71 }
72 Ok(())
73 }
74
75 pub fn set_resume_run_id(&self, export_name: &str, run_id: &str) -> Result<()> {
80 let now = chrono::Utc::now().to_rfc3339();
81 let sql = "INSERT INTO export_state (export_name, resume_run_id, last_run_at)
82 VALUES (?1, ?2, ?3)
83 ON CONFLICT(export_name) DO UPDATE SET resume_run_id = excluded.resume_run_id";
84 match &self.conn {
85 StateConn::Sqlite(c) => {
86 c.execute(sql, rusqlite::params![export_name, run_id, now])?;
87 }
88 StateConn::Postgres(client) => {
89 let mut c = client.borrow_mut();
90 c.execute(&pg_sql(sql), &[&export_name, &run_id, &now])?;
91 }
92 }
93 Ok(())
94 }
95
96 pub fn get_resume_run_id(&self, export_name: &str) -> Result<Option<String>> {
98 let sql = "SELECT resume_run_id FROM export_state WHERE export_name = ?1";
99 match &self.conn {
100 StateConn::Sqlite(c) => {
101 let mut stmt = c.prepare(sql)?;
102 let mut rows = stmt.query_map([export_name], |r| r.get::<_, Option<String>>(0))?;
103 Ok(rows.next().transpose()?.flatten())
104 }
105 StateConn::Postgres(client) => {
106 let mut c = client.borrow_mut();
107 let rows = c.query(&pg_sql(sql), &[&export_name])?;
108 Ok(rows.first().and_then(|r| r.get::<_, Option<String>>(0)))
109 }
110 }
111 }
112
113 pub fn clear_resume_run_id(&self, export_name: &str) -> Result<()> {
115 let sql = "UPDATE export_state SET resume_run_id = NULL WHERE export_name = ?1";
116 match &self.conn {
117 StateConn::Sqlite(c) => {
118 c.execute(sql, [export_name])?;
119 }
120 StateConn::Postgres(client) => {
121 let mut c = client.borrow_mut();
122 c.execute(&pg_sql(sql), &[&export_name])?;
123 }
124 }
125 Ok(())
126 }
127
128 pub fn clear_cursor_value(&self, export_name: &str) -> Result<()> {
141 let sql = "UPDATE export_state SET last_cursor_value = NULL WHERE export_name = ?1";
142 match &self.conn {
143 StateConn::Sqlite(c) => {
144 c.execute(sql, [export_name])?;
145 }
146 StateConn::Postgres(client) => {
147 let mut c = client.borrow_mut();
148 c.execute(&pg_sql(sql), &[&export_name])?;
149 }
150 }
151 Ok(())
152 }
153
154 pub fn reset(&self, export_name: &str) -> Result<()> {
161 let sql = "DELETE FROM export_state WHERE export_name = ?1";
162 match &self.conn {
163 StateConn::Sqlite(c) => {
164 c.execute(sql, [export_name])?;
165 }
166 StateConn::Postgres(client) => {
167 let mut c = client.borrow_mut();
168 c.execute(&pg_sql(sql), &[&export_name])?;
169 }
170 }
171 self.delete_progression(export_name)?;
172 Ok(())
173 }
174
175 pub fn list_all(&self) -> Result<Vec<CursorState>> {
176 let sql = "SELECT export_name, last_cursor_value, last_run_at FROM export_state ORDER BY export_name";
177 match &self.conn {
178 StateConn::Sqlite(c) => {
179 let mut stmt = c.prepare(sql)?;
180 let rows = stmt.query_map([], |row| {
181 Ok(CursorState {
182 export_name: row.get(0)?,
183 last_cursor_value: row.get(1)?,
184 last_run_at: row.get(2)?,
185 })
186 })?;
187 rows.collect::<std::result::Result<Vec<_>, _>>()
188 .map_err(Into::into)
189 }
190 StateConn::Postgres(client) => {
191 let mut c = client.borrow_mut();
192 let rows = c.query(sql, &[])?;
193 Ok(rows
194 .iter()
195 .map(|row| CursorState {
196 export_name: row.get(0),
197 last_cursor_value: row.get(1),
198 last_run_at: row.get(2),
199 })
200 .collect())
201 }
202 }
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 fn store() -> StateStore {
211 StateStore::open_in_memory().expect("in-memory store")
212 }
213
214 #[test]
215 fn get_unknown_returns_empty_state() {
216 let s = store();
217 let state = s.get("nonexistent").unwrap();
218 assert!(state.last_cursor_value.is_none());
219 }
220
221 #[test]
222 fn update_then_get_returns_stored_cursor() {
223 let s = store();
224 s.update("orders", "2024-06-01").unwrap();
225 assert_eq!(
226 s.get("orders").unwrap().last_cursor_value.as_deref(),
227 Some("2024-06-01")
228 );
229 }
230
231 #[test]
232 fn update_overwrites_previous_cursor() {
233 let s = store();
234 s.update("orders", "100").unwrap();
235 s.update("orders", "200").unwrap();
236 assert_eq!(
237 s.get("orders").unwrap().last_cursor_value.as_deref(),
238 Some("200")
239 );
240 }
241
242 #[test]
243 fn reset_clears_cursor_state() {
244 let s = store();
245 s.update("orders", "100").unwrap();
246 s.reset("orders").unwrap();
247 assert!(s.get("orders").unwrap().last_cursor_value.is_none());
248 }
249
250 #[test]
251 fn clear_cursor_value_nulls_the_cursor_but_keeps_the_resume_run_id() {
252 let s = store();
257 s.update("orders", "9000000").unwrap();
258 s.set_resume_run_id("orders", "run_2").unwrap();
259 s.clear_cursor_value("orders").unwrap();
260 assert!(
261 s.get("orders").unwrap().last_cursor_value.is_none(),
262 "cursor must be nulled"
263 );
264 assert_eq!(
265 s.get_resume_run_id("orders").unwrap().as_deref(),
266 Some("run_2"),
267 "resume_run_id must survive"
268 );
269 }
270
271 #[test]
272 fn list_all_on_empty_store_returns_empty() {
273 assert!(store().list_all().unwrap().is_empty());
274 }
275
276 #[test]
277 fn list_all_returns_entries_sorted_by_name() {
278 let s = store();
279 s.update("gamma", "3").unwrap();
280 s.update("alpha", "1").unwrap();
281 s.update("beta", "2").unwrap();
282 let all = s.list_all().unwrap();
283 assert_eq!(all[0].export_name, "alpha");
284 assert_eq!(all[2].export_name, "gamma");
285 }
286
287 #[test]
298 fn duplicate_cursor_values_are_stored_as_written() {
299 let s = store();
300 s.update("orders", "2024-06-01T00:00:00Z").unwrap();
301 s.update("orders", "2024-06-01T00:00:00Z").unwrap();
302 assert_eq!(
303 s.get("orders").unwrap().last_cursor_value.as_deref(),
304 Some("2024-06-01T00:00:00Z")
305 );
306 }
307
308 #[test]
312 fn high_precision_timestamp_is_preserved_byte_for_byte() {
313 let s = store();
314 let ts = "2024-06-01T12:34:56.123456789+02:00";
315 s.update("events", ts).unwrap();
316 assert_eq!(
317 s.get("events").unwrap().last_cursor_value.as_deref(),
318 Some(ts)
319 );
320 }
321
322 #[test]
325 fn unicode_and_binary_like_cursor_values_round_trip() {
326 let s = store();
327 let values = [
328 "2024-06-01",
329 "018f1c0b-7a34-7b54-8e16-1c5a9b3f1c2d", "ελληνικά 🚀 cursor",
331 "v\n\t with whitespace",
332 "",
333 ];
334 for v in values {
335 s.update("t", v).unwrap();
336 assert_eq!(
337 s.get("t").unwrap().last_cursor_value.as_deref(),
338 Some(v),
339 "cursor value {v:?} must round-trip exactly"
340 );
341 }
342 }
343
344 #[test]
347 fn reset_clears_cursor_state_completely() {
348 let s = store();
349 s.update("orders", "2024-06-01").unwrap();
350 s.reset("orders").unwrap();
351 let after = s.get("orders").unwrap();
352 assert!(after.last_cursor_value.is_none());
353 assert!(
354 after.last_run_at.is_none(),
355 "reset must clear last_run_at as well"
356 );
357 }
358
359 #[test]
363 fn reset_clears_committed_progression() {
364 let s = store();
365 s.update("orders", "100").unwrap();
366 s.record_committed_incremental("orders", "100", "run-1")
367 .unwrap();
368 s.record_committed_incremental("users", "9", "run-u")
370 .unwrap();
371
372 s.reset("orders").unwrap();
373
374 let p = s.get_progression("orders").unwrap();
375 assert!(
376 p.committed.is_none() && p.verified.is_none(),
377 "reset must clear the export's committed/verified boundary"
378 );
379 assert!(
380 s.get_progression("users").unwrap().committed.is_some(),
381 "reset must not touch another export's progression"
382 );
383 }
384}