Add conflict resolution diff UI and app branding
This commit is contained in:
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