1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4use super::{Comment, Database, ProjectLabel, ProjectMember, ProjectTeam, Relation};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct Cycle {
8 pub id: String,
9 pub workspace_id: String,
10 pub team_id: String,
11 pub team_key: String,
12 pub number: i32,
13 pub name: Option<String>,
14 pub starts_at: Option<String>,
15 pub ends_at: Option<String>,
16 pub completed_at: Option<String>,
17 pub archived_at: Option<String>,
18 pub created_at: String,
19 pub updated_at: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct IssueSyncRef {
24 pub id: String,
25 pub identifier: String,
26}
27
28struct SyncFamilyUpdate<'a> {
29 workspace_id: &'a str,
30 team_key: &'a str,
31 family: &'a str,
32 status: &'a str,
33 cursor: Option<&'a str>,
34 page_size: Option<usize>,
35 sync_token: &'a str,
36 error: Option<&'a str>,
37}
38
39impl Database {
40 pub fn upsert_issue_label_page(
41 &self,
42 issue_id: &str,
43 label_ids: &[String],
44 sync_token: &str,
45 ) -> Result<()> {
46 self.with_conn(|conn| {
47 let mut stmt = conn.prepare(
48 "INSERT INTO issue_labels (issue_id, label_id, sync_token)
49 VALUES (?1, ?2, ?3)
50 ON CONFLICT(issue_id, label_id) DO UPDATE SET
51 sync_token=excluded.sync_token",
52 )?;
53 for label_id in label_ids {
54 let exists: i64 = conn.query_row(
55 "SELECT COUNT(*) FROM labels WHERE id = ?1",
56 rusqlite::params![label_id],
57 |row| row.get(0),
58 )?;
59 if exists > 0 {
60 stmt.execute(rusqlite::params![issue_id, label_id, sync_token])?;
61 }
62 }
63 Ok(())
64 })
65 }
66
67 pub fn complete_issue_label_sync(&self, issue_id: &str, sync_token: &str) -> Result<usize> {
68 self.with_conn(|conn| {
69 Ok(conn.execute(
70 "DELETE FROM issue_labels
71 WHERE issue_id = ?1 AND COALESCE(sync_token, '') <> ?2",
72 rusqlite::params![issue_id, sync_token],
73 )?)
74 })
75 }
76
77 pub fn mark_project_sync_token(&self, project_id: &str, sync_token: &str) -> Result<()> {
78 self.with_conn(|conn| {
79 conn.execute(
80 "UPDATE projects SET sync_token = ?2 WHERE id = ?1",
81 rusqlite::params![project_id, sync_token],
82 )?;
83 Ok(())
84 })
85 }
86
87 pub fn list_project_ids_for_sync_token(
88 &self,
89 workspace_id: &str,
90 sync_token: &str,
91 after_id: Option<&str>,
92 limit: usize,
93 ) -> Result<Vec<String>> {
94 self.with_conn(|conn| {
95 let mut stmt = conn.prepare(
96 "SELECT id FROM projects
97 WHERE workspace_id = ?1 AND sync_token = ?2
98 AND (?3 IS NULL OR id > ?3)
99 ORDER BY id LIMIT ?4",
100 )?;
101 let rows = stmt.query_map(
102 rusqlite::params![workspace_id, sync_token, after_id, limit as i64],
103 |row| row.get::<_, String>(0),
104 )?;
105 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
106 })
107 }
108
109 pub fn reconcile_workspace_projects(
110 &self,
111 workspace_id: &str,
112 sync_token: &str,
113 ) -> Result<usize> {
114 self.with_conn(|conn| {
115 Ok(conn.execute(
116 "DELETE FROM projects
117 WHERE workspace_id = ?1 AND COALESCE(sync_token, '') <> ?2",
118 rusqlite::params![workspace_id, sync_token],
119 )?)
120 })
121 }
122
123 pub fn reconcile_team_projects(
124 &self,
125 workspace_id: &str,
126 team_key: &str,
127 sync_token: &str,
128 ) -> Result<usize> {
129 self.with_conn(|conn| {
130 let tx = conn.unchecked_transaction()?;
131 tx.execute(
132 "DELETE FROM project_teams
133 WHERE team_key = ?1 AND COALESCE(sync_token, '') <> ?2",
134 rusqlite::params![team_key, sync_token],
135 )?;
136 let changed = tx.execute(
137 "DELETE FROM projects
138 WHERE workspace_id = ?1
139 AND NOT EXISTS (
140 SELECT 1 FROM project_teams pt WHERE pt.project_id = projects.id
141 )",
142 rusqlite::params![workspace_id],
143 )?;
144 tx.commit()?;
145 Ok(changed)
146 })
147 }
148
149 pub fn upsert_project_team_page(
150 &self,
151 project_id: &str,
152 teams: &[ProjectTeam],
153 sync_token: &str,
154 ) -> Result<()> {
155 self.with_conn(|conn| {
156 let mut stmt = conn.prepare(
157 "INSERT INTO project_teams
158 (project_id, team_id, team_key, team_name, sync_token)
159 VALUES (?1, ?2, ?3, ?4, ?5)
160 ON CONFLICT(project_id, team_id) DO UPDATE SET
161 team_key=excluded.team_key,
162 team_name=excluded.team_name,
163 sync_token=excluded.sync_token",
164 )?;
165 for team in teams {
166 stmt.execute(rusqlite::params![
167 project_id, team.id, team.key, team.name, sync_token,
168 ])?;
169 }
170 Ok(())
171 })
172 }
173
174 pub fn complete_project_team_sync(&self, project_id: &str, sync_token: &str) -> Result<usize> {
175 self.with_conn(|conn| {
176 Ok(conn.execute(
177 "DELETE FROM project_teams
178 WHERE project_id = ?1 AND COALESCE(sync_token, '') <> ?2",
179 rusqlite::params![project_id, sync_token],
180 )?)
181 })
182 }
183
184 pub fn upsert_project_member_page(
185 &self,
186 project_id: &str,
187 members: &[ProjectMember],
188 sync_token: &str,
189 ) -> Result<()> {
190 self.with_conn(|conn| {
191 let mut stmt = conn.prepare(
192 "INSERT INTO project_members (project_id, user_id, user_name, sync_token)
193 VALUES (?1, ?2, ?3, ?4)
194 ON CONFLICT(project_id, user_id) DO UPDATE SET
195 user_name=excluded.user_name,
196 sync_token=excluded.sync_token",
197 )?;
198 for member in members {
199 stmt.execute(rusqlite::params![
200 project_id,
201 member.id,
202 member.name,
203 sync_token,
204 ])?;
205 }
206 Ok(())
207 })
208 }
209
210 pub fn complete_project_member_sync(
211 &self,
212 project_id: &str,
213 sync_token: &str,
214 ) -> Result<usize> {
215 self.with_conn(|conn| {
216 Ok(conn.execute(
217 "DELETE FROM project_members
218 WHERE project_id = ?1 AND COALESCE(sync_token, '') <> ?2",
219 rusqlite::params![project_id, sync_token],
220 )?)
221 })
222 }
223
224 pub fn upsert_project_label_page(
225 &self,
226 project_id: &str,
227 labels: &[ProjectLabel],
228 sync_token: &str,
229 ) -> Result<()> {
230 self.with_conn(|conn| {
231 let mut stmt = conn.prepare(
232 "INSERT INTO project_labels
233 (project_id, label_id, label_name, color, description, sync_token)
234 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
235 ON CONFLICT(project_id, label_id) DO UPDATE SET
236 label_name=excluded.label_name,
237 color=excluded.color,
238 description=excluded.description,
239 sync_token=excluded.sync_token",
240 )?;
241 for label in labels {
242 stmt.execute(rusqlite::params![
243 project_id,
244 label.id,
245 label.name,
246 label.color,
247 label.description,
248 sync_token,
249 ])?;
250 }
251 Ok(())
252 })
253 }
254
255 pub fn complete_project_label_sync(&self, project_id: &str, sync_token: &str) -> Result<usize> {
256 self.with_conn(|conn| {
257 Ok(conn.execute(
258 "DELETE FROM project_labels
259 WHERE project_id = ?1 AND COALESCE(sync_token, '') <> ?2",
260 rusqlite::params![project_id, sync_token],
261 )?)
262 })
263 }
264
265 pub fn mark_project_milestone_sync_token(
266 &self,
267 milestone_id: &str,
268 sync_token: &str,
269 ) -> Result<()> {
270 self.with_conn(|conn| {
271 conn.execute(
272 "UPDATE project_milestones SET sync_token = ?2 WHERE id = ?1",
273 rusqlite::params![milestone_id, sync_token],
274 )?;
275 Ok(())
276 })
277 }
278
279 pub fn reconcile_project_milestones_by_token(
280 &self,
281 project_id: &str,
282 sync_token: &str,
283 ) -> Result<usize> {
284 self.with_conn(|conn| {
285 Ok(conn.execute(
286 "DELETE FROM project_milestones
287 WHERE project_id = ?1 AND COALESCE(sync_token, '') <> ?2",
288 rusqlite::params![project_id, sync_token],
289 )?)
290 })
291 }
292
293 pub fn mark_label_sync_token(&self, label_id: &str, sync_token: &str) -> Result<()> {
294 self.with_conn(|conn| {
295 conn.execute(
296 "UPDATE labels SET sync_token = ?2 WHERE id = ?1",
297 rusqlite::params![label_id, sync_token],
298 )?;
299 Ok(())
300 })
301 }
302
303 pub fn reconcile_label_sync(&self, workspace_id: &str, sync_token: &str) -> Result<usize> {
304 self.with_conn(|conn| {
305 Ok(conn.execute(
306 "DELETE FROM labels
307 WHERE workspace_id = ?1 AND COALESCE(sync_token, '') <> ?2",
308 rusqlite::params![workspace_id, sync_token],
309 )?)
310 })
311 }
312
313 pub fn mark_issue_sync_token(&self, issue_id: &str, sync_token: &str) -> Result<()> {
314 self.with_conn(|conn| {
315 conn.execute(
316 "UPDATE issues SET sync_token = ?2 WHERE id = ?1",
317 rusqlite::params![issue_id, sync_token],
318 )?;
319 Ok(())
320 })
321 }
322
323 pub fn list_issue_sync_refs(
324 &self,
325 workspace_id: &str,
326 team_key: &str,
327 sync_token: &str,
328 after_id: Option<&str>,
329 limit: usize,
330 ) -> Result<Vec<IssueSyncRef>> {
331 self.with_conn(|conn| {
332 let mut stmt = conn.prepare(
333 "SELECT id, identifier FROM issues
334 WHERE workspace_id = ?1 AND team_key = ?2 AND sync_token = ?3
335 AND (?4 IS NULL OR id > ?4)
336 ORDER BY id LIMIT ?5",
337 )?;
338 let rows = stmt.query_map(
339 rusqlite::params![workspace_id, team_key, sync_token, after_id, limit as i64],
340 |row| {
341 Ok(IssueSyncRef {
342 id: row.get(0)?,
343 identifier: row.get(1)?,
344 })
345 },
346 )?;
347 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
348 })
349 }
350
351 pub fn reconcile_full_issue_sync(
352 &self,
353 workspace_id: &str,
354 team_key: &str,
355 sync_token: &str,
356 ) -> Result<usize> {
357 self.with_conn(|conn| {
358 Ok(conn.execute(
359 "DELETE FROM issues
360 WHERE workspace_id = ?1 AND team_key = ?2
361 AND COALESCE(sync_token, '') <> ?3",
362 rusqlite::params![workspace_id, team_key, sync_token],
363 )?)
364 })
365 }
366
367 pub fn upsert_relation_page(
368 &self,
369 issue_id: &str,
370 relations: &[Relation],
371 sync_token: &str,
372 ) -> Result<()> {
373 self.with_conn(|conn| {
374 let tx = conn.unchecked_transaction()?;
375 let mut stmt = tx.prepare(
376 "INSERT INTO issue_relations
377 (id, issue_id, related_issue_id, related_issue_identifier, relation_type, sync_token)
378 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
379 ON CONFLICT(id) DO UPDATE SET
380 issue_id=excluded.issue_id,
381 related_issue_id=excluded.related_issue_id,
382 related_issue_identifier=excluded.related_issue_identifier,
383 relation_type=excluded.relation_type,
384 sync_token=excluded.sync_token",
385 )?;
386 for relation in relations {
387 stmt.execute(rusqlite::params![
388 relation.id,
389 issue_id,
390 relation.related_issue_id,
391 relation.related_issue_identifier,
392 relation.relation_type,
393 sync_token,
394 ])?;
395 }
396 drop(stmt);
397 tx.commit()?;
398 Ok(())
399 })
400 }
401
402 pub fn complete_relation_sync(&self, issue_id: &str, sync_token: &str) -> Result<usize> {
403 self.with_conn(|conn| {
404 Ok(conn.execute(
405 "DELETE FROM issue_relations
406 WHERE issue_id = ?1 AND COALESCE(sync_token, '') <> ?2",
407 rusqlite::params![issue_id, sync_token],
408 )?)
409 })
410 }
411
412 pub fn upsert_comment_page(
413 &self,
414 issue_id: &str,
415 workspace_id: &str,
416 comments: &[Comment],
417 sync_token: &str,
418 ) -> Result<()> {
419 self.with_conn(|conn| {
420 let tx = conn.unchecked_transaction()?;
421 let mut stmt = tx.prepare(
422 "INSERT INTO comments
423 (id, issue_id, body, user_name, created_at, workspace_id,
424 updated_at, parent_id, url, sync_token)
425 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
426 ON CONFLICT(id) DO UPDATE SET
427 issue_id=excluded.issue_id,
428 body=excluded.body,
429 user_name=excluded.user_name,
430 created_at=excluded.created_at,
431 workspace_id=excluded.workspace_id,
432 updated_at=excluded.updated_at,
433 parent_id=excluded.parent_id,
434 url=excluded.url,
435 sync_token=excluded.sync_token",
436 )?;
437 for comment in comments {
438 stmt.execute(rusqlite::params![
439 comment.id,
440 issue_id,
441 comment.body,
442 comment.user_name,
443 comment.created_at,
444 workspace_id,
445 comment.updated_at,
446 comment.parent_id,
447 comment.url,
448 sync_token,
449 ])?;
450 }
451 drop(stmt);
452 tx.commit()?;
453 Ok(())
454 })
455 }
456
457 pub fn complete_comment_sync(
458 &self,
459 issue_id: &str,
460 workspace_id: &str,
461 sync_token: &str,
462 ) -> Result<usize> {
463 self.with_conn(|conn| {
464 Ok(conn.execute(
465 "DELETE FROM comments
466 WHERE issue_id = ?1 AND workspace_id = ?2
467 AND COALESCE(sync_token, '') <> ?3",
468 rusqlite::params![issue_id, workspace_id, sync_token],
469 )?)
470 })
471 }
472
473 pub fn upsert_cycle(&self, cycle: &Cycle, sync_token: &str) -> Result<()> {
474 self.with_conn(|conn| {
475 conn.execute(
476 "INSERT INTO cycles (
477 id, workspace_id, team_id, team_key, number, name, starts_at,
478 ends_at, completed_at, archived_at, created_at, updated_at,
479 sync_token, synced_at
480 ) VALUES (
481 ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13,
482 datetime('now')
483 ) ON CONFLICT(id) DO UPDATE SET
484 workspace_id=excluded.workspace_id,
485 team_id=excluded.team_id,
486 team_key=excluded.team_key,
487 number=excluded.number,
488 name=excluded.name,
489 starts_at=excluded.starts_at,
490 ends_at=excluded.ends_at,
491 completed_at=excluded.completed_at,
492 archived_at=excluded.archived_at,
493 created_at=excluded.created_at,
494 updated_at=excluded.updated_at,
495 sync_token=excluded.sync_token,
496 synced_at=datetime('now')",
497 rusqlite::params![
498 cycle.id,
499 cycle.workspace_id,
500 cycle.team_id,
501 cycle.team_key,
502 cycle.number,
503 cycle.name,
504 cycle.starts_at,
505 cycle.ends_at,
506 cycle.completed_at,
507 cycle.archived_at,
508 cycle.created_at,
509 cycle.updated_at,
510 sync_token,
511 ],
512 )?;
513 Ok(())
514 })
515 }
516
517 pub fn reconcile_cycles(
518 &self,
519 workspace_id: &str,
520 team_key: &str,
521 sync_token: &str,
522 ) -> Result<usize> {
523 self.with_conn(|conn| {
524 let tx = conn.unchecked_transaction()?;
525 tx.execute(
526 "UPDATE issues SET cycle_id = NULL, cycle_name = NULL
527 WHERE workspace_id = ?1 AND cycle_id IN (
528 SELECT id FROM cycles
529 WHERE workspace_id = ?1 AND team_key = ?2
530 AND COALESCE(sync_token, '') <> ?3
531 )",
532 rusqlite::params![workspace_id, team_key, sync_token],
533 )?;
534 let changed = tx.execute(
535 "DELETE FROM cycles
536 WHERE workspace_id = ?1 AND team_key = ?2
537 AND COALESCE(sync_token, '') <> ?3",
538 rusqlite::params![workspace_id, team_key, sync_token],
539 )?;
540 tx.commit()?;
541 Ok(changed)
542 })
543 }
544
545 pub fn mark_sync_family_running(
546 &self,
547 workspace_id: &str,
548 team_key: &str,
549 family: &str,
550 cursor: Option<&str>,
551 page_size: Option<usize>,
552 sync_token: &str,
553 ) -> Result<()> {
554 self.set_sync_family_state(SyncFamilyUpdate {
555 workspace_id,
556 team_key,
557 family,
558 status: "running",
559 cursor,
560 page_size,
561 sync_token,
562 error: None,
563 })
564 }
565
566 pub fn mark_sync_family_complete(
567 &self,
568 workspace_id: &str,
569 team_key: &str,
570 family: &str,
571 page_size: Option<usize>,
572 sync_token: &str,
573 ) -> Result<()> {
574 self.set_sync_family_state(SyncFamilyUpdate {
575 workspace_id,
576 team_key,
577 family,
578 status: "complete",
579 cursor: None,
580 page_size,
581 sync_token,
582 error: None,
583 })
584 }
585
586 pub fn mark_sync_family_failed(
587 &self,
588 workspace_id: &str,
589 team_key: &str,
590 family: &str,
591 sync_token: &str,
592 error: &str,
593 ) -> Result<()> {
594 self.set_sync_family_state(SyncFamilyUpdate {
595 workspace_id,
596 team_key,
597 family,
598 status: "failed",
599 cursor: None,
600 page_size: None,
601 sync_token,
602 error: Some(error),
603 })
604 }
605
606 fn set_sync_family_state(&self, state: SyncFamilyUpdate<'_>) -> Result<()> {
607 self.with_conn(|conn| {
608 conn.execute(
609 "INSERT INTO sync_family_state (
610 workspace_id, team_key, family, status, cursor, page_size,
611 sync_token, error, updated_at
612 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'))
613 ON CONFLICT(workspace_id, team_key, family) DO UPDATE SET
614 status=excluded.status,
615 cursor=excluded.cursor,
616 page_size=excluded.page_size,
617 sync_token=excluded.sync_token,
618 error=excluded.error,
619 updated_at=datetime('now')",
620 rusqlite::params![
621 state.workspace_id,
622 state.team_key,
623 state.family,
624 state.status,
625 state.cursor,
626 state.page_size.map(|value| value as i64),
627 state.sync_token,
628 state.error,
629 ],
630 )?;
631 Ok(())
632 })
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use crate::db::test_helpers::{make_issue, test_db};
640
641 #[test]
642 fn page_tokens_preserve_old_comments_until_completion() {
643 let (db, _dir) = test_db();
644 let issue = make_issue("ENG-1", "ENG");
645 db.upsert_issue(&issue).unwrap();
646 let old = Comment {
647 id: "old".into(),
648 issue_id: issue.id.clone(),
649 body: "old body".into(),
650 user_name: None,
651 created_at: "2026-01-01T00:00:00Z".into(),
652 updated_at: None,
653 parent_id: None,
654 url: None,
655 workspace_id: "default".into(),
656 };
657 db.replace_issue_comments(&issue.id, "default", &[old])
658 .unwrap();
659 let new = Comment {
660 id: "new".into(),
661 issue_id: issue.id.clone(),
662 body: "new body".into(),
663 user_name: None,
664 created_at: "2026-01-02T00:00:00Z".into(),
665 updated_at: None,
666 parent_id: None,
667 url: None,
668 workspace_id: "default".into(),
669 };
670 db.upsert_comment_page(&issue.id, "default", &[new], "run-1")
671 .unwrap();
672 assert_eq!(db.get_comments(&issue.id).unwrap().len(), 2);
673 db.complete_comment_sync(&issue.id, "default", "run-1")
674 .unwrap();
675 let comments = db.get_comments(&issue.id).unwrap();
676 assert_eq!(comments.len(), 1);
677 assert_eq!(comments[0].id, "new");
678 }
679
680 #[test]
681 fn restarting_after_partial_persistence_is_idempotent() {
682 let (db, _dir) = test_db();
683 let issue = make_issue("ENG-2", "ENG");
684 db.upsert_issue(&issue).unwrap();
685 let comment = |id: &str| Comment {
686 id: id.into(),
687 issue_id: issue.id.clone(),
688 body: id.into(),
689 user_name: None,
690 created_at: "2026-01-01T00:00:00Z".into(),
691 updated_at: None,
692 parent_id: None,
693 url: None,
694 workspace_id: "default".into(),
695 };
696
697 db.upsert_comment_page(&issue.id, "default", &[comment("one")], "interrupted")
698 .unwrap();
699 db.upsert_comment_page(
700 &issue.id,
701 "default",
702 &[comment("one"), comment("two")],
703 "resumed",
704 )
705 .unwrap();
706 db.complete_comment_sync(&issue.id, "default", "resumed")
707 .unwrap();
708
709 let comments = db.get_comments(&issue.id).unwrap();
710 assert_eq!(
711 comments
712 .iter()
713 .map(|comment| comment.id.as_str())
714 .collect::<Vec<_>>(),
715 ["one", "two"]
716 );
717 }
718
719 #[test]
720 fn cycle_membership_round_trips_and_reconciles_after_complete_sync() {
721 let (db, _dir) = test_db();
722 let cycle = Cycle {
723 id: "cycle-1".into(),
724 workspace_id: "default".into(),
725 team_id: "team-1".into(),
726 team_key: "ENG".into(),
727 number: 42,
728 name: Some("Launch".into()),
729 starts_at: Some("2026-01-01T00:00:00Z".into()),
730 ends_at: Some("2026-01-14T00:00:00Z".into()),
731 completed_at: None,
732 archived_at: Some("2026-02-01T00:00:00Z".into()),
733 created_at: "2025-12-01T00:00:00Z".into(),
734 updated_at: "2026-02-01T00:00:00Z".into(),
735 };
736 db.upsert_cycle(&cycle, "complete-run").unwrap();
737 let mut issue = make_issue("ENG-3", "ENG");
738 issue.cycle_id = Some(cycle.id.clone());
739 issue.cycle_name = cycle.name.clone();
740 db.upsert_issue(&issue).unwrap();
741
742 let stored = db.get_issue(&issue.id).unwrap().unwrap();
743 assert_eq!(stored.cycle_id.as_deref(), Some("cycle-1"));
744 assert_eq!(stored.cycle_name.as_deref(), Some("Launch"));
745
746 db.reconcile_cycles("default", "ENG", "next-complete-run")
747 .unwrap();
748 let stored = db.get_issue(&issue.id).unwrap().unwrap();
749 assert!(stored.cycle_id.is_none());
750 assert!(stored.cycle_name.is_none());
751 }
752
753 #[test]
754 fn migration_11_forces_exactly_one_membership_refresh() {
755 let (db, _dir) = test_db();
756 db.set_sync_cursor("default", "ENG", "2026-01-01T00:00:00Z")
757 .unwrap();
758 db.with_conn(|conn| {
759 conn.execute("DELETE FROM schema_version WHERE version = 11", [])?;
760 crate::db::schema::run_migrations(conn)
761 })
762 .unwrap();
763 assert!(!db.is_full_sync_done("default", "ENG").unwrap());
764
765 db.set_sync_cursor("default", "ENG", "2026-01-02T00:00:00Z")
766 .unwrap();
767 db.with_conn(crate::db::schema::run_migrations).unwrap();
768 assert!(db.is_full_sync_done("default", "ENG").unwrap());
769 }
770}