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 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 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(¤t).unwrap();
907 db.upsert_project(&stale).unwrap();
908 db.upsert_project(&other_workspace).unwrap();
909
910 db.upsert_project_team_page(¤t.id, ¤t.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}