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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "os" | |
| 6 | - | "path/filepath" | |
| 7 | 5 | "time" | |
| 8 | 6 | ||
| 9 | 7 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -98,13 +96,7 @@ func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID ent | |||
| 98 | 96 | ||
| 99 | 97 | // hasActiveLock checks if a session has an active lock file. | |
| 100 | 98 | 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) | |
| 108 | 100 | } | |
| 109 | 101 | ||
| 110 | 102 | // 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 { | |||
| 16 | 16 | GetActive(ctx context.Context) (*entity.Session, error) | |
| 17 | 17 | GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) | |
| 18 | 18 | 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 | } | |
| @@ -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) error | 18 | 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 ?; | |||
| 20 | 20 | UPDATE sessions | |
| 21 | 21 | SET ended_at = ? | |
| 22 | 22 | WHERE id = ?; | |
| 23 | + | ||
| 24 | + | -- name: DeleteSession :exec | |
| 25 | + | DELETE FROM sessions WHERE id = ?; | |
| @@ -20,3 +20,6 @@ LIMIT ?; | |||||
| 20 | UPDATE sessions | 20 | 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 | |||
| 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 | 96 | func sessionFromRow(row sqlc.Session) *entity.Session { | |
| 91 | 97 | var endedAt *time.Time | |
| 92 | 98 | 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.Time | 97 | 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 { | |||
| 21 | 21 | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error | |
| 22 | 22 | DeleteHistoryByID(ctx context.Context, id int64) error | |
| 23 | 23 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | |
| 24 | + | DeleteSession(ctx context.Context, id string) error | |
| 24 | 25 | DeleteSessionState(ctx context.Context, sessionID string) error | |
| 25 | 26 | DeleteTag(ctx context.Context, id int64) error | |
| 26 | 27 | DeleteZoomLevel(ctx context.Context, domain string) error | |
| @@ -21,6 +21,7 @@ type Querier interface { | |||||
| 21 | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error | 21 | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error | ||
| 22 | DeleteHistoryByID(ctx context.Context, id int64) error | 22 | DeleteHistoryByID(ctx context.Context, id int64) error | ||
| 23 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | 23 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | ||
| 24 | + | DeleteSession(ctx context.Context, id string) error | |||
| 24 | DeleteSessionState(ctx context.Context, sessionID string) error | 25 | DeleteSessionState(ctx context.Context, sessionID string) error | ||
| 25 | DeleteTag(ctx context.Context, id int64) error | 26 | DeleteTag(ctx context.Context, id int64) error | ||
| 26 | DeleteZoomLevel(ctx context.Context, domain string) error | 27 | DeleteZoomLevel(ctx context.Context, domain string) error | ||
M internal/infrastructure/persistence/sqlite/sqlc/sessions.sql.go +9 -0
| @@ -11,6 +11,15 @@ import ( | |||
| 11 | 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 | 23 | const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one | |
| 15 | 24 | SELECT id, type, started_at, ended_at FROM sessions | |
| 16 | 25 | 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 :one | 23 | const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one | ||
| 15 | SELECT id, type, started_at, ended_at FROM sessions | 24 | SELECT id, type, started_at, ended_at FROM sessions | ||
| 16 | WHERE type = 'browser' AND ended_at IS NULL | 25 | WHERE type = 'browser' AND ended_at IS NULL | ||