Add conflict resolution diff UI and app branding

This commit is contained in:
2026-05-13 16:25:28 -04:00
parent 780ff3a02c
commit d359baa010
83 changed files with 4523 additions and 3735 deletions

View File

@@ -0,0 +1,114 @@
package sync
import "time"
// ChangeType represents the type of file change in a delta.
type ChangeType string
const (
ChangeCreate ChangeType = "create"
ChangeUpdate ChangeType = "update"
ChangeDelete ChangeType = "delete"
ChangeRename ChangeType = "rename"
)
// ResolutionStrategy defines how to resolve a sync conflict.
type ResolutionStrategy string
const (
ResolutionLastWriteWins ResolutionStrategy = "last-write-wins"
ResolutionRenameBoth ResolutionStrategy = "rename-both"
ResolutionManualMerge ResolutionStrategy = "manual-merge"
ResolutionServerWins ResolutionStrategy = "server-wins"
ResolutionClientWins ResolutionStrategy = "client-wins"
)
// FileEntry represents a single file in a snapshot.
type FileEntry struct {
Path string `json:"path"`
Hash string `json:"hash"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
// Snapshot represents a point-in-time view of the filesystem.
type Snapshot struct {
ID string `json:"id"`
DeviceID string `json:"deviceId"`
UserID string `json:"userId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Files []FileEntry `json:"files"`
}
// Change represents a single file change.
type Change struct {
Type ChangeType `json:"type"`
Path string `json:"path"`
OldPath string `json:"oldPath,omitempty"`
Hash string `json:"hash,omitempty"`
Size int64 `json:"size,omitempty"`
Modified time.Time `json:"modified,omitempty"`
}
// Delta is a list of changes since a snapshot.
type Delta struct {
Changes []Change `json:"changes"`
}
// Conflict represents a file changed on both client and server.
type Conflict struct {
Path string `json:"path"`
ServerHash string `json:"serverHash"`
ClientHash string `json:"clientHash"`
ServerModified time.Time `json:"serverModified"`
ClientModified time.Time `json:"clientModified"`
Strategy ResolutionStrategy `json:"strategy,omitempty"`
}
// DeltaResult is the server's response to a delta upload.
type DeltaResult struct {
ServerDelta []Change `json:"serverDelta"`
Conflicts []Conflict `json:"conflicts"`
}
// Resolution is a user's choice for resolving a conflict.
type Resolution struct {
Path string `json:"path"`
Strategy ResolutionStrategy `json:"strategy"`
NewPath string `json:"newPath,omitempty"` // for rename-both
}
// InitRequest starts a new sync session.
type InitRequest struct {
DeviceID string `json:"deviceId"`
RootPath string `json:"rootPath"`
}
// InitResponse returns the server's current snapshot.
type InitResponse struct {
SnapshotID string `json:"snapshotId"`
ServerSnapshot []FileEntry `json:"serverSnapshot"`
}
// DeltaRequest uploads client changes.
type DeltaRequest struct {
SnapshotID string `json:"snapshotId"`
ClientDelta []Change `json:"clientDelta"`
}
// DeltaResponse returns server changes and conflicts.
type DeltaResponse struct {
ServerDelta []Change `json:"serverDelta"`
Conflicts []Conflict `json:"conflicts"`
}
// ResolveRequest uploads conflict resolutions.
type ResolveRequest struct {
SnapshotID string `json:"snapshotId"`
Resolutions []Resolution `json:"resolutions"`
}
// ResolveResponse confirms the new snapshot.
type ResolveResponse struct {
NewSnapshotID string `json:"newSnapshotId"`
}

View 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
}

View File

