file.go

  1package history
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"fmt"
  7	"strconv"
  8	"strings"
  9	"time"
 10
 11	"github.com/google/uuid"
 12	"github.com/kujtimiihoxha/opencode/internal/db"
 13	"github.com/kujtimiihoxha/opencode/internal/pubsub"
 14)
 15
 16const (
 17	InitialVersion = "initial"
 18)
 19
 20type File struct {
 21	ID        string
 22	SessionID string
 23	Path      string
 24	Content   string
 25	Version   string
 26	CreatedAt int64
 27	UpdatedAt int64
 28}
 29
 30type Service interface {
 31	pubsub.Suscriber[File]
 32	Create(ctx context.Context, sessionID, path, content string) (File, error)
 33	CreateVersion(ctx context.Context, sessionID, path, content string) (File, error)
 34	Get(ctx context.Context, id string) (File, error)
 35	GetByPathAndSession(ctx context.Context, path, sessionID string) (File, error)
 36	ListBySession(ctx context.Context, sessionID string) ([]File, error)
 37	ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
 38	Update(ctx context.Context, file File) (File, error)
 39	Delete(ctx context.Context, id string) error
 40	DeleteSessionFiles(ctx context.Context, sessionID string) error
 41}
 42
 43type service struct {
 44	*pubsub.Broker[File]
 45	db *sql.DB
 46	q  *db.Queries
 47}
 48
 49func NewService(q *db.Queries, db *sql.DB) Service {
 50	return &service{
 51		Broker: pubsub.NewBroker[File](),
 52		q:      q,
 53	}
 54}
 55
 56func (s *service) Create(ctx context.Context, sessionID, path, content string) (File, error) {
 57	return s.createWithVersion(ctx, sessionID, path, content, InitialVersion)
 58}
 59
 60func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
 61	// Get the latest version for this path
 62	files, err := s.q.ListFilesByPath(ctx, path)
 63	if err != nil {
 64		return File{}, err
 65	}
 66
 67	if len(files) == 0 {
 68		// No previous versions, create initial
 69		return s.Create(ctx, sessionID, path, content)
 70	}
 71
 72	// Get the latest version
 73	latestFile := files[0] // Files are ordered by created_at DESC
 74	latestVersion := latestFile.Version
 75
 76	// Generate the next version
 77	var nextVersion string
 78	if latestVersion == InitialVersion {
 79		nextVersion = "v1"
 80	} else if strings.HasPrefix(latestVersion, "v") {
 81		versionNum, err := strconv.Atoi(latestVersion[1:])
 82		if err != nil {
 83			// If we can't parse the version, just use a timestamp-based version
 84			nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
 85		} else {
 86			nextVersion = fmt.Sprintf("v%d", versionNum+1)
 87		}
 88	} else {
 89		// If the version format is unexpected, use a timestamp-based version
 90		nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
 91	}
 92
 93	return s.createWithVersion(ctx, sessionID, path, content, nextVersion)
 94}
 95
 96func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string) (File, error) {
 97	// Maximum number of retries for transaction conflicts
 98	const maxRetries = 3
 99	var file File
100	var err error
101
102	// Retry loop for transaction conflicts
103	for attempt := 0; attempt < maxRetries; attempt++ {
104		// Start a transaction
105		tx, err := s.db.BeginTx(ctx, nil)
106		if err != nil {
107			return File{}, fmt.Errorf("failed to begin transaction: %w", err)
108		}
109
110		// Create a new queries instance with the transaction
111		qtx := s.q.WithTx(tx)
112
113		// Try to create the file within the transaction
114		dbFile, err := qtx.CreateFile(ctx, db.CreateFileParams{
115			ID:        uuid.New().String(),
116			SessionID: sessionID,
117			Path:      path,
118			Content:   content,
119			Version:   version,
120		})
121		if err != nil {
122			// Rollback the transaction
123			tx.Rollback()
124
125			// Check if this is a uniqueness constraint violation
126			if strings.Contains(err.Error(), "UNIQUE constraint failed") {
127				if attempt < maxRetries-1 {
128					// If we have retries left, generate a new version and try again
129					if strings.HasPrefix(version, "v") {
130						versionNum, parseErr := strconv.Atoi(version[1:])
131						if parseErr == nil {
132							version = fmt.Sprintf("v%d", versionNum+1)
133							continue
134						}
135					}
136					// If we can't parse the version, use a timestamp-based version
137					version = fmt.Sprintf("v%d", time.Now().Unix())
138					continue
139				}
140			}
141			return File{}, err
142		}
143
144		// Commit the transaction
145		if err = tx.Commit(); err != nil {
146			return File{}, fmt.Errorf("failed to commit transaction: %w", err)
147		}
148
149		file = s.fromDBItem(dbFile)
150		s.Publish(pubsub.CreatedEvent, file)
151		return file, nil
152	}
153
154	return file, err
155}
156
157func (s *service) Get(ctx context.Context, id string) (File, error) {
158	dbFile, err := s.q.GetFile(ctx, id)
159	if err != nil {
160		return File{}, err
161	}
162	return s.fromDBItem(dbFile), nil
163}
164
165func (s *service) GetByPathAndSession(ctx context.Context, path, sessionID string) (File, error) {
166	dbFile, err := s.q.GetFileByPathAndSession(ctx, db.GetFileByPathAndSessionParams{
167		Path:      path,
168		SessionID: sessionID,
169	})
170	if err != nil {
171		return File{}, err
172	}
173	return s.fromDBItem(dbFile), nil
174}
175
176func (s *service) ListBySession(ctx context.Context, sessionID string) ([]File, error) {
177	dbFiles, err := s.q.ListFilesBySession(ctx, sessionID)
178	if err != nil {
179		return nil, err
180	}
181	files := make([]File, len(dbFiles))
182	for i, dbFile := range dbFiles {
183		files[i] = s.fromDBItem(dbFile)
184	}
185	return files, nil
186}
187
188func (s *service) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
189	dbFiles, err := s.q.ListLatestSessionFiles(ctx, sessionID)
190	if err != nil {
191		return nil, err
192	}
193	files := make([]File, len(dbFiles))
194	for i, dbFile := range dbFiles {
195		files[i] = s.fromDBItem(dbFile)
196	}
197	return files, nil
198}
199
200func (s *service) Update(ctx context.Context, file File) (File, error) {
201	dbFile, err := s.q.UpdateFile(ctx, db.UpdateFileParams{
202		ID:      file.ID,
203		Content: file.Content,
204		Version: file.Version,
205	})
206	if err != nil {
207		return File{}, err
208	}
209	updatedFile := s.fromDBItem(dbFile)
210	s.Publish(pubsub.UpdatedEvent, updatedFile)
211	return updatedFile, nil
212}
213
214func (s *service) Delete(ctx context.Context, id string) error {
215	file, err := s.Get(ctx, id)
216	if err != nil {
217		return err
218	}
219	err = s.q.DeleteFile(ctx, id)
220	if err != nil {
221		return err
222	}
223	s.Publish(pubsub.DeletedEvent, file)
224	return nil
225}
226
227func (s *service) DeleteSessionFiles(ctx context.Context, sessionID string) error {
228	files, err := s.ListBySession(ctx, sessionID)
229	if err != nil {
230		return err
231	}
232	for _, file := range files {
233		err = s.Delete(ctx, file.ID)
234		if err != nil {
235			return err
236		}
237	}
238	return nil
239}
240
241func (s *service) fromDBItem(item db.File) File {
242	return File{
243		ID:        item.ID,
244		SessionID: item.SessionID,
245		Path:      item.Path,
246		Content:   item.Content,
247		Version:   item.Version,
248		CreatedAt: item.CreatedAt,
249		UpdatedAt: item.UpdatedAt,
250	}
251}