Skip to main content

rectilinear_core/db/
sync.rs

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}