sync app filled in

This commit is contained in:
2026-06-01 10:53:28 -04:00
parent ebe0920f89
commit fb0673a1c5
36 changed files with 4527 additions and 98 deletions

View File

@@ -2,22 +2,33 @@ package sync
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/tim/cairnquire/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/store"
)
var (
ErrForbidden = errors.New("sync snapshot does not belong to user")
ErrContentRequired = errors.New("sync content is required")
ErrHashMismatch = errors.New("sync content hash mismatch")
ErrInvalidPath = errors.New("invalid sync path")
ErrInvalidHash = errors.New("invalid sync content hash")
ErrContentNotFound = errors.New("sync content hash not found")
)
// Service handles sync protocol business logic.
type Service struct {
repo *Repository
docService *docs.Service
repo *Repository
docService *docs.Service
contentStore *store.ContentStore
logger *slog.Logger
sourceDir string
logger *slog.Logger
sourceDir string
}
// NewService creates a new sync service.
@@ -48,11 +59,14 @@ func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snaps
}
// ApplyDelta processes client changes and computes server delta + conflicts.
func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta Delta) (*DeltaResult, error) {
func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, clientDelta Delta) (*DeltaResult, error) {
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
if err != nil {
return nil, fmt.Errorf("get snapshot: %w", err)
}
if snap.UserID != userID {
return nil, ErrForbidden
}
serverFiles := make(map[string]FileEntry)
for _, f := range snap.Files {
@@ -83,8 +97,8 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta
currentMap[f.Path] = f
}
var serverDelta []Change
var conflicts []Conflict
serverDelta := make([]Change, 0)
conflicts := make([]Conflict, 0)
// Detect server changes since snapshot
for path, current := range currentMap {
@@ -121,13 +135,15 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta
}
// Check for conflicts: both client and server changed same file
conflictPaths := make(map[string]struct{})
for _, clientChange := range clientDelta.Changes {
if clientChange.Type == ChangeCreate || clientChange.Type == ChangeUpdate {
if clientChange.Type == ChangeCreate || clientChange.Type == ChangeUpdate || clientChange.Type == ChangeDelete {
serverCurrent, serverHas := currentMap[clientChange.Path]
serverOld, serverHad := serverFiles[clientChange.Path]
if serverHad && serverHas && serverOld.Hash != serverCurrent.Hash &&
serverCurrent.Hash != clientChange.Hash {
conflictPaths[clientChange.Path] = struct{}{}
conflicts = append(conflicts, Conflict{
Path: clientChange.Path,
ServerHash: serverCurrent.Hash,
@@ -137,73 +153,107 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta
Strategy: ResolutionLastWriteWins,
})
}
if !serverHad && serverHas && serverCurrent.Hash != clientChange.Hash {
conflictPaths[clientChange.Path] = struct{}{}
conflicts = append(conflicts, Conflict{
Path: clientChange.Path,
ServerHash: serverCurrent.Hash,
ClientHash: clientChange.Hash,
ServerModified: serverCurrent.Modified,
ClientModified: clientChange.Modified,
Strategy: ResolutionManualMerge,
})
}
}
}
for _, clientChange := range clientDelta.Changes {
if _, conflicted := conflictPaths[clientChange.Path]; conflicted {
continue
}
if err := s.applyClientChange(ctx, clientChange); err != nil {
return nil, err
}
}
if len(clientDelta.Changes) > 0 {
if _, err := s.docService.SyncSourceDir(ctx); err != nil {
return nil, fmt.Errorf("sync documents after client delta: %w", err)
}
}
latestFiles, err := s.buildSnapshotFromDisk(ctx)
if err != nil {
return nil, fmt.Errorf("build post-delta snapshot: %w", err)
}
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, latestFiles)
if err != nil {
return nil, fmt.Errorf("create post-delta snapshot: %w", err)
}
return &DeltaResult{
ServerDelta: serverDelta,
Conflicts: conflicts,
ServerDelta: serverDelta,
Conflicts: conflicts,
NewSnapshotID: newSnap.ID,
}, nil
}
// ResolveConflicts applies resolved changes and creates a new snapshot.
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID string, resolutions []Resolution) (*Snapshot, error) {
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID 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
if snap.UserID != userID {
return nil, ErrForbidden
}
// Get current server state
currentFiles, err := s.buildSnapshotFromDisk(ctx)
if err != nil {
if _, err := s.buildSnapshotFromDisk(ctx); 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.
if err := s.applyResolvedContent(res.Path, res.Hash, res.Content); err != nil {
return nil, err
}
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)
if res.NewPath == "" {
res.NewPath = conflictPath(res.Path)
}
if err := s.applyResolvedContent(res.NewPath, res.Hash, res.Content); err != nil {
return nil, err
}
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)
if res.Content != "" {
if err := s.applyResolvedContent(res.Path, res.Hash, res.Content); err != nil {
return nil, err
}
}
case ResolutionManualMerge:
// Flag for later UI resolution
if res.Content != "" {
if err := s.applyResolvedContent(res.Path, res.Hash, res.Content); err != nil {
return nil, err
}
}
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)
if len(resolutions) > 0 {
if _, err := s.docService.SyncSourceDir(ctx); err != nil {
return nil, fmt.Errorf("sync documents after resolutions: %w", err)
}
}
files, err := s.buildSnapshotFromDisk(ctx)
if err != nil {
return nil, fmt.Errorf("build resolved snapshot: %w", err)
}
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, files)
@@ -217,12 +267,137 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID string, resol
// GetContent returns raw file content by hash.
func (s *Service) GetContent(hash string) ([]byte, error) {
if !isSHA256Hex(hash) {
return nil, ErrInvalidHash
}
return s.contentStore.Read(hash)
}
func (s *Service) applyClientChange(_ context.Context, change Change) error {
switch change.Type {
case ChangeCreate, ChangeUpdate:
return s.applyResolvedContent(change.Path, change.Hash, change.Content)
case ChangeDelete:
path, err := s.safeContentPath(change.Path)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("delete synced file %s: %w", change.Path, err)
}
return nil
case ChangeRename:
oldPath, err := s.safeContentPath(change.OldPath)
if err != nil {
return err
}
newPath, err := s.safeContentPath(change.Path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
return fmt.Errorf("create rename directory %s: %w", change.Path, err)
}
if err := os.Rename(oldPath, newPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("rename synced file %s to %s: %w", change.OldPath, change.Path, err)
}
if change.Content != "" || change.Hash != "" {
return s.applyResolvedContent(change.Path, change.Hash, change.Content)
}
return nil
default:
return nil
}
}
func (s *Service) applyResolvedContent(requestPath, expectedHash, content string) error {
path, err := s.safeContentPath(requestPath)
if err != nil {
return err
}
var bytes []byte
if content != "" {
record, err := s.contentStore.PutBytes([]byte(content))
if err != nil {
return fmt.Errorf("store synced content %s: %w", requestPath, err)
}
if expectedHash != "" && record.Hash != expectedHash {
return fmt.Errorf("%w: %s", ErrHashMismatch, requestPath)
}
bytes = []byte(content)
} else {
if expectedHash == "" {
return fmt.Errorf("%w: %s", ErrContentRequired, requestPath)
}
if !isSHA256Hex(expectedHash) {
return fmt.Errorf("%w: %s", ErrInvalidHash, expectedHash)
}
bytes, err = s.contentStore.Read(expectedHash)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%w: %s", ErrContentNotFound, expectedHash)
}
return fmt.Errorf("read synced content %s: %w", expectedHash, err)
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create synced file directory %s: %w", requestPath, err)
}
if err := os.WriteFile(path, bytes, 0o644); err != nil {
return fmt.Errorf("write synced file %s: %w", requestPath, err)
}
return nil
}
func (s *Service) safeContentPath(requestPath string) (string, error) {
if requestPath == "" {
return "", ErrInvalidPath
}
clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(strings.Trim(requestPath, "/"))))
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || filepath.IsAbs(clean) {
return "", ErrInvalidPath
}
if !strings.HasSuffix(strings.ToLower(clean), ".md") {
return "", ErrInvalidPath
}
sourceRoot, err := filepath.Abs(s.sourceDir)
if err != nil {
return "", fmt.Errorf("resolve source root: %w", err)
}
target, err := filepath.Abs(filepath.Join(sourceRoot, filepath.FromSlash(clean)))
if err != nil {
return "", ErrInvalidPath
}
relative, err := filepath.Rel(sourceRoot, target)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", ErrInvalidPath
}
return target, nil
}
func conflictPath(path string) string {
ext := filepath.Ext(path)
stem := strings.TrimSuffix(path, ext)
return stem + " (conflict)" + ext
}
func isSHA256Hex(hash string) bool {
if len(hash) != 64 {
return false
}
for _, c := range hash {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
return false
}
}
return true
}
// buildSnapshotFromDisk walks the source directory and builds a file list.
func (s *Service) buildSnapshotFromDisk(ctx context.Context) ([]FileEntry, error) {
var files []FileEntry
files := make([]FileEntry, 0)
err := filepath.WalkDir(s.sourceDir, func(path string, entry os.DirEntry, err error) error {
if err != nil {
@@ -231,7 +406,7 @@ func (s *Service) buildSnapshotFromDisk(ctx context.Context) ([]FileEntry, error
if entry.IsDir() {
return nil
}
if filepath.Ext(path) != ".md" {
if strings.ToLower(filepath.Ext(path)) != ".md" {
return nil
}