Add conflict resolution diff UI and app branding
This commit is contained in:
273
apps/server/internal/sync/service.go
Normal file
273
apps/server/internal/sync/service.go
Normal file
@@ -0,0 +1,273 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user