detached 9f01528

feat(usecase): implement session deletion logic and shared lock helper

commit
9f01528cb379f245f12479b07964bda131a224ab
author
brice · · 2025-12-25 09:42 UTC
parent
2beb81f
signature
unsigned
8 files · +123 -9
A internal/application/usecase/delete_session.go +79 -0
@@ -0,0 +1,79 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// DeleteSessionUseCase errors.
13+var (
14+ ErrCannotDeleteCurrentSession = errors.New("cannot delete current session")
15+ ErrCannotDeleteActiveSession = errors.New("cannot delete active session")
16+)
17+
18+// DeleteSessionUseCase handles session deletion with proper validation.
19+type DeleteSessionUseCase struct {
20+ stateRepo repository.SessionStateRepository
21+ sessionRepo repository.SessionRepository
22+ lockDir string
23+}
24+
25+// NewDeleteSessionUseCase creates a new DeleteSessionUseCase.
26+func NewDeleteSessionUseCase(
27+ stateRepo repository.SessionStateRepository,
28+ sessionRepo repository.SessionRepository,
29+ lockDir string,
30+) *DeleteSessionUseCase {
31+ return &DeleteSessionUseCase{
32+ stateRepo: stateRepo,
33+ sessionRepo: sessionRepo,
34+ lockDir: lockDir,
35+ }
36+}
37+
38+// DeleteSessionInput contains the parameters for session deletion.
39+type DeleteSessionInput struct {
40+ SessionID entity.SessionID
41+ CurrentSessionID entity.SessionID
42+}
43+
44+// Execute deletes a session and its state snapshot.
45+// It validates that the session is not current or active before deletion.
46+func (uc *DeleteSessionUseCase) Execute(ctx context.Context, input DeleteSessionInput) error {
47+ log := logging.FromContext(ctx)
48+
49+ // Validate not deleting current session
50+ if input.SessionID == input.CurrentSessionID && input.CurrentSessionID != "" {
51+ return ErrCannotDeleteCurrentSession
52+ }
53+
54+ // Check session exists
55+ session, err := uc.sessionRepo.FindByID(ctx, input.SessionID)
56+ if err != nil {
57+ return err
58+ }
59+ if session == nil {
60+ return ErrSessionNotFound
61+ }
62+
63+ // Check not active (EndedAt is nil OR has lock file)
64+ if session.IsActive() || isSessionLocked(uc.lockDir, input.SessionID) {
65+ return ErrCannotDeleteActiveSession
66+ }
67+
68+ log.Info().Str("session_id", string(input.SessionID)).Msg("deleting session")
69+
70+ // Delete snapshot (ignore "not found" - session may not have a snapshot)
71+ if uc.stateRepo != nil {
72+ if err := uc.stateRepo.DeleteSnapshot(ctx, input.SessionID); err != nil {
73+ log.Debug().Err(err).Msg("failed to delete session snapshot (may not exist)")
74+ }
75+ }
76+
77+ // Delete session record
78+ return uc.sessionRepo.Delete(ctx, input.SessionID)
79+}
@@ -0,0 +1,79 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// DeleteSessionUseCase errors.
13+var (
14+ ErrCannotDeleteCurrentSession = errors.New("cannot delete current session")
15+ ErrCannotDeleteActiveSession = errors.New("cannot delete active session")
16+)
17+
18+// DeleteSessionUseCase handles session deletion with proper validation.
19+type DeleteSessionUseCase struct {
20+ stateRepo repository.SessionStateRepository
21+ sessionRepo repository.SessionRepository
22+ lockDir string
23+}
24+
25+// NewDeleteSessionUseCase creates a new DeleteSessionUseCase.
26+func NewDeleteSessionUseCase(
27+ stateRepo repository.SessionStateRepository,
28+ sessionRepo repository.SessionRepository,
29+ lockDir string,
30+) *DeleteSessionUseCase {
31+ return &DeleteSessionUseCase{
32+ stateRepo: stateRepo,
33+ sessionRepo: sessionRepo,
34+ lockDir: lockDir,
35+ }
36+}
37+
38+// DeleteSessionInput contains the parameters for session deletion.
39+type DeleteSessionInput struct {
40+ SessionID entity.SessionID
41+ CurrentSessionID entity.SessionID
42+}
43+
44+// Execute deletes a session and its state snapshot.
45+// It validates that the session is not current or active before deletion.
46+func (uc *DeleteSessionUseCase) Execute(ctx context.Context, input DeleteSessionInput) error {
47+ log := logging.FromContext(ctx)
48+
49+ // Validate not deleting current session
50+ if input.SessionID == input.CurrentSessionID && input.CurrentSessionID != "" {
51+ return ErrCannotDeleteCurrentSession
52+ }
53+
54+ // Check session exists
55+ session, err := uc.sessionRepo.FindByID(ctx, input.SessionID)
56+ if err != nil {
57+ return err
58+ }
59+ if session == nil {
60+ return ErrSessionNotFound
61+ }
62+
63+ // Check not active (EndedAt is nil OR has lock file)
64+ if session.IsActive() || isSessionLocked(uc.lockDir, input.SessionID) {
65+ return ErrCannotDeleteActiveSession
66+ }
67+
68+ log.Info().Str("session_id", string(input.SessionID)).Msg("deleting session")
69+
70+ // Delete snapshot (ignore "not found" - session may not have a snapshot)
71+ if uc.stateRepo != nil {
72+ if err := uc.stateRepo.DeleteSnapshot(ctx, input.SessionID); err != nil {
73+ log.Debug().Err(err).Msg("failed to delete session snapshot (may not exist)")
74+ }
75+ }
76+
77+ // Delete session record
78+ return uc.sessionRepo.Delete(ctx, input.SessionID)
79+}
M internal/application/usecase/list_sessions.go +1 -9
@@ -2,8 +2,6 @@ package usecase
22
33 import (
44 "context"
5- "os"
6- "path/filepath"
75 "time"
86
97 "github.com/bnema/dumber/internal/domain/entity"
@@ -98,13 +96,7 @@ func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID ent
9896
9997 // hasActiveLock checks if a session has an active lock file.
10098 func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool {
101- if uc.lockDir == "" {
102- return false
103- }
104-
105- lockPath := filepath.Join(uc.lockDir, string(sessionID)+".lock")
106- _, err := os.Stat(lockPath)
107- return err == nil
99+ return isSessionLocked(uc.lockDir, sessionID)
108100 }
109101
110102 // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.
@@ -2,8 +2,6 @@ package usecase
2 2
3 import (3 import (
4 "context"4 "context"
5- "os"
6- "path/filepath"
7 "time"5 "time"
8 6
9 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
@@ -98,13 +96,7 @@ func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID ent
98 96
99 // hasActiveLock checks if a session has an active lock file.97 // hasActiveLock checks if a session has an active lock file.
100 func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool {98 func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool {
101- if uc.lockDir == "" {99+ return isSessionLocked(uc.lockDir, sessionID)
102- return false
103- }
104-
105- lockPath := filepath.Join(uc.lockDir, string(sessionID)+".lock")
106- _, err := os.Stat(lockPath)
107- return err == nil
108 }100 }
109 101
110 // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.102 // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.
A internal/application/usecase/session_lock.go +21 -0
@@ -0,0 +1,21 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+// isSessionLocked checks if a session has an active lock file.
12+// Lock files are created in lockDir with format: session_<sessionID>.lock
13+// This matches the format used by bootstrap.sessionLockPath().
14+func isSessionLocked(lockDir string, sessionID entity.SessionID) bool {
15+ if lockDir == "" {
16+ return false
17+ }
18+ lockPath := filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
19+ _, err := os.Stat(lockPath)
20+ return err == nil
21+}
@@ -0,0 +1,21 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+// isSessionLocked checks if a session has an active lock file.
12+// Lock files are created in lockDir with format: session_<sessionID>.lock
13+// This matches the format used by bootstrap.sessionLockPath().
14+func isSessionLocked(lockDir string, sessionID entity.SessionID) bool {
15+ if lockDir == "" {
16+ return false
17+ }
18+ lockPath := filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
19+ _, err := os.Stat(lockPath)
20+ return err == nil
21+}
M internal/domain/repository/session.go +3 -0
@@ -16,4 +16,7 @@ type SessionRepository interface {
1616 GetActive(ctx context.Context) (*entity.Session, error)
1717 GetRecent(ctx context.Context, limit int) ([]*entity.Session, error)
1818 MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error
19+
20+ // Delete removes a session record.
21+ Delete(ctx context.Context, id entity.SessionID) error
1922 }
@@ -16,4 +16,7 @@ type SessionRepository interface {
16 GetActive(ctx context.Context) (*entity.Session, error)16 GetActive(ctx context.Context) (*entity.Session, error)
17 GetRecent(ctx context.Context, limit int) ([]*entity.Session, error)17 GetRecent(ctx context.Context, limit int) ([]*entity.Session, error)
18 MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error18 MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error
19+
20+ // Delete removes a session record.
21+ Delete(ctx context.Context, id entity.SessionID) error
19 }22 }
M internal/infrastructure/persistence/sqlite/queries/sessions.sql +3 -0
@@ -20,3 +20,6 @@ LIMIT ?;
2020 UPDATE sessions
2121 SET ended_at = ?
2222 WHERE id = ?;
23+
24+-- name: DeleteSession :exec
25+DELETE FROM sessions WHERE id = ?;
@@ -20,3 +20,6 @@ LIMIT ?;
20 UPDATE sessions20 UPDATE sessions
21 SET ended_at = ?21 SET ended_at = ?
22 WHERE id = ?;22 WHERE id = ?;
23+
24+-- name: DeleteSession :exec
25+DELETE FROM sessions WHERE id = ?;
M internal/infrastructure/persistence/sqlite/session_repo.go +6 -0
@@ -87,6 +87,12 @@ func (r *sessionRepo) MarkEnded(ctx context.Context, id entity.SessionID, endedA
8787 })
8888 }
8989
90+func (r *sessionRepo) Delete(ctx context.Context, id entity.SessionID) error {
91+ log := logging.FromContext(ctx)
92+ log.Debug().Str("session_id", string(id)).Msg("deleting session")
93+ return r.queries.DeleteSession(ctx, string(id))
94+}
95+
9096 func sessionFromRow(row sqlc.Session) *entity.Session {
9197 var endedAt *time.Time
9298 if row.EndedAt.Valid {
@@ -87,6 +87,12 @@ func (r *sessionRepo) MarkEnded(ctx context.Context, id entity.SessionID, endedA
87 })87 })
88 }88 }
89 89
90+func (r *sessionRepo) Delete(ctx context.Context, id entity.SessionID) error {
91+ log := logging.FromContext(ctx)
92+ log.Debug().Str("session_id", string(id)).Msg("deleting session")
93+ return r.queries.DeleteSession(ctx, string(id))
94+}
95+
90 func sessionFromRow(row sqlc.Session) *entity.Session {96 func sessionFromRow(row sqlc.Session) *entity.Session {
91 var endedAt *time.Time97 var endedAt *time.Time
92 if row.EndedAt.Valid {98 if row.EndedAt.Valid {
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +1 -0
@@ -21,6 +21,7 @@ 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+ DeleteSession(ctx context.Context, id string) error
2425 DeleteSessionState(ctx context.Context, sessionID string) error
2526 DeleteTag(ctx context.Context, id int64) error
2627 DeleteZoomLevel(ctx context.Context, domain string) error
@@ -21,6 +21,7 @@ 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+ DeleteSession(ctx context.Context, id string) error
24 DeleteSessionState(ctx context.Context, sessionID string) error25 DeleteSessionState(ctx context.Context, sessionID string) error
25 DeleteTag(ctx context.Context, id int64) error26 DeleteTag(ctx context.Context, id int64) error
26 DeleteZoomLevel(ctx context.Context, domain string) error27 DeleteZoomLevel(ctx context.Context, domain string) error
M internal/infrastructure/persistence/sqlite/sqlc/sessions.sql.go +9 -0
@@ -11,6 +11,15 @@ import (
1111 "time"
1212 )
1313
14+const DeleteSession = `-- name: DeleteSession :exec
15+DELETE FROM sessions WHERE id = ?
16+`
17+
18+func (q *Queries) DeleteSession(ctx context.Context, id string) error {
19+ _, err := q.db.ExecContext(ctx, DeleteSession, id)
20+ return err
21+}
22+
1423 const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one
1524 SELECT id, type, started_at, ended_at FROM sessions
1625 WHERE type = 'browser' AND ended_at IS NULL
@@ -11,6 +11,15 @@ import (
11 "time"11 "time"
12 )12 )
13 13
14+const DeleteSession = `-- name: DeleteSession :exec
15+DELETE FROM sessions WHERE id = ?
16+`
17+
18+func (q *Queries) DeleteSession(ctx context.Context, id string) error {
19+ _, err := q.db.ExecContext(ctx, DeleteSession, id)
20+ return err
21+}
22+
14 const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one23 const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one
15 SELECT id, type, started_at, ended_at FROM sessions24 SELECT id, type, started_at, ended_at FROM sessions
16 WHERE type = 'browser' AND ended_at IS NULL25 WHERE type = 'browser' AND ended_at IS NULL

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