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()) }