package sync import ( "context" "fmt" "log/slog" "os" "path/filepath" "github.com/tim/cairnquire/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/store" ) // Service handles sync protocol business logic. type Service struct { repo *Repository docService *docs.Service contentStore *store.ContentStore logger *slog.Logger sourceDir string } // NewService creates a new sync service. func NewService(repo *Repository, docService *docs.Service, contentStore *store.ContentStore, sourceDir string, logger *slog.Logger) *Service { return &Service{ repo: repo, docService: docService, contentStore: contentStore, logger: logger, sourceDir: sourceDir, } } // InitSync creates a new snapshot from the current server state. func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snapshot, error) { files, err := s.buildSnapshotFromDisk(ctx) if err != nil { return nil, fmt.Errorf("build snapshot: %w", err) } snap, err := s.repo.CreateSnapshot(ctx, deviceID, userID, files) if err != nil { return nil, fmt.Errorf("create snapshot: %w", err) } s.logger.Info("sync initialized", "device", deviceID, "snapshot", snap.ID, "files", len(files)) return snap, nil } // ApplyDelta processes client changes and computes server delta + conflicts. func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta Delta) (*DeltaResult, error) { snap, err := s.repo.GetSnapshot(ctx, snapshotID) if err != nil { return nil, fmt.Errorf("get snapshot: %w", err) } serverFiles := make(map[string]FileEntry) for _, f := range snap.Files { serverFiles[f.Path] = f } clientFiles := make(map[string]FileEntry) for _, c := range clientDelta.Changes { if c.Type == ChangeDelete || c.Type == ChangeRename { continue } clientFiles[c.Path] = FileEntry{ Path: c.Path, Hash: c.Hash, Size: c.Size, Modified: c.Modified, } } // Rebuild current server state (may have changed since snapshot) currentFiles, err := s.buildSnapshotFromDisk(ctx) if err != nil { return nil, fmt.Errorf("build current snapshot: %w", err) } currentMap := make(map[string]FileEntry) for _, f := range currentFiles { currentMap[f.Path] = f } var serverDelta []Change var conflicts []Conflict // Detect server changes since snapshot for path, current := range currentMap { old, existed := serverFiles[path] if !existed { // Server created this file serverDelta = append(serverDelta, Change{ Type: ChangeCreate, Path: path, Hash: current.Hash, Size: current.Size, Modified: current.Modified, }) } else if old.Hash != current.Hash { // Server updated this file serverDelta = append(serverDelta, Change{ Type: ChangeUpdate, Path: path, Hash: current.Hash, Size: current.Size, Modified: current.Modified, }) } } // Detect server deletions for path := range serverFiles { if _, exists := currentMap[path]; !exists { serverDelta = append(serverDelta, Change{ Type: ChangeDelete, Path: path, }) } } // Check for conflicts: both client and server changed same file for _, clientChange := range clientDelta.Changes { if clientChange.Type == ChangeCreate || clientChange.Type == ChangeUpdate { serverCurrent, serverHas := currentMap[clientChange.Path] serverOld, serverHad := serverFiles[clientChange.Path] if serverHad && serverHas && serverOld.Hash != serverCurrent.Hash && serverCurrent.Hash != clientChange.Hash { conflicts = append(conflicts, Conflict{ Path: clientChange.Path, ServerHash: serverCurrent.Hash, ClientHash: clientChange.Hash, ServerModified: serverCurrent.Modified, ClientModified: clientChange.Modified, Strategy: ResolutionLastWriteWins, }) } } } return &DeltaResult{ ServerDelta: serverDelta, Conflicts: conflicts, }, nil } // ResolveConflicts applies resolved changes and creates a new snapshot. func (s *Service) ResolveConflicts(ctx context.Context, snapshotID string, resolutions []Resolution) (*Snapshot, error) { snap, err := s.repo.GetSnapshot(ctx, snapshotID) if err != nil { return nil, fmt.Errorf("get snapshot: %w", err) } // Build a map of resolutions by path resMap := make(map[string]Resolution) for _, r := range resolutions { resMap[r.Path] = r } // Get current server state currentFiles, err := s.buildSnapshotFromDisk(ctx) if err != nil { return nil, fmt.Errorf("build current snapshot: %w", err) } currentMap := make(map[string]FileEntry) for _, f := range currentFiles { currentMap[f.Path] = f } // Apply resolutions to create the merged state merged := make(map[string]FileEntry) for path, f := range currentMap { merged[path] = f } for _, res := range resolutions { switch res.Strategy { case ResolutionClientWins: // In a real implementation, we'd apply the client's content here. // For now, we keep the server state and mark it resolved. s.logger.Debug("conflict resolved: client wins", "path", res.Path) case ResolutionServerWins: // Keep server state (already in merged) s.logger.Debug("conflict resolved: server wins", "path", res.Path) case ResolutionRenameBoth: if existing, ok := merged[res.Path]; ok { merged[res.NewPath] = existing delete(merged, res.Path) } case ResolutionLastWriteWins: // Keep whichever is newer - in practice server state since // we haven't received client content yet s.logger.Debug("conflict resolved: last-write-wins", "path", res.Path) case ResolutionManualMerge: // Flag for later UI resolution s.logger.Debug("conflict deferred: manual merge", "path", res.Path) } } // Create new snapshot from merged state var files []FileEntry for _, f := range merged { files = append(files, f) } newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, files) if err != nil { return nil, fmt.Errorf("create new snapshot: %w", err) } s.logger.Info("conflicts resolved", "snapshot", snapshotID, "newSnapshot", newSnap.ID, "resolutions", len(resolutions)) return newSnap, nil } // GetContent returns raw file content by hash. func (s *Service) GetContent(hash string) ([]byte, error) { return s.contentStore.Read(hash) } // buildSnapshotFromDisk walks the source directory and builds a file list. func (s *Service) buildSnapshotFromDisk(ctx context.Context) ([]FileEntry, error) { var files []FileEntry err := filepath.WalkDir(s.sourceDir, func(path string, entry os.DirEntry, err error) error { if err != nil { return err } if entry.IsDir() { return nil } if filepath.Ext(path) != ".md" { return nil } info, err := entry.Info() if err != nil { return err } relative, err := filepath.Rel(s.sourceDir, path) if err != nil { return err } relative = filepath.ToSlash(relative) content, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read file %s: %w", path, err) } record, err := s.contentStore.PutBytes(content) if err != nil { return fmt.Errorf("store file %s: %w", path, err) } files = append(files, FileEntry{ Path: relative, Hash: record.Hash, Size: info.Size(), Modified: info.ModTime().UTC(), }) return nil }) if err != nil { return nil, err } return files, nil }