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