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}