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}