Skip to main content

sc/sync/
types.rs

1//! Sync types for JSONL export/import.
2//!
3//! This module defines the record types used in JSONL files for synchronization.
4//! Each record type wraps the underlying data model with sync metadata.
5
6use serde::{Deserialize, Serialize};
7
8use crate::model::Plan;
9use crate::storage::sqlite::{Checkpoint, ContextItem, Issue, Memory, Session, TimeEntry};
10
11/// Tagged union for JSONL records.
12///
13/// Each line in a JSONL file is one of these record types, discriminated by the `type` field.
14/// The serde tag attribute ensures the JSON looks like:
15/// `{"type":"session","id":"sess_123",...}`
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "type", rename_all = "snake_case")]
18pub enum SyncRecord {
19    /// A session record with sync metadata.
20    Session(SessionRecord),
21    /// An issue record with sync metadata.
22    Issue(IssueRecord),
23    /// A context item record with sync metadata.
24    ContextItem(ContextItemRecord),
25    /// A memory record with sync metadata.
26    Memory(MemoryRecord),
27    /// A checkpoint record with sync metadata.
28    Checkpoint(CheckpointRecord),
29    /// A plan record with sync metadata.
30    Plan(PlanRecord),
31    /// A time entry record with sync metadata.
32    TimeEntry(TimeEntryRecord),
33}
34
35/// Session with sync metadata.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SessionRecord {
38    /// The session data.
39    #[serde(flatten)]
40    pub data: Session,
41    /// SHA256 hash of the serialized data (for change detection).
42    pub content_hash: String,
43    /// ISO8601 timestamp when this record was exported.
44    pub exported_at: String,
45}
46
47/// Issue with sync metadata.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct IssueRecord {
50    /// The issue data.
51    #[serde(flatten)]
52    pub data: Issue,
53    /// SHA256 hash of the serialized data.
54    pub content_hash: String,
55    /// ISO8601 timestamp when this record was exported.
56    pub exported_at: String,
57}
58
59/// Context item with sync metadata.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ContextItemRecord {
62    /// The context item data.
63    #[serde(flatten)]
64    pub data: ContextItem,
65    /// SHA256 hash of the serialized data.
66    pub content_hash: String,
67    /// ISO8601 timestamp when this record was exported.
68    pub exported_at: String,
69}
70
71/// Memory with sync metadata.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct MemoryRecord {
74    /// The memory data.
75    #[serde(flatten)]
76    pub data: Memory,
77    /// SHA256 hash of the serialized data.
78    pub content_hash: String,
79    /// ISO8601 timestamp when this record was exported.
80    pub exported_at: String,
81}
82
83/// Checkpoint with sync metadata.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct CheckpointRecord {
86    /// The checkpoint data.
87    #[serde(flatten)]
88    pub data: Checkpoint,
89    /// SHA256 hash of the serialized data.
90    pub content_hash: String,
91    /// ISO8601 timestamp when this record was exported.
92    pub exported_at: String,
93}
94
95/// Plan with sync metadata.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct PlanRecord {
98    /// The plan data.
99    #[serde(flatten)]
100    pub data: Plan,
101    /// SHA256 hash of the serialized data.
102    pub content_hash: String,
103    /// ISO8601 timestamp when this record was exported.
104    pub exported_at: String,
105}
106
107/// Time entry with sync metadata.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct TimeEntryRecord {
110    /// The time entry data.
111    #[serde(flatten)]
112    pub data: TimeEntry,
113    /// SHA256 hash of the serialized data.
114    pub content_hash: String,
115    /// ISO8601 timestamp when this record was exported.
116    pub exported_at: String,
117}
118
119/// A deletion record for sync.
120///
121/// When a record is deleted locally, a deletion record is created so that
122/// imports on other machines can apply the deletion. Unlike data records,
123/// deletions are stored in a separate `deletions.jsonl` file.
124///
125/// # Git-Friendly Design
126///
127/// Deletions accumulate in the JSONL file, providing a history of what was deleted.
128/// Git tracks when deletions were added, allowing teams to see what changed.
129/// Periodically, old deletions can be compacted (removed) once all machines have synced.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct DeletionRecord {
132    /// The type of entity that was deleted.
133    pub entity_type: EntityType,
134    /// The ID of the deleted entity.
135    pub entity_id: String,
136    /// The project path this deletion belongs to.
137    pub project_path: String,
138    /// ISO8601 timestamp when the deletion occurred.
139    pub deleted_at: String,
140    /// Actor who performed the deletion.
141    pub deleted_by: String,
142}
143
144/// Entity types for deletion tracking.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum EntityType {
148    /// A session.
149    Session,
150    /// An issue.
151    Issue,
152    /// A context item.
153    ContextItem,
154    /// A memory item.
155    Memory,
156    /// A checkpoint.
157    Checkpoint,
158    /// A plan.
159    Plan,
160    /// A time entry.
161    TimeEntry,
162}
163
164impl std::fmt::Display for EntityType {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        match self {
167            Self::Session => write!(f, "session"),
168            Self::Issue => write!(f, "issue"),
169            Self::ContextItem => write!(f, "context_item"),
170            Self::Memory => write!(f, "memory"),
171            Self::Checkpoint => write!(f, "checkpoint"),
172            Self::Plan => write!(f, "plan"),
173            Self::TimeEntry => write!(f, "time_entry"),
174        }
175    }
176}
177
178impl std::str::FromStr for EntityType {
179    type Err = String;
180
181    fn from_str(s: &str) -> Result<Self, Self::Err> {
182        match s {
183            "session" => Ok(Self::Session),
184            "issue" => Ok(Self::Issue),
185            "context_item" => Ok(Self::ContextItem),
186            "memory" => Ok(Self::Memory),
187            "checkpoint" => Ok(Self::Checkpoint),
188            "plan" => Ok(Self::Plan),
189            "time_entry" => Ok(Self::TimeEntry),
190            _ => Err(format!("Unknown entity type: {s}")),
191        }
192    }
193}
194
195/// Conflict resolution strategy for imports.
196///
197/// When importing a record that already exists locally, this determines
198/// which version wins.
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
200pub enum MergeStrategy {
201    /// Use the record with the newer `updated_at` timestamp.
202    #[default]
203    PreferNewer,
204    /// Always keep the local version.
205    PreferLocal,
206    /// Always take the external (imported) version.
207    PreferExternal,
208}
209
210/// Statistics for an export operation.
211#[derive(Debug, Default, Clone, Serialize)]
212pub struct ExportStats {
213    /// Number of sessions exported.
214    pub sessions: usize,
215    /// Number of issues exported.
216    pub issues: usize,
217    /// Number of context items exported.
218    pub context_items: usize,
219    /// Number of memories exported.
220    pub memories: usize,
221    /// Number of checkpoints exported.
222    pub checkpoints: usize,
223    /// Number of plans exported.
224    pub plans: usize,
225    /// Number of time entries exported.
226    pub time_entries: usize,
227    /// Number of deletions exported.
228    pub deletions: usize,
229}
230
231impl ExportStats {
232    /// Total number of data records exported (excludes deletions).
233    #[must_use]
234    pub fn total(&self) -> usize {
235        self.sessions + self.issues + self.context_items + self.memories + self.checkpoints + self.plans + self.time_entries
236    }
237
238    /// Total including deletions.
239    #[must_use]
240    pub fn total_with_deletions(&self) -> usize {
241        self.total() + self.deletions
242    }
243
244    /// Returns true if nothing was exported.
245    #[must_use]
246    pub fn is_empty(&self) -> bool {
247        self.total_with_deletions() == 0
248    }
249}
250
251/// Statistics for an import operation.
252#[derive(Debug, Default, Clone, Serialize)]
253pub struct ImportStats {
254    /// Statistics for sessions.
255    pub sessions: EntityStats,
256    /// Statistics for issues.
257    pub issues: EntityStats,
258    /// Statistics for context items.
259    pub context_items: EntityStats,
260    /// Statistics for memories.
261    pub memories: EntityStats,
262    /// Statistics for checkpoints.
263    pub checkpoints: EntityStats,
264    /// Statistics for plans.
265    pub plans: EntityStats,
266    /// Statistics for time entries.
267    pub time_entries: EntityStats,
268}
269
270impl ImportStats {
271    /// Total number of records processed.
272    #[must_use]
273    pub fn total_processed(&self) -> usize {
274        self.sessions.total()
275            + self.issues.total()
276            + self.context_items.total()
277            + self.memories.total()
278            + self.checkpoints.total()
279            + self.plans.total()
280            + self.time_entries.total()
281    }
282
283    /// Total number of records created.
284    #[must_use]
285    pub fn total_created(&self) -> usize {
286        self.sessions.created
287            + self.issues.created
288            + self.context_items.created
289            + self.memories.created
290            + self.checkpoints.created
291            + self.plans.created
292            + self.time_entries.created
293    }
294
295    /// Total number of records updated.
296    #[must_use]
297    pub fn total_updated(&self) -> usize {
298        self.sessions.updated
299            + self.issues.updated
300            + self.context_items.updated
301            + self.memories.updated
302            + self.checkpoints.updated
303            + self.plans.updated
304            + self.time_entries.updated
305    }
306}
307
308/// Per-entity statistics for import operations.
309#[derive(Debug, Default, Clone, Serialize)]
310pub struct EntityStats {
311    /// Number of new records created.
312    pub created: usize,
313    /// Number of existing records updated.
314    pub updated: usize,
315    /// Number of records skipped (no change or merge strategy chose local).
316    pub skipped: usize,
317    /// Number of conflicts encountered.
318    pub conflicts: usize,
319}
320
321impl EntityStats {
322    /// Total records processed.
323    #[must_use]
324    pub fn total(&self) -> usize {
325        self.created + self.updated + self.skipped + self.conflicts
326    }
327}
328
329/// Sync status information.
330#[derive(Debug, Clone, Serialize)]
331pub struct SyncStatus {
332    /// Number of dirty sessions pending export.
333    pub dirty_sessions: usize,
334    /// Number of dirty issues pending export.
335    pub dirty_issues: usize,
336    /// Number of dirty context items pending export.
337    pub dirty_context_items: usize,
338    /// Number of pending deletions to export.
339    pub pending_deletions: usize,
340    /// Total sessions for this project.
341    pub total_sessions: usize,
342    /// Total issues for this project.
343    pub total_issues: usize,
344    /// Total context items for this project.
345    pub total_context_items: usize,
346    /// Whether a backfill is needed (data exists but no dirty records).
347    pub needs_backfill: bool,
348    /// Whether any export files exist.
349    pub has_export_files: bool,
350    /// List of export files with their sizes.
351    pub export_files: Vec<ExportFileInfo>,
352}
353
354/// Information about an export file.
355#[derive(Debug, Clone, Serialize)]
356pub struct ExportFileInfo {
357    /// File name (e.g., "sessions.jsonl").
358    pub name: String,
359    /// File size in bytes.
360    pub size: u64,
361    /// Number of lines (records) in the file.
362    pub line_count: usize,
363}
364
365/// Sync-specific errors.
366#[derive(Debug, thiserror::Error)]
367pub enum SyncError {
368    /// IO error during file operations.
369    #[error("IO error: {0}")]
370    Io(#[from] std::io::Error),
371
372    /// JSON serialization/deserialization error.
373    #[error("JSON error: {0}")]
374    Json(#[from] serde_json::Error),
375
376    /// Database error.
377    #[error("Database error: {0}")]
378    Database(String),
379
380    /// No dirty records to export.
381    #[error("No dirty records to export (use --force to export all)")]
382    NothingToExport,
383
384    /// JSONL file not found.
385    #[error("JSONL file not found: {0}")]
386    FileNotFound(String),
387
388    /// Invalid record format.
389    #[error("Invalid record at line {line}: {message}")]
390    InvalidRecord {
391        /// Line number (1-indexed).
392        line: usize,
393        /// Error message.
394        message: String,
395    },
396}
397
398impl From<rusqlite::Error> for SyncError {
399    fn from(err: rusqlite::Error) -> Self {
400        Self::Database(err.to_string())
401    }
402}
403
404/// Result type for sync operations.
405pub type SyncResult<T> = std::result::Result<T, SyncError>;
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn test_export_stats() {
413        let mut stats = ExportStats::default();
414        assert!(stats.is_empty());
415
416        stats.sessions = 5;
417        stats.issues = 3;
418        assert_eq!(stats.total(), 8);
419        assert!(!stats.is_empty());
420    }
421
422    #[test]
423    fn test_entity_stats() {
424        let stats = EntityStats {
425            created: 10,
426            updated: 5,
427            skipped: 2,
428            conflicts: 1,
429        };
430        assert_eq!(stats.total(), 18);
431    }
432
433    #[test]
434    fn test_merge_strategy_default() {
435        let strategy = MergeStrategy::default();
436        assert_eq!(strategy, MergeStrategy::PreferNewer);
437    }
438}