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