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 = ?2
141                   AND project_id IN (
142                       SELECT id FROM projects WHERE workspace_id = ?1
143                   )
144                   AND COALESCE(sync_token, '') <> ?3",
145                rusqlite::params![workspace_id, team_key, sync_token],
146            )?;
147            let changed = tx.execute(
148                "DELETE FROM projects
149                 WHERE workspace_id = ?1
150                   AND NOT EXISTS (
151                       SELECT 1 FROM project_teams pt WHERE pt.project_id = projects.id
152                   )",
153                rusqlite::params![workspace_id],
154            )?;
155            tx.commit()?;
156            Ok(changed)
157        })
158    }
159
160    pub fn upsert_project_team_page(
161        &self,
162        project_id: &str,
163        teams: &[ProjectTeam],
164        sync_token: &str,
165    ) -> Result<()> {
166        self.with_conn(|conn| {
167            let mut stmt = conn.prepare(
168                "INSERT INTO project_teams
169                    (project_id, team_id, team_key, team_name, sync_token)
170                 VALUES (?1, ?2, ?3, ?4, ?5)
171                 ON CONFLICT(project_id, team_id) DO UPDATE SET
172                    team_key=excluded.team_key,
173                    team_name=excluded.team_name,
174                    sync_token=excluded.sync_token",
175            )?;
176            for team in teams {
177                stmt.execute(rusqlite::params![
178                    project_id, team.id, team.key, team.name, sync_token,
179                ])?;
180            }
181            Ok(())
182        })
183    }
184
185    pub fn complete_project_team_sync(&self, project_id: &str, sync_token: &str) -> Result<usize> {
186        self.with_conn(|conn| {
187            Ok(conn.execute(
188                "DELETE FROM project_teams
189                 WHERE project_id = ?1 AND COALESCE(sync_token, '') <> ?2",
190                rusqlite::params![project_id, sync_token],
191            )?)
192        })
193    }
194
195    pub fn upsert_project_member_page(
196        &self,
197        project_id: &str,
198        members: &[ProjectMember],
199        sync_token: &str,
200    ) -> Result<()> {
201        self.with_conn(|conn| {
202            let mut stmt = conn.prepare(
203                "INSERT INTO project_members (project_id, user_id, user_name, sync_token)
204                 VALUES (?1, ?2, ?3, ?4)
205                 ON CONFLICT(project_id, user_id) DO UPDATE SET
206                    user_name=excluded.user_name,
207                    sync_token=excluded.sync_token",
208            )?;
209            for member in members {
210                stmt.execute(rusqlite::params![
211                    project_id,
212                    member.id,
213                    member.name,
214                    sync_token,
215                ])?;
216            }
217            Ok(())
218        })
219    }
220
221    pub fn complete_project_member_sync(
222        &self,
223        project_id: &str,
224        sync_token: &str,
225    ) -> Result<usize> {
226        self.with_conn(|conn| {
227            Ok(conn.execute(
228                "DELETE FROM project_members
229                 WHERE project_id = ?1 AND COALESCE(sync_token, '') <> ?2",
230                rusqlite::params![project_id, sync_token],
231            )?)
232        })
233    }
234
235    pub fn upsert_project_label_page(
236        &self,
237        project_id: &str,
238        labels: &[ProjectLabel],
239        sync_token: &str,
240    ) -> Result<()> {
241        self.with_conn(|conn| {
242            let mut stmt = conn.prepare(
243                "INSERT INTO project_labels
244                    (project_id, label_id, label_name, color, description, sync_token)
245                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
246                 ON CONFLICT(project_id, label_id) DO UPDATE SET
247                    label_name=excluded.label_name,
248                    color=excluded.color,
249                    description=excluded.description,
250                    sync_token=excluded.sync_token",
251            )?;
252            for label in labels {
253                stmt.execute(rusqlite::params![
254                    project_id,
255                    label.id,
256                    label.name,
257                    label.color,
258                    label.description,
259                    sync_token,
260                ])?;
261            }
262            Ok(())
263        })
264    }
265
266    pub fn complete_project_label_sync(&self, project_id: &str, sync_token: &str) -> Result<usize> {
267        self.with_conn(|conn| {
268            Ok(conn.execute(
269                "DELETE FROM project_labels
270                 WHERE project_id = ?1 AND COALESCE(sync_token, '') <> ?2",
271                rusqlite::params![project_id, sync_token],
272            )?)
273        })
274    }
275
276    pub fn mark_project_milestone_sync_token(
277        &self,
278        milestone_id: &str,
279        sync_token: &str,
280    ) -> Result<()> {
281        self.with_conn(|conn| {
282            conn.execute(
283                "UPDATE project_milestones SET sync_token = ?2 WHERE id = ?1",
284                rusqlite::params![milestone_id, sync_token],
285            )?;
286            Ok(())
287        })
288    }
289
290    pub fn reconcile_project_milestones_by_token(
291        &self,
292        project_id: &str,
293        sync_token: &str,
294    ) -> Result<usize> {
295        self.with_conn(|conn| {
296            Ok(conn.execute(
297                "DELETE FROM project_milestones
298                 WHERE project_id = ?1 AND COALESCE(sync_token, '') <> ?2",
299                rusqlite::params![project_id, sync_token],
300            )?)
301        })
302    }
303
304    pub fn mark_label_sync_token(&self, label_id: &str, sync_token: &str) -> Result<()> {
305        self.with_conn(|conn| {
306            conn.execute(
307                "UPDATE labels SET sync_token = ?2 WHERE id = ?1",
308                rusqlite::params![label_id, sync_token],
309            )?;
310            Ok(())
311        })
312    }
313
314    pub fn reconcile_label_sync(&self, workspace_id: &str, sync_token: &str) -> Result<usize> {
315        self.with_conn(|conn| {
316            Ok(conn.execute(
317                "DELETE FROM labels
318                 WHERE workspace_id = ?1 AND COALESCE(sync_token, '') <> ?2",
319                rusqlite::params![workspace_id, sync_token],
320            )?)
321        })
322    }
323
324    pub fn mark_issue_sync_token(&self, issue_id: &str, sync_token: &str) -> Result<()> {
325        self.with_conn(|conn| {
326            conn.execute(
327                "UPDATE issues SET sync_token = ?2 WHERE id = ?1",
328                rusqlite::params![issue_id, sync_token],
329            )?;
330            Ok(())
331        })
332    }
333
334    pub fn list_issue_sync_refs(
335        &self,
336        workspace_id: &str,
337        team_key: &str,
338        sync_token: &str,
339        after_id: Option<&str>,
340        limit: usize,
341    ) -> Result<Vec<IssueSyncRef>> {
342        self.with_conn(|conn| {
343            let mut stmt = conn.prepare(
344                "SELECT id, identifier FROM issues
345                 WHERE workspace_id = ?1 AND team_key = ?2 AND sync_token = ?3
346                   AND (?4 IS NULL OR id > ?4)
347                 ORDER BY id LIMIT ?5",
348            )?;
349            let rows = stmt.query_map(
350                rusqlite::params![workspace_id, team_key, sync_token, after_id, limit as i64],
351                |row| {
352                    Ok(IssueSyncRef {
353                        id: row.get(0)?,
354                        identifier: row.get(1)?,
355                    })
356                },
357            )?;
358            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
359        })
360    }
361
362    /// Issues changed in this catalog sync plus any issue whose previous
363    /// comment hydration failed. The latter makes supplemental failures
364    /// naturally recoverable even after the authoritative issue cursor moves.
365    pub fn list_comment_hydration_refs(
366        &self,
367        workspace_id: &str,
368        team_key: &str,
369        sync_token: &str,
370        after_id: Option<&str>,
371        limit: usize,
372    ) -> Result<Vec<IssueSyncRef>> {
373        self.with_conn(|conn| {
374            let mut stmt = conn.prepare(
375                "SELECT i.id, i.identifier
376                 FROM issues i
377                 LEFT JOIN comment_sync_state comments ON comments.issue_id = i.id
378                 WHERE i.workspace_id = ?1 AND i.team_key = ?2
379                   AND (i.sync_token = ?3 OR comments.status IN ('permission_denied', 'unavailable'))
380                   AND (?4 IS NULL OR i.id > ?4)
381                 ORDER BY i.id LIMIT ?5",
382            )?;
383            let rows = stmt.query_map(
384                rusqlite::params![workspace_id, team_key, sync_token, after_id, limit as i64],
385                |row| {
386                    Ok(IssueSyncRef {
387                        id: row.get(0)?,
388                        identifier: row.get(1)?,
389                    })
390                },
391            )?;
392            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
393        })
394    }
395
396    pub fn reconcile_full_issue_sync(
397        &self,
398        workspace_id: &str,
399        team_key: &str,
400        sync_token: &str,
401    ) -> Result<usize> {
402        self.with_conn(|conn| {
403            Ok(conn.execute(
404                "DELETE FROM issues
405                 WHERE workspace_id = ?1 AND team_key = ?2
406                   AND COALESCE(sync_token, '') <> ?3",
407                rusqlite::params![workspace_id, team_key, sync_token],
408            )?)
409        })
410    }
411
412    /// Reconcile a full issue-index traversal as of its fixed upper bound.
413    /// Locally cached rows newer than that bound were intentionally outside the
414    /// traversal and must not be mistaken for hard deletions.
415    pub fn reconcile_full_issue_index(
416        &self,
417        workspace_id: &str,
418        team_key: &str,
419        sync_token: &str,
420        upper_bound: &str,
421    ) -> Result<usize> {
422        self.with_conn(|conn| {
423            Ok(conn.execute(
424                "DELETE FROM issues
425                 WHERE workspace_id = ?1 AND team_key = ?2
426                   AND COALESCE(sync_token, '') <> ?3
427                   AND julianday(updated_at) <= julianday(?4)",
428                rusqlite::params![workspace_id, team_key, sync_token, upper_bound],
429            )?)
430        })
431    }
432
433    pub fn upsert_relation_page(
434        &self,
435        issue_id: &str,
436        relations: &[Relation],
437        sync_token: &str,
438    ) -> Result<()> {
439        self.with_conn(|conn| {
440            let tx = conn.unchecked_transaction()?;
441            let mut stmt = tx.prepare(
442                "INSERT INTO issue_relations
443                    (id, issue_id, related_issue_id, related_issue_identifier, relation_type, sync_token)
444                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
445                 ON CONFLICT(id) DO UPDATE SET
446                    issue_id=excluded.issue_id,
447                    related_issue_id=excluded.related_issue_id,
448                    related_issue_identifier=excluded.related_issue_identifier,
449                    relation_type=excluded.relation_type,
450                    sync_token=excluded.sync_token",
451            )?;
452            for relation in relations {
453                stmt.execute(rusqlite::params![
454                    relation.id,
455                    issue_id,
456                    relation.related_issue_id,
457                    relation.related_issue_identifier,
458                    relation.relation_type,
459                    sync_token,
460                ])?;
461            }
462            drop(stmt);
463            tx.commit()?;
464            Ok(())
465        })
466    }
467
468    pub fn complete_relation_sync(&self, issue_id: &str, sync_token: &str) -> Result<usize> {
469        self.with_conn(|conn| {
470            Ok(conn.execute(
471                "DELETE FROM issue_relations
472                 WHERE issue_id = ?1 AND COALESCE(sync_token, '') <> ?2",
473                rusqlite::params![issue_id, sync_token],
474            )?)
475        })
476    }
477
478    pub fn upsert_comment_page(
479        &self,
480        issue_id: &str,
481        workspace_id: &str,
482        comments: &[Comment],
483        sync_token: &str,
484    ) -> Result<()> {
485        self.with_conn(|conn| {
486            let tx = conn.unchecked_transaction()?;
487            let mut stmt = tx.prepare(
488                "INSERT INTO comments
489                    (id, issue_id, body, user_name, created_at, workspace_id,
490                     updated_at, parent_id, url, sync_token)
491                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
492                 ON CONFLICT(id) DO UPDATE SET
493                    issue_id=excluded.issue_id,
494                    body=excluded.body,
495                    user_name=excluded.user_name,
496                    created_at=excluded.created_at,
497                    workspace_id=excluded.workspace_id,
498                    updated_at=excluded.updated_at,
499                    parent_id=excluded.parent_id,
500                    url=excluded.url,
501                    sync_token=excluded.sync_token",
502            )?;
503            for comment in comments {
504                stmt.execute(rusqlite::params![
505                    comment.id,
506                    issue_id,
507                    comment.body,
508                    comment.user_name,
509                    comment.created_at,
510                    workspace_id,
511                    comment.updated_at,
512                    comment.parent_id,
513                    comment.url,
514                    sync_token,
515                ])?;
516            }
517            drop(stmt);
518            tx.commit()?;
519            Ok(())
520        })
521    }
522
523    pub fn complete_comment_sync(
524        &self,
525        issue_id: &str,
526        workspace_id: &str,
527        sync_token: &str,
528    ) -> Result<usize> {
529        self.with_conn(|conn| {
530            Ok(conn.execute(
531                "DELETE FROM comments
532                 WHERE issue_id = ?1 AND workspace_id = ?2
533                   AND COALESCE(sync_token, '') <> ?3",
534                rusqlite::params![issue_id, workspace_id, sync_token],
535            )?)
536        })
537    }
538
539    pub fn upsert_cycle(&self, cycle: &Cycle, sync_token: &str) -> Result<()> {
540        self.with_conn(|conn| {
541            conn.execute(
542                "INSERT INTO cycles (
543                    id, workspace_id, team_id, team_key, number, name, starts_at,
544                    ends_at, completed_at, archived_at, created_at, updated_at,
545                    sync_token, synced_at
546                 ) VALUES (
547                    ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13,
548                    datetime('now')
549                 ) ON CONFLICT(id) DO UPDATE SET
550                    workspace_id=excluded.workspace_id,
551                    team_id=excluded.team_id,
552                    team_key=excluded.team_key,
553                    number=excluded.number,
554                    name=excluded.name,
555                    starts_at=excluded.starts_at,
556                    ends_at=excluded.ends_at,
557                    completed_at=excluded.completed_at,
558                    archived_at=excluded.archived_at,
559                    created_at=excluded.created_at,
560                    updated_at=excluded.updated_at,
561                    sync_token=excluded.sync_token,
562                    synced_at=datetime('now')",
563                rusqlite::params![
564                    cycle.id,
565                    cycle.workspace_id,
566                    cycle.team_id,
567                    cycle.team_key,
568                    cycle.number,
569                    cycle.name,
570                    cycle.starts_at,
571                    cycle.ends_at,
572                    cycle.completed_at,
573                    cycle.archived_at,
574                    cycle.created_at,
575                    cycle.updated_at,
576                    sync_token,
577                ],
578            )?;
579            Ok(())
580        })
581    }
582
583    pub fn reconcile_cycles(
584        &self,
585        workspace_id: &str,
586        team_key: &str,
587        sync_token: &str,
588    ) -> Result<usize> {
589        self.with_conn(|conn| {
590            let tx = conn.unchecked_transaction()?;
591            tx.execute(
592                "UPDATE issues SET cycle_id = NULL, cycle_name = NULL
593                 WHERE workspace_id = ?1 AND cycle_id IN (
594                    SELECT id FROM cycles
595                    WHERE workspace_id = ?1 AND team_key = ?2
596                      AND COALESCE(sync_token, '') <> ?3
597                 )",
598                rusqlite::params![workspace_id, team_key, sync_token],
599            )?;
600            let changed = tx.execute(
601                "DELETE FROM cycles
602                 WHERE workspace_id = ?1 AND team_key = ?2
603                   AND COALESCE(sync_token, '') <> ?3",
604                rusqlite::params![workspace_id, team_key, sync_token],
605            )?;
606            tx.commit()?;
607            Ok(changed)
608        })
609    }
610
611    pub fn mark_sync_family_running(
612        &self,
613        workspace_id: &str,
614        team_key: &str,
615        family: &str,
616        cursor: Option<&str>,
617        page_size: Option<usize>,
618        sync_token: &str,
619    ) -> Result<()> {
620        self.set_sync_family_state(SyncFamilyUpdate {
621            workspace_id,
622            team_key,
623            family,
624            status: "running",
625            cursor,
626            page_size,
627            sync_token,
628            error: None,
629        })
630    }
631
632    pub fn mark_sync_family_complete(
633        &self,
634        workspace_id: &str,
635        team_key: &str,
636        family: &str,
637        page_size: Option<usize>,
638        sync_token: &str,
639    ) -> Result<()> {
640        self.set_sync_family_state(SyncFamilyUpdate {
641            workspace_id,
642            team_key,
643            family,
644            status: "complete",
645            cursor: None,
646            page_size,
647            sync_token,
648            error: None,
649        })
650    }
651
652    pub fn mark_sync_family_failed(
653        &self,
654        workspace_id: &str,
655        team_key: &str,
656        family: &str,
657        sync_token: &str,
658        error: &str,
659    ) -> Result<()> {
660        self.set_sync_family_state(SyncFamilyUpdate {
661            workspace_id,
662            team_key,
663            family,
664            status: "failed",
665            cursor: None,
666            page_size: None,
667            sync_token,
668            error: Some(error),
669        })
670    }
671
672    pub fn mark_sync_family_partial(
673        &self,
674        workspace_id: &str,
675        team_key: &str,
676        family: &str,
677        sync_token: &str,
678        error: &str,
679    ) -> Result<()> {
680        self.set_sync_family_state(SyncFamilyUpdate {
681            workspace_id,
682            team_key,
683            family,
684            status: "partial",
685            cursor: None,
686            page_size: None,
687            sync_token,
688            error: Some(error),
689        })
690    }
691
692    pub fn get_sync_family_state(
693        &self,
694        workspace_id: &str,
695        team_key: &str,
696        family: &str,
697    ) -> Result<Option<SyncFamilyState>> {
698        self.with_conn(|conn| {
699            let mut stmt = conn.prepare(
700                "SELECT status, sync_token, error
701                 FROM sync_family_state
702                 WHERE workspace_id = ?1 AND team_key = ?2 AND family = ?3",
703            )?;
704            let mut rows = stmt.query(rusqlite::params![workspace_id, team_key, family])?;
705            if let Some(row) = rows.next()? {
706                Ok(Some(SyncFamilyState {
707                    status: row.get(0)?,
708                    sync_token: row.get(1)?,
709                    error: row.get(2)?,
710                }))
711            } else {
712                Ok(None)
713            }
714        })
715    }
716
717    fn set_sync_family_state(&self, state: SyncFamilyUpdate<'_>) -> Result<()> {
718        self.with_conn(|conn| {
719            conn.execute(
720                "INSERT INTO sync_family_state (
721                    workspace_id, team_key, family, status, cursor, page_size,
722                    sync_token, error, updated_at
723                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'))
724                 ON CONFLICT(workspace_id, team_key, family) DO UPDATE SET
725                    status=excluded.status,
726                    cursor=excluded.cursor,
727                    page_size=excluded.page_size,
728                    sync_token=excluded.sync_token,
729                    error=excluded.error,
730                    updated_at=datetime('now')",
731                rusqlite::params![
732                    state.workspace_id,
733                    state.team_key,
734                    state.family,
735                    state.status,
736                    state.cursor,
737                    state.page_size.map(|value| value as i64),
738                    state.sync_token,
739                    state.error,
740                ],
741            )?;
742            Ok(())
743        })
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use crate::db::test_helpers::{make_issue, test_db};
751    use crate::db::Project;
752
753    fn project(id: &str, workspace_id: &str) -> Project {
754        Project {
755            id: id.into(),
756            workspace_id: workspace_id.into(),
757            slug_id: id.into(),
758            name: id.into(),
759            description: String::new(),
760            content: None,
761            icon: None,
762            color: "#000000".into(),
763            status_id: "status-1".into(),
764            status_name: "Planned".into(),
765            status_type: "planned".into(),
766            status_color: "#000000".into(),
767            priority: 0,
768            start_date: None,
769            target_date: None,
770            lead_id: None,
771            lead_name: None,
772            created_at: "2026-01-01T00:00:00Z".into(),
773            updated_at: "2026-01-01T00:00:00Z".into(),
774            archived_at: None,
775            url: format!("https://linear.app/project/{id}"),
776            progress: 0.0,
777            synced_at: None,
778            teams: vec![ProjectTeam {
779                id: format!("team-{workspace_id}"),
780                key: "ENG".into(),
781                name: "Engineering".into(),
782            }],
783            members: Vec::new(),
784            labels: Vec::new(),
785        }
786    }
787
788    #[test]
789    fn page_tokens_preserve_old_comments_until_completion() {
790        let (db, _dir) = test_db();
791        let issue = make_issue("ENG-1", "ENG");
792        db.upsert_issue(&issue).unwrap();
793        let old = Comment {
794            id: "old".into(),
795            issue_id: issue.id.clone(),
796            body: "old body".into(),
797            user_name: None,
798            created_at: "2026-01-01T00:00:00Z".into(),
799            updated_at: None,
800            parent_id: None,
801            url: None,
802            workspace_id: "default".into(),
803        };
804        db.replace_issue_comments(&issue.id, "default", &[old])
805            .unwrap();
806        let new = Comment {
807            id: "new".into(),
808            issue_id: issue.id.clone(),
809            body: "new body".into(),
810            user_name: None,
811            created_at: "2026-01-02T00:00:00Z".into(),
812            updated_at: None,
813            parent_id: None,
814            url: None,
815            workspace_id: "default".into(),
816        };
817        db.upsert_comment_page(&issue.id, "default", &[new], "run-1")
818            .unwrap();
819        assert_eq!(db.get_comments(&issue.id).unwrap().len(), 2);
820        db.complete_comment_sync(&issue.id, "default", "run-1")
821            .unwrap();
822        let comments = db.get_comments(&issue.id).unwrap();
823        assert_eq!(comments.len(), 1);
824        assert_eq!(comments[0].id, "new");
825    }
826
827    #[test]
828    fn restarting_after_partial_persistence_is_idempotent() {
829        let (db, _dir) = test_db();
830        let issue = make_issue("ENG-2", "ENG");
831        db.upsert_issue(&issue).unwrap();
832        let comment = |id: &str| Comment {
833            id: id.into(),
834            issue_id: issue.id.clone(),
835            body: id.into(),
836            user_name: None,
837            created_at: "2026-01-01T00:00:00Z".into(),
838            updated_at: None,
839            parent_id: None,
840            url: None,
841            workspace_id: "default".into(),
842        };
843
844        db.upsert_comment_page(&issue.id, "default", &[comment("one")], "interrupted")
845            .unwrap();
846        db.upsert_comment_page(
847            &issue.id,
848            "default",
849            &[comment("one"), comment("two")],
850            "resumed",
851        )
852        .unwrap();
853        db.complete_comment_sync(&issue.id, "default", "resumed")
854            .unwrap();
855
856        let comments = db.get_comments(&issue.id).unwrap();
857        assert_eq!(
858            comments
859                .iter()
860                .map(|comment| comment.id.as_str())
861                .collect::<Vec<_>>(),
862            ["one", "two"]
863        );
864    }
865
866    #[test]
867    fn cycle_membership_round_trips_and_reconciles_after_complete_sync() {
868        let (db, _dir) = test_db();
869        let cycle = Cycle {
870            id: "cycle-1".into(),
871            workspace_id: "default".into(),
872            team_id: "team-1".into(),
873            team_key: "ENG".into(),
874            number: 42,
875            name: Some("Launch".into()),
876            starts_at: Some("2026-01-01T00:00:00Z".into()),
877            ends_at: Some("2026-01-14T00:00:00Z".into()),
878            completed_at: None,
879            archived_at: Some("2026-02-01T00:00:00Z".into()),
880            created_at: "2025-12-01T00:00:00Z".into(),
881            updated_at: "2026-02-01T00:00:00Z".into(),
882        };
883        db.upsert_cycle(&cycle, "complete-run").unwrap();
884        let mut issue = make_issue("ENG-3", "ENG");
885        issue.cycle_id = Some(cycle.id.clone());
886        issue.cycle_name = cycle.name.clone();
887        db.upsert_issue(&issue).unwrap();
888
889        let stored = db.get_issue(&issue.id).unwrap().unwrap();
890        assert_eq!(stored.cycle_id.as_deref(), Some("cycle-1"));
891        assert_eq!(stored.cycle_name.as_deref(), Some("Launch"));
892
893        db.reconcile_cycles("default", "ENG", "next-complete-run")
894            .unwrap();
895        let stored = db.get_issue(&issue.id).unwrap().unwrap();
896        assert!(stored.cycle_id.is_none());
897        assert!(stored.cycle_name.is_none());
898    }
899
900    #[test]
901    fn team_project_reconciliation_is_scoped_to_the_workspace() {
902        let (db, _dir) = test_db();
903        let current = project("current-a", "workspace-a");
904        let stale = project("stale-a", "workspace-a");
905        let other_workspace = project("current-b", "workspace-b");
906        db.upsert_project(&current).unwrap();
907        db.upsert_project(&stale).unwrap();
908        db.upsert_project(&other_workspace).unwrap();
909
910        db.upsert_project_team_page(&current.id, &current.teams, "run-a")
911            .unwrap();
912        db.reconcile_team_projects("workspace-a", "ENG", "run-a")
913            .unwrap();
914
915        assert!(db
916            .get_project("workspace-a", "current-a")
917            .unwrap()
918            .is_some());
919        assert!(db.get_project("workspace-a", "stale-a").unwrap().is_none());
920        let preserved = db.get_project("workspace-b", "current-b").unwrap().unwrap();
921        assert_eq!(preserved.teams.len(), 1);
922        assert_eq!(preserved.teams[0].key, "ENG");
923    }
924
925    #[test]
926    fn migration_11_forces_exactly_one_membership_refresh() {
927        let (db, _dir) = test_db();
928        db.set_sync_cursor("default", "ENG", "2026-01-01T00:00:00Z")
929            .unwrap();
930        db.with_conn(|conn| {
931            conn.execute("DELETE FROM schema_version WHERE version >= 11", [])?;
932            crate::db::schema::run_migrations(conn)
933        })
934        .unwrap();
935        assert!(!db.is_full_sync_done("default", "ENG").unwrap());
936
937        db.set_sync_cursor("default", "ENG", "2026-01-02T00:00:00Z")
938            .unwrap();
939        db.with_conn(crate::db::schema::run_migrations).unwrap();
940        assert!(db.is_full_sync_done("default", "ENG").unwrap());
941    }
942}