Add conflict resolution diff UI and app branding
This commit is contained in:
114
apps/server/internal/sync/protocol.go
Normal file
114
apps/server/internal/sync/protocol.go
Normal 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"`
|
||||
}
|
||||
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
|
||||
}
|
||||
231
apps/server/internal/sync/service_test.go
Normal file
231
apps/server/internal/sync/service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
206
apps/server/internal/sync/store.go
Normal file
206
apps/server/internal/sync/store.go
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user