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 { | |||
| 70 | 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 | 82 | type ZoomLevel struct { | |
| 74 | 83 | Domain string `json:"domain"` | |
| 75 | 84 | 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 { | |||
| 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 | + | DeleteSessionState(ctx context.Context, sessionID string) error | |
| 24 | 25 | DeleteTag(ctx context.Context, id int64) error | |
| 25 | 26 | DeleteZoomLevel(ctx context.Context, domain string) error | |
| 26 | 27 | GetActiveBrowserSession(ctx context.Context) (Session, error) | |
| 27 | 28 | GetAllFavorites(ctx context.Context) ([]Favorite, error) | |
| 28 | 29 | GetAllFolders(ctx context.Context) ([]FavoriteFolder, error) | |
| 30 | + | GetAllSessionStates(ctx context.Context) ([]SessionState, error) | |
| 29 | 31 | GetAllTags(ctx context.Context) ([]FavoriteTag, error) | |
| 30 | 32 | GetAllWhitelistedDomains(ctx context.Context) ([]string, error) | |
| 31 | 33 | GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error) | |
| @@ -44,6 +46,8 @@ type Querier interface { | |||
| 44 | 46 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | |
| 45 | 47 | GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) | |
| 46 | 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 | 51 | GetTagByID(ctx context.Context, id int64) (FavoriteTag, error) | |
| 48 | 52 | GetTagByName(ctx context.Context, name string) (FavoriteTag, error) | |
| 49 | 53 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | |
| @@ -66,6 +70,7 @@ type Querier interface { | |||
| 66 | 70 | UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error | |
| 67 | 71 | UpdateTag(ctx context.Context, arg UpdateTagParams) error | |
| 68 | 72 | UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error | |
| 73 | + | UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error | |
| 69 | 74 | } | |
| 70 | 75 | ||
| 71 | 76 | var _ Querier = (*Queries)(nil) | |
| @@ -21,11 +21,13 @@ 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 | + | DeleteSessionState(ctx context.Context, sessionID string) error | |||
| 24 | DeleteTag(ctx context.Context, id int64) error | 25 | DeleteTag(ctx context.Context, id int64) error | ||
| 25 | DeleteZoomLevel(ctx context.Context, domain string) error | 26 | 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) error | 70 | UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error | ||
| 67 | UpdateTag(ctx context.Context, arg UpdateTagParams) error | 71 | UpdateTag(ctx context.Context, arg UpdateTagParams) error | ||
| 68 | UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error | 72 | 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 | + | } | |||