detached f1ba35b

feat(infra): add SQLite session state persistence and snapshot service

commit
f1ba35b322b7f22971a9cac19c65bf5344a3f973
author
bnema · · 2025-12-24 21:27 UTC
committer
brice · · 2025-12-25 05:18 UTC
parent
5446b2d
signature
unsigned
7 files · +452 -0
A internal/infrastructure/persistence/sqlite/migrations/006_session_states.sql +14 -0
@@ -0,0 +1,14 @@
1+-- +goose Up
2+CREATE TABLE session_states (
3+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
4+ state_json TEXT NOT NULL,
5+ version INTEGER NOT NULL DEFAULT 1,
6+ tab_count INTEGER NOT NULL DEFAULT 0,
7+ pane_count INTEGER NOT NULL DEFAULT 0,
8+ updated_at TIMESTAMP NOT NULL
9+);
10+
11+CREATE INDEX idx_session_states_updated_at ON session_states(updated_at DESC);
12+
13+-- +goose Down
14+DROP TABLE session_states;
@@ -0,0 +1,14 @@
1+-- +goose Up
2+CREATE TABLE session_states (
3+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
4+ state_json TEXT NOT NULL,
5+ version INTEGER NOT NULL DEFAULT 1,
6+ tab_count INTEGER NOT NULL DEFAULT 0,
7+ pane_count INTEGER NOT NULL DEFAULT 0,
8+ updated_at TIMESTAMP NOT NULL
9+);
10+
11+CREATE INDEX idx_session_states_updated_at ON session_states(updated_at DESC);
12+
13+-- +goose Down
14+DROP TABLE session_states;
A internal/infrastructure/persistence/sqlite/queries/session_states.sql +32 -0
@@ -0,0 +1,32 @@
1+-- name: UpsertSessionState :exec
2+INSERT INTO session_states (session_id, state_json, version, tab_count, pane_count, updated_at)
3+VALUES (?, ?, ?, ?, ?, ?)
4+ON CONFLICT(session_id) DO UPDATE SET
5+ state_json = excluded.state_json,
6+ version = excluded.version,
7+ tab_count = excluded.tab_count,
8+ pane_count = excluded.pane_count,
9+ updated_at = excluded.updated_at;
10+
11+-- name: GetSessionState :one
12+SELECT session_id, state_json, version, tab_count, pane_count, updated_at
13+FROM session_states
14+WHERE session_id = ?;
15+
16+-- name: GetAllSessionStates :many
17+SELECT session_id, state_json, version, tab_count, pane_count, updated_at
18+FROM session_states
19+ORDER BY updated_at DESC;
20+
21+-- name: DeleteSessionState :exec
22+DELETE FROM session_states WHERE session_id = ?;
23+
24+-- name: GetSessionsWithState :many
25+SELECT
26+ s.id, s.type, s.started_at, s.ended_at,
27+ ss.state_json, ss.tab_count, ss.pane_count, ss.updated_at as state_updated_at
28+FROM sessions s
29+LEFT JOIN session_states ss ON s.id = ss.session_id
30+WHERE s.type = 'browser'
31+ORDER BY s.started_at DESC
32+LIMIT ?;
@@ -0,0 +1,32 @@
1+-- name: UpsertSessionState :exec
2+INSERT INTO session_states (session_id, state_json, version, tab_count, pane_count, updated_at)
3+VALUES (?, ?, ?, ?, ?, ?)
4+ON CONFLICT(session_id) DO UPDATE SET
5+ state_json = excluded.state_json,
6+ version = excluded.version,
7+ tab_count = excluded.tab_count,
8+ pane_count = excluded.pane_count,
9+ updated_at = excluded.updated_at;
10+
11+-- name: GetSessionState :one
12+SELECT session_id, state_json, version, tab_count, pane_count, updated_at
13+FROM session_states
14+WHERE session_id = ?;
15+
16+-- name: GetAllSessionStates :many
17+SELECT session_id, state_json, version, tab_count, pane_count, updated_at
18+FROM session_states
19+ORDER BY updated_at DESC;
20+
21+-- name: DeleteSessionState :exec
22+DELETE FROM session_states WHERE session_id = ?;
23+
24+-- name: GetSessionsWithState :many
25+SELECT
26+ s.id, s.type, s.started_at, s.ended_at,
27+ ss.state_json, ss.tab_count, ss.pane_count, ss.updated_at as state_updated_at
28+FROM sessions s
29+LEFT JOIN session_states ss ON s.id = ss.session_id
30+WHERE s.type = 'browser'
31+ORDER BY s.started_at DESC
32+LIMIT ?;
A internal/infrastructure/persistence/sqlite/session_state_repo.go +101 -0
@@ -0,0 +1,101 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "encoding/json"
7+ "errors"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/domain/repository"
11+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+type sessionStateRepo struct {
16+ queries *sqlc.Queries
17+}
18+
19+// NewSessionStateRepository creates a new session state repository.
20+func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository {
21+ return &sessionStateRepo{queries: sqlc.New(db)}
22+}
23+
24+// SaveSnapshot saves or updates a session state snapshot.
25+func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.SessionState) error {
26+ log := logging.FromContext(ctx)
27+ if state == nil {
28+ return errors.New("session state cannot be nil")
29+ }
30+
31+ stateJSON, err := json.Marshal(state)
32+ if err != nil {
33+ log.Error().Err(err).Msg("failed to marshal session state")
34+ return err
35+ }
36+
37+ log.Debug().
38+ Str("session_id", string(state.SessionID)).
39+ Int("tab_count", len(state.Tabs)).
40+ Int("pane_count", state.CountPanes()).
41+ Msg("saving session state snapshot")
42+
43+ return r.queries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{
44+ SessionID: string(state.SessionID),
45+ StateJson: string(stateJSON),
46+ Version: int64(state.Version),
47+ TabCount: int64(len(state.Tabs)),
48+ PaneCount: int64(state.CountPanes()),
49+ UpdatedAt: state.SavedAt,
50+ })
51+}
52+
53+// GetSnapshot returns the latest snapshot for a session.
54+func (r *sessionStateRepo) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) {
55+ row, err := r.queries.GetSessionState(ctx, string(sessionID))
56+ if err != nil {
57+ if errors.Is(err, sql.ErrNoRows) {
58+ return nil, nil
59+ }
60+ return nil, err
61+ }
62+
63+ var state entity.SessionState
64+ if err := json.Unmarshal([]byte(row.StateJson), &state); err != nil {
65+ logging.FromContext(ctx).Error().Err(err).
66+ Str("session_id", string(sessionID)).
67+ Msg("failed to unmarshal session state")
68+ return nil, err
69+ }
70+
71+ return &state, nil
72+}
73+
74+// DeleteSnapshot removes a session's snapshot.
75+func (r *sessionStateRepo) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
76+ log := logging.FromContext(ctx)
77+ log.Debug().Str("session_id", string(sessionID)).Msg("deleting session state snapshot")
78+ return r.queries.DeleteSessionState(ctx, string(sessionID))
79+}
80+
81+// GetAllSnapshots returns all snapshots with summary info.
82+func (r *sessionStateRepo) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) {
83+ rows, err := r.queries.GetAllSessionStates(ctx)
84+ if err != nil {
85+ return nil, err
86+ }
87+
88+ states := make([]*entity.SessionState, 0, len(rows))
89+ for _, row := range rows {
90+ var state entity.SessionState
91+ if err := json.Unmarshal([]byte(row.StateJson), &state); err != nil {
92+ logging.FromContext(ctx).Warn().Err(err).
93+ Str("session_id", row.SessionID).
94+ Msg("skipping corrupted session state")
95+ continue
96+ }
97+ states = append(states, &state)
98+ }
99+
100+ return states, nil
101+}
@@ -0,0 +1,101 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "encoding/json"
7+ "errors"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/domain/repository"
11+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+type sessionStateRepo struct {
16+ queries *sqlc.Queries
17+}
18+
19+// NewSessionStateRepository creates a new session state repository.
20+func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository {
21+ return &sessionStateRepo{queries: sqlc.New(db)}
22+}
23+
24+// SaveSnapshot saves or updates a session state snapshot.
25+func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.SessionState) error {
26+ log := logging.FromContext(ctx)
27+ if state == nil {
28+ return errors.New("session state cannot be nil")
29+ }
30+
31+ stateJSON, err := json.Marshal(state)
32+ if err != nil {
33+ log.Error().Err(err).Msg("failed to marshal session state")
34+ return err
35+ }
36+
37+ log.Debug().
38+ Str("session_id", string(state.SessionID)).
39+ Int("tab_count", len(state.Tabs)).
40+ Int("pane_count", state.CountPanes()).
41+ Msg("saving session state snapshot")
42+
43+ return r.queries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{
44+ SessionID: string(state.SessionID),
45+ StateJson: string(stateJSON),
46+ Version: int64(state.Version),
47+ TabCount: int64(len(state.Tabs)),
48+ PaneCount: int64(state.CountPanes()),
49+ UpdatedAt: state.SavedAt,
50+ })
51+}
52+
53+// GetSnapshot returns the latest snapshot for a session.
54+func (r *sessionStateRepo) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) {
55+ row, err := r.queries.GetSessionState(ctx, string(sessionID))
56+ if err != nil {
57+ if errors.Is(err, sql.ErrNoRows) {
58+ return nil, nil
59+ }
60+ return nil, err
61+ }
62+
63+ var state entity.SessionState
64+ if err := json.Unmarshal([]byte(row.StateJson), &state); err != nil {
65+ logging.FromContext(ctx).Error().Err(err).
66+ Str("session_id", string(sessionID)).
67+ Msg("failed to unmarshal session state")
68+ return nil, err
69+ }
70+
71+ return &state, nil
72+}
73+
74+// DeleteSnapshot removes a session's snapshot.
75+func (r *sessionStateRepo) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
76+ log := logging.FromContext(ctx)
77+ log.Debug().Str("session_id", string(sessionID)).Msg("deleting session state snapshot")
78+ return r.queries.DeleteSessionState(ctx, string(sessionID))
79+}
80+
81+// GetAllSnapshots returns all snapshots with summary info.
82+func (r *sessionStateRepo) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) {
83+ rows, err := r.queries.GetAllSessionStates(ctx)
84+ if err != nil {
85+ return nil, err
86+ }
87+
88+ states := make([]*entity.SessionState, 0, len(rows))
89+ for _, row := range rows {
90+ var state entity.SessionState
91+ if err := json.Unmarshal([]byte(row.StateJson), &state); err != nil {
92+ logging.FromContext(ctx).Warn().Err(err).
93+ Str("session_id", row.SessionID).
94+ Msg("skipping corrupted session state")
95+ continue
96+ }
97+ states = append(states, &state)
98+ }
99+
100+ return states, nil
101+}
M internal/infrastructure/persistence/sqlite/sqlc/models.go +9 -0
@@ -70,6 +70,15 @@ type Session struct {
7070 EndedAt sql.NullTime `json:"ended_at"`
7171 }
7272
73+type SessionState struct {
74+ SessionID string `json:"session_id"`
75+ StateJson string `json:"state_json"`
76+ Version int64 `json:"version"`
77+ TabCount int64 `json:"tab_count"`
78+ PaneCount int64 `json:"pane_count"`
79+ UpdatedAt time.Time `json:"updated_at"`
80+}
81+
7382 type ZoomLevel struct {
7483 Domain string `json:"domain"`
7584 ZoomFactor float64 `json:"zoom_factor"`
@@ -70,6 +70,15 @@ type Session struct {
70 EndedAt sql.NullTime `json:"ended_at"`70 EndedAt sql.NullTime `json:"ended_at"`
71 }71 }
72 72
73+type SessionState struct {
74+ SessionID string `json:"session_id"`
75+ StateJson string `json:"state_json"`
76+ Version int64 `json:"version"`
77+ TabCount int64 `json:"tab_count"`
78+ PaneCount int64 `json:"pane_count"`
79+ UpdatedAt time.Time `json:"updated_at"`
80+}
81+
73 type ZoomLevel struct {82 type ZoomLevel struct {
74 Domain string `json:"domain"`83 Domain string `json:"domain"`
75 ZoomFactor float64 `json:"zoom_factor"`84 ZoomFactor float64 `json:"zoom_factor"`
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +5 -0
@@ -21,11 +21,13 @@ type Querier interface {
2121 DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error
2222 DeleteHistoryByID(ctx context.Context, id int64) error
2323 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
24+ DeleteSessionState(ctx context.Context, sessionID string) error
2425 DeleteTag(ctx context.Context, id int64) error
2526 DeleteZoomLevel(ctx context.Context, domain string) error
2627 GetActiveBrowserSession(ctx context.Context) (Session, error)
2728 GetAllFavorites(ctx context.Context) ([]Favorite, error)
2829 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
30+ GetAllSessionStates(ctx context.Context) ([]SessionState, error)
2931 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
3032 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
3133 GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)
@@ -44,6 +46,8 @@ type Querier interface {
4446 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
4547 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
4648 GetSessionByID(ctx context.Context, id string) (Session, error)
49+ GetSessionState(ctx context.Context, sessionID string) (SessionState, error)
50+ GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error)
4751 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)
4852 GetTagByName(ctx context.Context, name string) (FavoriteTag, error)
4953 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
@@ -66,6 +70,7 @@ type Querier interface {
6670 UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error
6771 UpdateTag(ctx context.Context, arg UpdateTagParams) error
6872 UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error
73+ UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error
6974 }
7075
7176 var _ Querier = (*Queries)(nil)
@@ -21,11 +21,13 @@ type Querier interface {
21 DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error21 DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error
22 DeleteHistoryByID(ctx context.Context, id int64) error22 DeleteHistoryByID(ctx context.Context, id int64) error
23 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error23 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
24+ DeleteSessionState(ctx context.Context, sessionID string) error
24 DeleteTag(ctx context.Context, id int64) error25 DeleteTag(ctx context.Context, id int64) error
25 DeleteZoomLevel(ctx context.Context, domain string) error26 DeleteZoomLevel(ctx context.Context, domain string) error
26 GetActiveBrowserSession(ctx context.Context) (Session, error)27 GetActiveBrowserSession(ctx context.Context) (Session, error)
27 GetAllFavorites(ctx context.Context) ([]Favorite, error)28 GetAllFavorites(ctx context.Context) ([]Favorite, error)
28 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)29 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
30+ GetAllSessionStates(ctx context.Context) ([]SessionState, error)
29 GetAllTags(ctx context.Context) ([]FavoriteTag, error)31 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
30 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)32 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
31 GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)33 GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)
@@ -44,6 +46,8 @@ type Querier interface {
44 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)46 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
45 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)47 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
46 GetSessionByID(ctx context.Context, id string) (Session, error)48 GetSessionByID(ctx context.Context, id string) (Session, error)
49+ GetSessionState(ctx context.Context, sessionID string) (SessionState, error)
50+ GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error)
47 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)51 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)
48 GetTagByName(ctx context.Context, name string) (FavoriteTag, error)52 GetTagByName(ctx context.Context, name string) (FavoriteTag, error)
49 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)53 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
@@ -66,6 +70,7 @@ type Querier interface {
66 UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error70 UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error
67 UpdateTag(ctx context.Context, arg UpdateTagParams) error71 UpdateTag(ctx context.Context, arg UpdateTagParams) error
68 UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error72 UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error
73+ UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error
69 }74 }
70 75
71 var _ Querier = (*Queries)(nil)76 var _ Querier = (*Queries)(nil)
A internal/infrastructure/persistence/sqlite/sqlc/session_states.sql.go +163 -0
@@ -0,0 +1,163 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: session_states.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+ "database/sql"
11+ "time"
12+)
13+
14+const DeleteSessionState = `-- name: DeleteSessionState :exec
15+DELETE FROM session_states WHERE session_id = ?
16+`
17+
18+func (q *Queries) DeleteSessionState(ctx context.Context, sessionID string) error {
19+ _, err := q.db.ExecContext(ctx, DeleteSessionState, sessionID)
20+ return err
21+}
22+
23+const GetAllSessionStates = `-- name: GetAllSessionStates :many
24+SELECT session_id, state_json, version, tab_count, pane_count, updated_at
25+FROM session_states
26+ORDER BY updated_at DESC
27+`
28+
29+func (q *Queries) GetAllSessionStates(ctx context.Context) ([]SessionState, error) {
30+ rows, err := q.db.QueryContext(ctx, GetAllSessionStates)
31+ if err != nil {
32+ return nil, err
33+ }
34+ defer rows.Close()
35+ items := []SessionState{}
36+ for rows.Next() {
37+ var i SessionState
38+ if err := rows.Scan(
39+ &i.SessionID,
40+ &i.StateJson,
41+ &i.Version,
42+ &i.TabCount,
43+ &i.PaneCount,
44+ &i.UpdatedAt,
45+ ); err != nil {
46+ return nil, err
47+ }
48+ items = append(items, i)
49+ }
50+ if err := rows.Close(); err != nil {
51+ return nil, err
52+ }
53+ if err := rows.Err(); err != nil {
54+ return nil, err
55+ }
56+ return items, nil
57+}
58+
59+const GetSessionState = `-- name: GetSessionState :one
60+SELECT session_id, state_json, version, tab_count, pane_count, updated_at
61+FROM session_states
62+WHERE session_id = ?
63+`
64+
65+func (q *Queries) GetSessionState(ctx context.Context, sessionID string) (SessionState, error) {
66+ row := q.db.QueryRowContext(ctx, GetSessionState, sessionID)
67+ var i SessionState
68+ err := row.Scan(
69+ &i.SessionID,
70+ &i.StateJson,
71+ &i.Version,
72+ &i.TabCount,
73+ &i.PaneCount,
74+ &i.UpdatedAt,
75+ )
76+ return i, err
77+}
78+
79+const GetSessionsWithState = `-- name: GetSessionsWithState :many
80+SELECT
81+ s.id, s.type, s.started_at, s.ended_at,
82+ ss.state_json, ss.tab_count, ss.pane_count, ss.updated_at as state_updated_at
83+FROM sessions s
84+LEFT JOIN session_states ss ON s.id = ss.session_id
85+WHERE s.type = 'browser'
86+ORDER BY s.started_at DESC
87+LIMIT ?
88+`
89+
90+type GetSessionsWithStateRow struct {
91+ ID string `json:"id"`
92+ Type string `json:"type"`
93+ StartedAt time.Time `json:"started_at"`
94+ EndedAt sql.NullTime `json:"ended_at"`
95+ StateJson sql.NullString `json:"state_json"`
96+ TabCount sql.NullInt64 `json:"tab_count"`
97+ PaneCount sql.NullInt64 `json:"pane_count"`
98+ StateUpdatedAt sql.NullTime `json:"state_updated_at"`
99+}
100+
101+func (q *Queries) GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error) {
102+ rows, err := q.db.QueryContext(ctx, GetSessionsWithState, limit)
103+ if err != nil {
104+ return nil, err
105+ }
106+ defer rows.Close()
107+ items := []GetSessionsWithStateRow{}
108+ for rows.Next() {
109+ var i GetSessionsWithStateRow
110+ if err := rows.Scan(
111+ &i.ID,
112+ &i.Type,
113+ &i.StartedAt,
114+ &i.EndedAt,
115+ &i.StateJson,
116+ &i.TabCount,
117+ &i.PaneCount,
118+ &i.StateUpdatedAt,
119+ ); err != nil {
120+ return nil, err
121+ }
122+ items = append(items, i)
123+ }
124+ if err := rows.Close(); err != nil {
125+ return nil, err
126+ }
127+ if err := rows.Err(); err != nil {
128+ return nil, err
129+ }
130+ return items, nil
131+}
132+
133+const UpsertSessionState = `-- name: UpsertSessionState :exec
134+INSERT INTO session_states (session_id, state_json, version, tab_count, pane_count, updated_at)
135+VALUES (?, ?, ?, ?, ?, ?)
136+ON CONFLICT(session_id) DO UPDATE SET
137+ state_json = excluded.state_json,
138+ version = excluded.version,
139+ tab_count = excluded.tab_count,
140+ pane_count = excluded.pane_count,
141+ updated_at = excluded.updated_at
142+`
143+
144+type UpsertSessionStateParams struct {
145+ SessionID string `json:"session_id"`
146+ StateJson string `json:"state_json"`
147+ Version int64 `json:"version"`
148+ TabCount int64 `json:"tab_count"`
149+ PaneCount int64 `json:"pane_count"`
150+ UpdatedAt time.Time `json:"updated_at"`
151+}
152+
153+func (q *Queries) UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error {
154+ _, err := q.db.ExecContext(ctx, UpsertSessionState,
155+ arg.SessionID,
156+ arg.StateJson,
157+ arg.Version,
158+ arg.TabCount,
159+ arg.PaneCount,
160+ arg.UpdatedAt,
161+ )
162+ return err
163+}
@@ -0,0 +1,163 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: session_states.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+ "database/sql"
11+ "time"
12+)
13+
14+const DeleteSessionState = `-- name: DeleteSessionState :exec
15+DELETE FROM session_states WHERE session_id = ?
16+`
17+
18+func (q *Queries) DeleteSessionState(ctx context.Context, sessionID string) error {
19+ _, err := q.db.ExecContext(ctx, DeleteSessionState, sessionID)
20+ return err
21+}
22+
23+const GetAllSessionStates = `-- name: GetAllSessionStates :many
24+SELECT session_id, state_json, version, tab_count, pane_count, updated_at
25+FROM session_states
26+ORDER BY updated_at DESC
27+`
28+
29+func (q *Queries) GetAllSessionStates(ctx context.Context) ([]SessionState, error) {
30+ rows, err := q.db.QueryContext(ctx, GetAllSessionStates)
31+ if err != nil {
32+ return nil, err
33+ }
34+ defer rows.Close()
35+ items := []SessionState{}
36+ for rows.Next() {
37+ var i SessionState
38+ if err := rows.Scan(
39+ &i.SessionID,
40+ &i.StateJson,
41+ &i.Version,
42+ &i.TabCount,
43+ &i.PaneCount,
44+ &i.UpdatedAt,
45+ ); err != nil {
46+ return nil, err
47+ }
48+ items = append(items, i)
49+ }
50+ if err := rows.Close(); err != nil {
51+ return nil, err
52+ }
53+ if err := rows.Err(); err != nil {
54+ return nil, err
55+ }
56+ return items, nil
57+}
58+
59+const GetSessionState = `-- name: GetSessionState :one
60+SELECT session_id, state_json, version, tab_count, pane_count, updated_at
61+FROM session_states
62+WHERE session_id = ?
63+`
64+
65+func (q *Queries) GetSessionState(ctx context.Context, sessionID string) (SessionState, error) {
66+ row := q.db.QueryRowContext(ctx, GetSessionState, sessionID)
67+ var i SessionState
68+ err := row.Scan(
69+ &i.SessionID,
70+ &i.StateJson,
71+ &i.Version,
72+ &i.TabCount,
73+ &i.PaneCount,
74+ &i.UpdatedAt,
75+ )
76+ return i, err
77+}
78+
79+const GetSessionsWithState = `-- name: GetSessionsWithState :many
80+SELECT
81+ s.id, s.type, s.started_at, s.ended_at,
82+ ss.state_json, ss.tab_count, ss.pane_count, ss.updated_at as state_updated_at
83+FROM sessions s
84+LEFT JOIN session_states ss ON s.id = ss.session_id
85+WHERE s.type = 'browser'
86+ORDER BY s.started_at DESC
87+LIMIT ?
88+`
89+
90+type GetSessionsWithStateRow struct {
91+ ID string `json:"id"`
92+ Type string `json:"type"`
93+ StartedAt time.Time `json:"started_at"`
94+ EndedAt sql.NullTime `json:"ended_at"`
95+ StateJson sql.NullString `json:"state_json"`
96+ TabCount sql.NullInt64 `json:"tab_count"`
97+ PaneCount sql.NullInt64 `json:"pane_count"`
98+ StateUpdatedAt sql.NullTime `json:"state_updated_at"`
99+}
100+
101+func (q *Queries) GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error) {
102+ rows, err := q.db.QueryContext(ctx, GetSessionsWithState, limit)
103+ if err != nil {
104+ return nil, err
105+ }
106+ defer rows.Close()
107+ items := []GetSessionsWithStateRow{}
108+ for rows.Next() {
109+ var i GetSessionsWithStateRow
110+ if err := rows.Scan(
111+ &i.ID,
112+ &i.Type,
113+ &i.StartedAt,
114+ &i.EndedAt,
115+ &i.StateJson,
116+ &i.TabCount,
117+ &i.PaneCount,
118+ &i.StateUpdatedAt,
119+ ); err != nil {
120+ return nil, err
121+ }
122+ items = append(items, i)
123+ }
124+ if err := rows.Close(); err != nil {
125+ return nil, err
126+ }
127+ if err := rows.Err(); err != nil {
128+ return nil, err
129+ }
130+ return items, nil
131+}
132+
133+const UpsertSessionState = `-- name: UpsertSessionState :exec
134+INSERT INTO session_states (session_id, state_json, version, tab_count, pane_count, updated_at)
135+VALUES (?, ?, ?, ?, ?, ?)
136+ON CONFLICT(session_id) DO UPDATE SET
137+ state_json = excluded.state_json,
138+ version = excluded.version,
139+ tab_count = excluded.tab_count,
140+ pane_count = excluded.pane_count,
141+ updated_at = excluded.updated_at
142+`
143+
144+type UpsertSessionStateParams struct {
145+ SessionID string `json:"session_id"`
146+ StateJson string `json:"state_json"`
147+ Version int64 `json:"version"`
148+ TabCount int64 `json:"tab_count"`
149+ PaneCount int64 `json:"pane_count"`
150+ UpdatedAt time.Time `json:"updated_at"`
151+}
152+
153+func (q *Queries) UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error {
154+ _, err := q.db.ExecContext(ctx, UpsertSessionState,
155+ arg.SessionID,
156+ arg.StateJson,
157+ arg.Version,
158+ arg.TabCount,
159+ arg.PaneCount,
160+ arg.UpdatedAt,
161+ )
162+ return err
163+}
A internal/infrastructure/snapshot/service.go +128 -0
@@ -0,0 +1,128 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// Service handles debounced session state snapshots.
14+type Service struct {
15+ snapshotUC *usecase.SnapshotSessionUseCase
16+ provider port.TabListProvider
17+ interval time.Duration
18+
19+ mu sync.Mutex
20+ timer *time.Timer
21+ dirty bool
22+ ctx context.Context
23+ cancel context.CancelFunc
24+}
25+
26+// NewService creates a new snapshot service.
27+func NewService(
28+ snapshotUC *usecase.SnapshotSessionUseCase,
29+ provider port.TabListProvider,
30+ intervalMs int,
31+) *Service {
32+ if intervalMs <= 0 {
33+ intervalMs = 5000 // Default 5 seconds
34+ }
35+ return &Service{
36+ snapshotUC: snapshotUC,
37+ provider: provider,
38+ interval: time.Duration(intervalMs) * time.Millisecond,
39+ }
40+}
41+
42+// Start begins watching for dirty state.
43+func (s *Service) Start(ctx context.Context) {
44+ s.mu.Lock()
45+ defer s.mu.Unlock()
46+
47+ s.ctx, s.cancel = context.WithCancel(ctx)
48+ logging.FromContext(ctx).Debug().Dur("interval", s.interval).Msg("snapshot service started")
49+}
50+
51+// Stop stops the service and saves final state.
52+func (s *Service) Stop(ctx context.Context) error {
53+ s.mu.Lock()
54+ if s.cancel != nil {
55+ s.cancel()
56+ }
57+ if s.timer != nil {
58+ s.timer.Stop()
59+ s.timer = nil
60+ }
61+ s.mu.Unlock()
62+
63+ // Final save on shutdown
64+ return s.SaveNow(ctx)
65+}
66+
67+// MarkDirty signals that state has changed.
68+// Debounces saves to avoid excessive DB writes.
69+func (s *Service) MarkDirty() {
70+ s.mu.Lock()
71+ defer s.mu.Unlock()
72+
73+ s.dirty = true
74+
75+ // Reset or create timer
76+ if s.timer != nil {
77+ s.timer.Stop()
78+ }
79+
80+ s.timer = time.AfterFunc(s.interval, func() {
81+ s.mu.Lock()
82+ ctx := s.ctx
83+ s.mu.Unlock()
84+
85+ if ctx == nil {
86+ return
87+ }
88+
89+ if err := s.saveSnapshot(ctx); err != nil {
90+ logging.FromContext(ctx).Error().Err(err).Msg("failed to save session snapshot")
91+ }
92+ })
93+}
94+
95+// SaveNow forces immediate save (for shutdown).
96+func (s *Service) SaveNow(ctx context.Context) error {
97+ s.mu.Lock()
98+ if s.timer != nil {
99+ s.timer.Stop()
100+ s.timer = nil
101+ }
102+ dirty := s.dirty
103+ s.mu.Unlock()
104+
105+ if !dirty {
106+ return nil
107+ }
108+
109+ return s.saveSnapshot(ctx)
110+}
111+
112+func (s *Service) saveSnapshot(ctx context.Context) error {
113+ s.mu.Lock()
114+ s.dirty = false
115+ s.mu.Unlock()
116+
117+ tabList := s.provider.GetTabList()
118+ sessionID := s.provider.GetSessionID()
119+
120+ if sessionID == "" {
121+ return nil
122+ }
123+
124+ return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{
125+ SessionID: sessionID,
126+ TabList: tabList,
127+ })
128+}
@@ -0,0 +1,128 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// Service handles debounced session state snapshots.
14+type Service struct {
15+ snapshotUC *usecase.SnapshotSessionUseCase
16+ provider port.TabListProvider
17+ interval time.Duration
18+
19+ mu sync.Mutex
20+ timer *time.Timer
21+ dirty bool
22+ ctx context.Context
23+ cancel context.CancelFunc
24+}
25+
26+// NewService creates a new snapshot service.
27+func NewService(
28+ snapshotUC *usecase.SnapshotSessionUseCase,
29+ provider port.TabListProvider,
30+ intervalMs int,
31+) *Service {
32+ if intervalMs <= 0 {
33+ intervalMs = 5000 // Default 5 seconds
34+ }
35+ return &Service{
36+ snapshotUC: snapshotUC,
37+ provider: provider,
38+ interval: time.Duration(intervalMs) * time.Millisecond,
39+ }
40+}
41+
42+// Start begins watching for dirty state.
43+func (s *Service) Start(ctx context.Context) {
44+ s.mu.Lock()
45+ defer s.mu.Unlock()
46+
47+ s.ctx, s.cancel = context.WithCancel(ctx)
48+ logging.FromContext(ctx).Debug().Dur("interval", s.interval).Msg("snapshot service started")
49+}
50+
51+// Stop stops the service and saves final state.
52+func (s *Service) Stop(ctx context.Context) error {
53+ s.mu.Lock()
54+ if s.cancel != nil {
55+ s.cancel()
56+ }
57+ if s.timer != nil {
58+ s.timer.Stop()
59+ s.timer = nil
60+ }
61+ s.mu.Unlock()
62+
63+ // Final save on shutdown
64+ return s.SaveNow(ctx)
65+}
66+
67+// MarkDirty signals that state has changed.
68+// Debounces saves to avoid excessive DB writes.
69+func (s *Service) MarkDirty() {
70+ s.mu.Lock()
71+ defer s.mu.Unlock()
72+
73+ s.dirty = true
74+
75+ // Reset or create timer
76+ if s.timer != nil {
77+ s.timer.Stop()
78+ }
79+
80+ s.timer = time.AfterFunc(s.interval, func() {
81+ s.mu.Lock()
82+ ctx := s.ctx
83+ s.mu.Unlock()
84+
85+ if ctx == nil {
86+ return
87+ }
88+
89+ if err := s.saveSnapshot(ctx); err != nil {
90+ logging.FromContext(ctx).Error().Err(err).Msg("failed to save session snapshot")
91+ }
92+ })
93+}
94+
95+// SaveNow forces immediate save (for shutdown).
96+func (s *Service) SaveNow(ctx context.Context) error {
97+ s.mu.Lock()
98+ if s.timer != nil {
99+ s.timer.Stop()
100+ s.timer = nil
101+ }
102+ dirty := s.dirty
103+ s.mu.Unlock()
104+
105+ if !dirty {
106+ return nil
107+ }
108+
109+ return s.saveSnapshot(ctx)
110+}
111+
112+func (s *Service) saveSnapshot(ctx context.Context) error {
113+ s.mu.Lock()
114+ s.dirty = false
115+ s.mu.Unlock()
116+
117+ tabList := s.provider.GetTabList()
118+ sessionID := s.provider.GetSessionID()
119+
120+ if sessionID == "" {
121+ return nil
122+ }
123+
124+ return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{
125+ SessionID: sessionID,
126+ TabList: tabList,
127+ })
128+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close