@@ -0,0 +1,231 @@
package sync
import (
"context"
"database/sql"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/markdown"
"github.com/tim/cairnquire/apps/server/internal/store"
)
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := sql.Open("libsql", "file:"+dbPath)
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
ctx := context.Background()
if err := database.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
return db
}
func setupTestService(t *testing.T) (*Service, string) {
t.Helper()
sourceDir := t.TempDir()
storeDir := t.TempDir()
db := setupTestDB(t)
// Insert a test user to satisfy foreign key constraints
if _, err := db.ExecContext(context.Background(), `
INSERT INTO users (id, email, display_name, created_at)
VALUES ('user:test', 'test@example.com', 'Test User', ?)
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("insert test user: %v", err)
}
// Create some test markdown files
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nWorld"), 0o644); err != nil {
t.Fatalf("create hello.md: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "guide.md"), []byte("# Guide\n\nSteps"), 0o644); err != nil {
t.Fatalf("create guide.md: %v", err)
}
contentStore, err := store.New(storeDir)
if err != nil {
t.Fatalf("create content store: %v", err)
}
renderer := markdown.NewRenderer()
repo := docs.NewRepository(db)
docService := docs.NewService(sourceDir, contentStore, renderer, repo, slog.Default())
syncRepo := NewRepository(db)
service := NewService(syncRepo, docService, contentStore, sourceDir, slog.Default())
return service, sourceDir
}
func TestInitSyncCreatesSnapshot(t *testing.T) {
service, _ := setupTestService(t)
ctx := context.Background()
snap, err := service.InitSync(ctx, "device-1", "user:test")
if err != nil {
t.Fatalf("InitSync() error = %v", err)
}
if snap.ID == "" {
t.Fatal("expected snapshot ID to be set")
}
if snap.DeviceID != "device-1" {
t.Fatalf("expected device ID device-1, got %s", snap.DeviceID)
}
if len(snap.Files) != 2 {
t.Fatalf("expected 2 files, got %d", len(snap.Files))
}
}
func TestApplyDeltaDetectsServerChanges(t *testing.T) {
service, sourceDir := setupTestService(t)
ctx := context.Background()
snap, err := service.InitSync(ctx, "device-1", "user:test")
if err != nil {
t.Fatalf("InitSync() error = %v", err)
}
// Modify a file on the server
time.Sleep(10 * time.Millisecond)
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nUpdated"), 0o644); err != nil {
t.Fatalf("update hello.md: %v", err)
}
// Client sends empty delta
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: nil})
if err != nil {
t.Fatalf("ApplyDelta() error = %v", err)
}
if len(result.ServerDelta) != 1 {
t.Fatalf("expected 1 server delta, got %d", len(result.ServerDelta))
}
change := result.ServerDelta[0]
if change.Type != ChangeUpdate {
t.Fatalf("expected update change, got %s", change.Type)
}
if change.Path != "hello.md" {
t.Fatalf("expected path hello.md, got %s", change.Path)
}
if len(result.Conflicts) != 0 {
t.Fatalf("expected 0 conflicts, got %d", len(result.Conflicts))
}
}
func TestApplyDeltaDetectsConflicts(t *testing.T) {
service, sourceDir := setupTestService(t)
ctx := context.Background()
snap, err := service.InitSync(ctx, "device-1", "user:test")
if err != nil {
t.Fatalf("InitSync() error = %v", err)
}
// Both server and client change the same file
time.Sleep(10 * time.Millisecond)
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nServer Update"), 0o644); err != nil {
t.Fatalf("update hello.md: %v", err)
}
// Client also changes the file (different content = different hash)
clientChange := Change{
Type: ChangeUpdate,
Path: "hello.md",
Hash: "clienthash123456789012345678901234567890123456789012345678901234",
Size: 100,
Modified: time.Now().UTC(),
}
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: []Change{clientChange}})
if err != nil {
t.Fatalf("ApplyDelta() error = %v", err)
}
if len(result.Conflicts) != 1 {
t.Fatalf("expected 1 conflict, got %d", len(result.Conflicts))
}
conflict := result.Conflicts[0]
if conflict.Path != "hello.md" {
t.Fatalf("expected conflict path hello.md, got %s", conflict.Path)
}
if conflict.Strategy != ResolutionLastWriteWins {
t.Fatalf("expected strategy last-write-wins, got %s", conflict.Strategy)
}
}
func TestResolveConflictsCreatesNewSnapshot(t *testing.T) {
service, _ := setupTestService(t)
ctx := context.Background()
snap, err := service.InitSync(ctx, "device-1", "user:test")
if err != nil {
t.Fatalf("InitSync() error = %v", err)
}
resolutions := []Resolution{
{
Path: "hello.md",
Strategy: ResolutionServerWins,
},
}
newSnap, err := service.ResolveConflicts(ctx, snap.ID, resolutions)
if err != nil {
t.Fatalf("ResolveConflicts() error = %v", err)
}
if newSnap.ID == "" {
t.Fatal("expected new snapshot ID to be set")
}
if newSnap.ID == snap.ID {
t.Fatal("expected new snapshot ID to be different from old")
}
if len(newSnap.Files) != 2 {
t.Fatalf("expected 2 files in new snapshot, got %d", len(newSnap.Files))
}
}
func TestGetContentReturnsFileBytes(t *testing.T) {
service, _ := setupTestService(t)
ctx := context.Background()
snap, err := service.InitSync(ctx, "device-1", "user:test")
if err != nil {
t.Fatalf("InitSync() error = %v", err)
}
if len(snap.Files) == 0 {
t.Fatal("expected at least one file")
}
hash := snap.Files[0].Hash
content, err := service.GetContent(hash)
if err != nil {
t.Fatalf("GetContent() error = %v", err)
}
if len(content) == 0 {
t.Fatal("expected non-empty content")
}
}

View File

@@ -0,0 +1,206 @@
package sync
import (
"context"
"database/sql"
"fmt"
"time"
)
// Repository provides database access for sync operations.
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
// APIKeyRecord represents a stored API key.
type APIKeyRecord struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
KeyHash string `json:"keyHash"`
Scopes string `json:"scopes"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}
// CreateSnapshot persists a new snapshot and returns it with an ID.
func (r *Repository) CreateSnapshot(ctx context.Context, deviceID, userID string, files []FileEntry) (*Snapshot, error) {
id := generateID("snap", deviceID)
now := time.Now().UTC()
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
INSERT INTO sync_snapshots (id, device_id, user_id, created_at)
VALUES (?, ?, ?, ?)
`, id, deviceID, userID, now.Format(time.RFC3339)); err != nil {
return nil, fmt.Errorf("insert snapshot: %w", err)
}
for _, f := range files {
if _, err := tx.ExecContext(ctx, `
INSERT INTO sync_files (snapshot_id, path, hash, size_bytes, modified_at)
VALUES (?, ?, ?, ?, ?)
`, id, f.Path, f.Hash, f.Size, f.Modified.Format(time.RFC3339)); err != nil {
return nil, fmt.Errorf("insert sync file %s: %w", f.Path, err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit snapshot: %w", err)
}
return &Snapshot{
ID: id,
DeviceID: deviceID,
CreatedAt: now,
Files: files,
}, nil
}
// GetSnapshot retrieves a snapshot by ID.
func (r *Repository) GetSnapshot(ctx context.Context, snapshotID string) (*Snapshot, error) {
var snap Snapshot
var created string
var userID sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, device_id, user_id, created_at
FROM sync_snapshots
WHERE id = ?
`, snapshotID).Scan(&snap.ID, &snap.DeviceID, &userID, &created)
if err != nil {
return nil, err
}
snap.CreatedAt, err = time.Parse(time.RFC3339, created)
if err != nil {
return nil, fmt.Errorf("parse created_at: %w", err)
}
if userID.Valid {
snap.UserID = userID.String
}
rows, err := r.db.QueryContext(ctx, `
SELECT path, hash, size_bytes, modified_at
FROM sync_files
WHERE snapshot_id = ?
ORDER BY path ASC
`, snapshotID)
if err != nil {
return nil, fmt.Errorf("query sync files: %w", err)
}
defer rows.Close()
for rows.Next() {
var f FileEntry
var modified string
if err := rows.Scan(&f.Path, &f.Hash, &f.Size, &modified); err != nil {
return nil, fmt.Errorf("scan sync file: %w", err)
}
f.Modified, err = time.Parse(time.RFC3339, modified)
if err != nil {
return nil, fmt.Errorf("parse modified_at: %w", err)
}
snap.Files = append(snap.Files, f)
}
return &snap, rows.Err()
}
// ValidateAPIKey checks if an API key hash is valid and returns the associated user.
func (r *Repository) ValidateAPIKey(ctx context.Context, keyHash string) (*APIKeyRecord, error) {
var record APIKeyRecord
var created, expires, lastUsed string
err := r.db.QueryRowContext(ctx, `
SELECT id, user_id, name, key_hash, scopes, created_at, expires_at, last_used_at
FROM api_keys
WHERE key_hash = ?
`, keyHash).Scan(
&record.ID, &record.UserID, &record.Name, &record.KeyHash,
&record.Scopes, &created, &expires, &lastUsed,
)
if err != nil {
return nil, err
}
record.CreatedAt, err = time.Parse(time.RFC3339, created)
if err != nil {
return nil, fmt.Errorf("parse created_at: %w", err)
}
if expires != "" {
t, err := time.Parse(time.RFC3339, expires)
if err != nil {
return nil, fmt.Errorf("parse expires_at: %w", err)
}
if time.Now().UTC().After(t) {
return nil, fmt.Errorf("api key expired")
}
record.ExpiresAt = &t
}
if lastUsed != "" {
t, err := time.Parse(time.RFC3339, lastUsed)
if err != nil {
return nil, fmt.Errorf("parse last_used_at: %w", err)
}
record.LastUsedAt = &t
}
// Update last_used_at
now := time.Now().UTC().Format(time.RFC3339)
if _, err := r.db.ExecContext(ctx, `
UPDATE api_keys SET last_used_at = ? WHERE id = ?
`, now, record.ID); err != nil {
return nil, fmt.Errorf("update last_used_at: %w", err)
}
return &record, nil
}
// ListLatestFiles returns the most recent file states from the latest snapshot.
func (r *Repository) ListLatestFiles(ctx context.Context, deviceID string) ([]FileEntry, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT sf.path, sf.hash, sf.size_bytes, sf.modified_at
FROM sync_files sf
JOIN sync_snapshots ss ON ss.id = sf.snapshot_id
WHERE ss.device_id = ?
AND ss.created_at = (
SELECT MAX(created_at) FROM sync_snapshots WHERE device_id = ?
)
ORDER BY sf.path ASC
`, deviceID, deviceID)
if err != nil {
return nil, fmt.Errorf("query latest files: %w", err)
}
defer rows.Close()
var files []FileEntry
for rows.Next() {
var f FileEntry
var modified string
if err := rows.Scan(&f.Path, &f.Hash, &f.Size, &modified); err != nil {
return nil, fmt.Errorf("scan file: %w", err)
}
f.Modified, err = time.Parse(time.RFC3339, modified)
if err != nil {
return nil, fmt.Errorf("parse modified: %w", err)
}
files = append(files, f)
}
return files, rows.Err()
}
func generateID(prefix, suffix string) string {
return fmt.Sprintf("%s:%s:%d", prefix, suffix, time.Now().UnixNano())
}