1package sqlite
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9
10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/domain/repository"
12 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
13 "github.com/bnema/dumber/internal/logging"
14)
15
16type sessionStateRepo struct {
17 db *sql.DB
18 queries *sqlc.Queries
19}
20
21// NewSessionStateRepository creates a new session state repository.
22func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository {
23 return &sessionStateRepo{
24 db: db,
25 queries: sqlc.New(db),
26 }
27}
28
29// SaveSnapshot saves or updates a session state snapshot.
30func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.SessionState) error {
31 log := logging.FromContext(ctx)
32 if state == nil {
33 return errors.New("session state cannot be nil")
34 }
35
36 stateJSON, err := json.Marshal(state)
37 if err != nil {
38 log.Error().Err(err).Msg("failed to marshal session state")
39 return err
40 }
41
42 log.Debug().
43 Str("session_id", string(state.SessionID)).
44 Int("tab_count", len(state.Tabs)).
45 Int("pane_count", state.CountPanes()).
46 Msg("saving session state snapshot")
47
48 tx, err := r.db.BeginTx(ctx, nil)
49 if err != nil {
50 return fmt.Errorf("begin snapshot transaction: %w", err)
51 }
52 defer func() {
53 if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
54 log.Debug().Err(rollbackErr).Msg("snapshot rollback reported non-terminal error")
55 }
56 }()
57
58 txQueries := r.queries.WithTx(tx)
59 if err := txQueries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{
60 SessionID: string(state.SessionID),
61 StateJson: string(stateJSON),
62 Version: int64(state.Version),
63 TabCount: int64(len(state.Tabs)),
64 PaneCount: int64(state.CountPanes()),
65 UpdatedAt: state.SavedAt,
66 }); err != nil {
67 return err
68 }
69
70 if err := tx.Commit(); err != nil {
71 return fmt.Errorf("commit snapshot transaction: %w", err)
72 }
73
74 return nil
75}
76
77// GetSnapshot returns the latest snapshot for a session.
78func (r *sessionStateRepo) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) {
79 row, err := r.queries.GetSessionState(ctx, string(sessionID))
80 if err != nil {
81 if errors.Is(err, sql.ErrNoRows) {
82 return nil, nil
83 }
84 return nil, err
85 }
86
87 var state entity.SessionState
88 if err := json.Unmarshal([]byte(row.StateJson), &state); err != nil {
89 logging.FromContext(ctx).Error().Err(err).
90 Str("session_id", string(sessionID)).
91 Msg("failed to unmarshal session state")
92 return nil, err
93 }
94
95 return &state, nil
96}
97
98// DeleteSnapshot removes a session's snapshot.
99func (r *sessionStateRepo) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
100 log := logging.FromContext(ctx)
101 log.Debug().Str("session_id", string(sessionID)).Msg("deleting session state snapshot")
102 return r.queries.DeleteSessionState(ctx, string(sessionID))
103}
104
105// GetAllSnapshots returns all snapshots with summary info.
106func (r *sessionStateRepo) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) {
107 rows, err := r.queries.GetAllSessionStates(ctx)
108 if err != nil {
109 return nil, err
110 }
111
112 states := make([]*entity.SessionState, 0, len(rows))
113 for _, row := range rows {
114 var state entity.SessionState
115 if err := json.Unmarshal([]byte(row.StateJson), &state); err != nil {
116 logging.FromContext(ctx).Warn().Err(err).
117 Str("session_id", row.SessionID).
118 Msg("skipping corrupted session state")
119 continue
120 }
121 states = append(states, &state)
122 }
123
124 return states, nil
125}
126
127// GetTotalSnapshotsSize returns the total size of all session snapshots in bytes.
128func (r *sessionStateRepo) GetTotalSnapshotsSize(ctx context.Context) (int64, error) {
129 result, err := r.queries.GetTotalSessionStatesSize(ctx)
130 if err != nil {
131 return 0, err
132 }
133 // SQLite returns int64 for SUM, but COALESCE wraps it as interface{}
134 switch v := result.(type) {
135 case int64:
136 return v, nil
137 case float64:
138 return int64(v), nil
139 default:
140 return 0, nil
141 }
142}