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