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 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}