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