detached cbc3a47

refactor(omnibox): scope history suggestions by age (#436)

* refactor(omnibox): scope history suggestions by age

* perf(omnibox): preserve bounded history query plans

* test(history): harden bounded query benchmarks
commit
cbc3a47f42cf3a3af3cedb7b59e3afc239523c9a
author
bnema · · 2026-09-14 20:11 UTC
committer
GitHub · · 2026-09-14 20:11 UTC
parent
acc7e1e
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
29 files · +1599 -228
M CHANGELOG.md +1 -0
@@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file.
1717 - **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration.
1818 - **Page mode configuration**: Page Mode is now called Vim Mode and uses the `workspace.vim_mode` configuration namespace for activation, timeouts, and action sequences. Existing page-mode bindings are migrated by the configuration loader.
1919 - **Browsing-context routing**: External links, floating panes, popups, and standalone omnibox launches now use explicit browsing-context ownership so they open in the appropriate pane or browser window.
20+- **Omnibox history window**: Replaced `omnibox.most_visited_days` with `omnibox.max_history_days`. The window now applies to typed omnibox searches and to both empty-input lists (`recent` and `most_visited`), filtered in SQL before result limits. `dumber config migrate` renames the legacy key while preserving its value (including `0`).
2021 - **CEF presentation and lifecycle**: Browser visibility, first-frame presentation, accessibility enablement, browser recreation, and PipeWire audio handoff now follow stricter lifecycle ownership and synchronization.
2122 - **Dependencies and packaging**: Updated the CEF/GTK bridge and PureGo dependencies, refreshed vendored build inputs, and synchronized CI, Flatpak, AUR, and release workflow metadata.
2223
@@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file.
17 - **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration.17 - **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration.
18 - **Page mode configuration**: Page Mode is now called Vim Mode and uses the `workspace.vim_mode` configuration namespace for activation, timeouts, and action sequences. Existing page-mode bindings are migrated by the configuration loader.18 - **Page mode configuration**: Page Mode is now called Vim Mode and uses the `workspace.vim_mode` configuration namespace for activation, timeouts, and action sequences. Existing page-mode bindings are migrated by the configuration loader.
19 - **Browsing-context routing**: External links, floating panes, popups, and standalone omnibox launches now use explicit browsing-context ownership so they open in the appropriate pane or browser window.19 - **Browsing-context routing**: External links, floating panes, popups, and standalone omnibox launches now use explicit browsing-context ownership so they open in the appropriate pane or browser window.
20+- **Omnibox history window**: Replaced `omnibox.most_visited_days` with `omnibox.max_history_days`. The window now applies to typed omnibox searches and to both empty-input lists (`recent` and `most_visited`), filtered in SQL before result limits. `dumber config migrate` renames the legacy key while preserving its value (including `0`).
20 - **CEF presentation and lifecycle**: Browser visibility, first-frame presentation, accessibility enablement, browser recreation, and PipeWire audio handoff now follow stricter lifecycle ownership and synchronization.21 - **CEF presentation and lifecycle**: Browser visibility, first-frame presentation, accessibility enablement, browser recreation, and PipeWire audio handoff now follow stricter lifecycle ownership and synchronization.
21 - **Dependencies and packaging**: Updated the CEF/GTK bridge and PureGo dependencies, refreshed vendored build inputs, and synchronized CI, Flatpak, AUR, and release workflow metadata.22 - **Dependencies and packaging**: Updated the CEF/GTK bridge and PureGo dependencies, refreshed vendored build inputs, and synchronized CI, Flatpak, AUR, and release workflow metadata.
22 23
M docs/config/index.md +6 -2
@@ -105,7 +105,7 @@ dumber dmenu --days 14 --most-visited
105105 |-----|------|---------|--------------|-------------|
106106 | `omnibox.style` | string | `"command"` | `multiplexer`, `command`, `minimal` | Omnibox visual treatment |
107107 | `omnibox.initial_behavior` | string | `"recent"` | `recent`, `most_visited`, `none` | Initial history display behavior; press `Ctrl+R` in the omnibox to switch |
108-| `omnibox.most_visited_days` | int | `30` | `>= 0` | Days of history to consider when `initial_behavior = "most_visited"` (`0` = all history) |
108+| `omnibox.max_history_days` | int | `30` | `>= 0` | Days of history used by omnibox suggestions, for both typed searches and the empty-input list (`0` = all history) |
109109 | `omnibox.auto_open_on_new_pane` | bool | `false` | - | Automatically open the omnibox after creating a new pane |
110110
111111 Changing `omnibox.style` takes effect after restarting Dumber.
@@ -115,7 +115,7 @@ Changing `omnibox.style` takes effect after restarting Dumber.
115115 [omnibox]
116116 style = "command" # Soft terminal-inspired command palette
117117 initial_behavior = "recent" # Show recent history when omnibox opens
118-most_visited_days = 30 # Days of history used for most_visited
118+max_history_days = 30 # History window for omnibox searches and initial list
119119 auto_open_on_new_pane = false
120120
121121 # Alternative visual treatments:
@@ -127,6 +127,10 @@ auto_open_on_new_pane = false
127127 # initial_behavior = "none" # Show no initial suggestions
128128 ```
129129
130+> **Note:** `omnibox.max_history_days` is non-destructive: it never deletes history, it only limits which entries omnibox suggestions consider. `most_visited` ranks by lifetime visit counts among URLs whose `last_visited` falls inside the window.
131+>
132+> This key replaced `omnibox.most_visited_days`. Run `dumber config migrate` to rename the legacy key while preserving its value. The derived environment variable is `DUMBER_OMNIBOX_MAX_HISTORY_DAYS`; the old `DUMBER_OMNIBOX_MOST_VISITED_DAYS` is no longer read.
133+
130134 ## Logging
131135
132136 | Key | Type | Default | Valid Values | Description |
@@ -105,7 +105,7 @@ dumber dmenu --days 14 --most-visited
105 |-----|------|---------|--------------|-------------|105 |-----|------|---------|--------------|-------------|
106 | `omnibox.style` | string | `"command"` | `multiplexer`, `command`, `minimal` | Omnibox visual treatment |106 | `omnibox.style` | string | `"command"` | `multiplexer`, `command`, `minimal` | Omnibox visual treatment |
107 | `omnibox.initial_behavior` | string | `"recent"` | `recent`, `most_visited`, `none` | Initial history display behavior; press `Ctrl+R` in the omnibox to switch |107 | `omnibox.initial_behavior` | string | `"recent"` | `recent`, `most_visited`, `none` | Initial history display behavior; press `Ctrl+R` in the omnibox to switch |
108-| `omnibox.most_visited_days` | int | `30` | `>= 0` | Days of history to consider when `initial_behavior = "most_visited"` (`0` = all history) |108+| `omnibox.max_history_days` | int | `30` | `>= 0` | Days of history used by omnibox suggestions, for both typed searches and the empty-input list (`0` = all history) |
109 | `omnibox.auto_open_on_new_pane` | bool | `false` | - | Automatically open the omnibox after creating a new pane |109 | `omnibox.auto_open_on_new_pane` | bool | `false` | - | Automatically open the omnibox after creating a new pane |
110 110
111 Changing `omnibox.style` takes effect after restarting Dumber.111 Changing `omnibox.style` takes effect after restarting Dumber.
@@ -115,7 +115,7 @@ Changing `omnibox.style` takes effect after restarting Dumber.
115 [omnibox]115 [omnibox]
116 style = "command" # Soft terminal-inspired command palette116 style = "command" # Soft terminal-inspired command palette
117 initial_behavior = "recent" # Show recent history when omnibox opens117 initial_behavior = "recent" # Show recent history when omnibox opens
118-most_visited_days = 30 # Days of history used for most_visited118+max_history_days = 30 # History window for omnibox searches and initial list
119 auto_open_on_new_pane = false119 auto_open_on_new_pane = false
120 120
121 # Alternative visual treatments:121 # Alternative visual treatments:
@@ -127,6 +127,10 @@ auto_open_on_new_pane = false
127 # initial_behavior = "none" # Show no initial suggestions127 # initial_behavior = "none" # Show no initial suggestions
128 ```128 ```
129 129
130+> **Note:** `omnibox.max_history_days` is non-destructive: it never deletes history, it only limits which entries omnibox suggestions consider. `most_visited` ranks by lifetime visit counts among URLs whose `last_visited` falls inside the window.
131+>
132+> This key replaced `omnibox.most_visited_days`. Run `dumber config migrate` to rename the legacy key while preserving its value. The derived environment variable is `DUMBER_OMNIBOX_MAX_HISTORY_DAYS`; the old `DUMBER_OMNIBOX_MOST_VISITED_DAYS` is no longer read.
133+
130 ## Logging134 ## Logging
131 135
132 | Key | Type | Default | Valid Values | Description |136 | Key | Type | Default | Valid Values | Description |
M docs/reference/configuration.md +1 -1
@@ -21,7 +21,7 @@
2121 | `dmenu.sort_by_visit_count` | bool | `true` | |
2222 | `omnibox.style` | string | `command` | `multiplexer`, `command`, `minimal` |
2323 | `omnibox.initial_behavior` | string | `recent` | `recent`, `most_visited`, `none` |
24-| `omnibox.most_visited_days` | int | `30` | `>= 0` |
24+| `omnibox.max_history_days` | int | `30` | `>= 0` |
2525 | `omnibox.auto_open_on_new_pane` | bool | `false` | |
2626 | `logging.level` | string | `info` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` |
2727 | `logging.format` | string | `text` | `text`, `json`, `console` |
@@ -21,7 +21,7 @@
21 | `dmenu.sort_by_visit_count` | bool | `true` | |21 | `dmenu.sort_by_visit_count` | bool | `true` | |
22 | `omnibox.style` | string | `command` | `multiplexer`, `command`, `minimal` |22 | `omnibox.style` | string | `command` | `multiplexer`, `command`, `minimal` |
23 | `omnibox.initial_behavior` | string | `recent` | `recent`, `most_visited`, `none` |23 | `omnibox.initial_behavior` | string | `recent` | `recent`, `most_visited`, `none` |
24-| `omnibox.most_visited_days` | int | `30` | `>= 0` |24+| `omnibox.max_history_days` | int | `30` | `>= 0` |
25 | `omnibox.auto_open_on_new_pane` | bool | `false` | |25 | `omnibox.auto_open_on_new_pane` | bool | `false` | |
26 | `logging.level` | string | `info` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` |26 | `logging.level` | string | `info` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` |
27 | `logging.format` | string | `text` | `text`, `json`, `console` |27 | `logging.format` | string | `text` | `text`, `json`, `console` |
M internal/application/dto/history.go +3 -0
@@ -6,6 +6,9 @@ import "github.com/bnema/dumber/internal/domain/entity"
66 type HistorySearchInput struct {
77 Query string
88 Limit int
9+ // MaxAgeDays restricts results to entries last visited within the last N
10+ // days. Zero or negative means no age restriction (all stored history).
11+ MaxAgeDays int
912 }
1013
1114 // HistorySearchOutput holds search results for history search use cases.
@@ -6,6 +6,9 @@ import "github.com/bnema/dumber/internal/domain/entity"
6 type HistorySearchInput struct {6 type HistorySearchInput struct {
7 Query string7 Query string
8 Limit int8 Limit int
9+ // MaxAgeDays restricts results to entries last visited within the last N
10+ // days. Zero or negative means no age restriction (all stored history).
11+ MaxAgeDays int
9 }12 }
10 13
11 // HistorySearchOutput holds search results for history search use cases.14 // HistorySearchOutput holds search results for history search use cases.
M internal/application/usecase/search_history.go +50 -3
@@ -96,8 +96,8 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (
9696
9797 limit := clampPositiveLimit(input.Limit, defaultHistorySearchLimit, maxHistorySearchLimit)
9898
99- // Use repository's FTS5 search
100- matches, err := uc.historyRepo.Search(ctx, input.Query, limit)
99+ // Use repository's FTS5 search with the optional age scope applied in SQL.
100+ matches, err := uc.historyRepo.Search(ctx, input.Query, limit, historyScopeFromDays(input.MaxAgeDays, time.Now()))
101101 if err != nil {
102102 return nil, fmt.Errorf("failed to search history: %w", err)
103103 }
@@ -116,7 +116,7 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (
116116 func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
117117 limit = clampOptionalLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit)
118118
119- entries, err := uc.historyRepo.GetRecent(ctx, limit, offset)
119+ entries, err := uc.historyRepo.GetRecent(ctx, limit, offset, repository.HistoryScope{})
120120 if err != nil {
121121 return nil, fmt.Errorf("failed to get recent history: %w", err)
122122 }
@@ -124,6 +124,53 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
124124 return entries, nil
125125 }
126126
127+// GetRecentWithin retrieves up to limit recent history entries visited within
128+// the last maxAgeDays days. Zero or negative maxAgeDays means all stored
129+// history; a non-positive limit returns no entries. The age filter and limit
130+// are applied in SQL.
131+func (uc *SearchHistoryUseCase) GetRecentWithin(ctx context.Context, limit, maxAgeDays int) ([]*entity.HistoryEntry, error) {
132+ if limit <= 0 {
133+ return []*entity.HistoryEntry{}, nil
134+ }
135+ limit = clampPositiveLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit)
136+
137+ entries, err := uc.historyRepo.GetRecent(ctx, limit, 0, historyScopeFromDays(maxAgeDays, time.Now()))
138+ if err != nil {
139+ return nil, fmt.Errorf("failed to get recent history: %w", err)
140+ }
141+
142+ return entries, nil
143+}
144+
145+// GetMostVisitedWithin retrieves up to limit most-visited history entries
146+// visited within the last maxAgeDays days. Zero or negative maxAgeDays means
147+// all stored history; a non-positive limit returns no entries. The age filter
148+// and limit are applied in SQL.
149+func (uc *SearchHistoryUseCase) GetMostVisitedWithin(ctx context.Context, limit, maxAgeDays int) ([]*entity.HistoryEntry, error) {
150+ if limit <= 0 {
151+ return []*entity.HistoryEntry{}, nil
152+ }
153+ limit = clampPositiveLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit)
154+
155+ entries, err := uc.historyRepo.GetMostVisitedWithin(ctx, limit, historyScopeFromDays(maxAgeDays, time.Now()))
156+ if err != nil {
157+ return nil, fmt.Errorf("failed to get most visited history: %w", err)
158+ }
159+
160+ return entries, nil
161+}
162+
163+// historyScopeFromDays converts an optional max age in days to an explicit
164+// history scope. The application boundary normalizes zero and negative values
165+// to "no age restriction"; config validation rejects negative
166+// omnibox.max_history_days before it reaches this layer.
167+func historyScopeFromDays(maxAgeDays int, now time.Time) repository.HistoryScope {
168+ if maxAgeDays <= 0 {
169+ return repository.HistoryScope{}
170+ }
171+ return repository.HistoryScope{Cutoff: now.UTC().AddDate(0, 0, -maxAgeDays)}
172+}
173+
127174 // GetRecentByDomain retrieves recent history entries for a canonical domain. A
128175 // zero limit means all matching entries; negative limits use the default page
129176 // size. Positive limits are capped to keep WebUI-originated queries bounded.
@@ -96,8 +96,8 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (
96 96
97 limit := clampPositiveLimit(input.Limit, defaultHistorySearchLimit, maxHistorySearchLimit)97 limit := clampPositiveLimit(input.Limit, defaultHistorySearchLimit, maxHistorySearchLimit)
98 98
99- // Use repository's FTS5 search99+ // Use repository's FTS5 search with the optional age scope applied in SQL.
100- matches, err := uc.historyRepo.Search(ctx, input.Query, limit)100+ matches, err := uc.historyRepo.Search(ctx, input.Query, limit, historyScopeFromDays(input.MaxAgeDays, time.Now()))
101 if err != nil {101 if err != nil {
102 return nil, fmt.Errorf("failed to search history: %w", err)102 return nil, fmt.Errorf("failed to search history: %w", err)
103 }103 }
@@ -116,7 +116,7 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (
116 func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {116 func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
117 limit = clampOptionalLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit)117 limit = clampOptionalLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit)
118 118
119- entries, err := uc.historyRepo.GetRecent(ctx, limit, offset)119+ entries, err := uc.historyRepo.GetRecent(ctx, limit, offset, repository.HistoryScope{})
120 if err != nil {120 if err != nil {
121 return nil, fmt.Errorf("failed to get recent history: %w", err)121 return nil, fmt.Errorf("failed to get recent history: %w", err)
122 }122 }
@@ -124,6 +124,53 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
124 return entries, nil124 return entries, nil
125 }125 }
126 126
127+// GetRecentWithin retrieves up to limit recent history entries visited within
128+// the last maxAgeDays days. Zero or negative maxAgeDays means all stored
129+// history; a non-positive limit returns no entries. The age filter and limit
130+// are applied in SQL.
131+func (uc *SearchHistoryUseCase) GetRecentWithin(ctx context.Context, limit, maxAgeDays int) ([]*entity.HistoryEntry, error) {
132+ if limit <= 0 {
133+ return []*entity.HistoryEntry{}, nil
134+ }
135+ limit = clampPositiveLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit)
136+
137+ entries, err := uc.historyRepo.GetRecent(ctx, limit, 0, historyScopeFromDays(maxAgeDays, time.Now()))
138+ if err != nil {
139+ return nil, fmt.Errorf("failed to get recent history: %w", err)
140+ }
141+
142+ return entries, nil
143+}
144+
145+// GetMostVisitedWithin retrieves up to limit most-visited history entries
146+// visited within the last maxAgeDays days. Zero or negative maxAgeDays means
147+// all stored history; a non-positive limit returns no entries. The age filter
148+// and limit are applied in SQL.
149+func (uc *SearchHistoryUseCase) GetMostVisitedWithin(ctx context.Context, limit, maxAgeDays int) ([]*entity.HistoryEntry, error) {
150+ if limit <= 0 {
151+ return []*entity.HistoryEntry{}, nil
152+ }
153+ limit = clampPositiveLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit)
154+
155+ entries, err := uc.historyRepo.GetMostVisitedWithin(ctx, limit, historyScopeFromDays(maxAgeDays, time.Now()))
156+ if err != nil {
157+ return nil, fmt.Errorf("failed to get most visited history: %w", err)
158+ }
159+
160+ return entries, nil
161+}
162+
163+// historyScopeFromDays converts an optional max age in days to an explicit
164+// history scope. The application boundary normalizes zero and negative values
165+// to "no age restriction"; config validation rejects negative
166+// omnibox.max_history_days before it reaches this layer.
167+func historyScopeFromDays(maxAgeDays int, now time.Time) repository.HistoryScope {
168+ if maxAgeDays <= 0 {
169+ return repository.HistoryScope{}
170+ }
171+ return repository.HistoryScope{Cutoff: now.UTC().AddDate(0, 0, -maxAgeDays)}
172+}
173+
127 // GetRecentByDomain retrieves recent history entries for a canonical domain. A174 // GetRecentByDomain retrieves recent history entries for a canonical domain. A
128 // zero limit means all matching entries; negative limits use the default page175 // zero limit means all matching entries; negative limits use the default page
129 // size. Positive limits are capped to keep WebUI-originated queries bounded.176 // size. Positive limits are capped to keep WebUI-originated queries bounded.
M internal/application/usecase/search_history_test.go +176 -4
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/application/dto"
1010 "github.com/bnema/dumber/internal/application/usecase"
1111 "github.com/bnema/dumber/internal/domain/entity"
12+ repository "github.com/bnema/dumber/internal/domain/repository"
1213 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
1314 "github.com/stretchr/testify/assert"
1415 "github.com/stretchr/testify/mock"
@@ -63,7 +64,7 @@ func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) {
6364 ctx := testContext()
6465 historyRepo := repomocks.NewMockHistoryRepository(t)
6566 entries := []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Example"}}
66- historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0).Return(entries, nil).Once()
67+ historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0, repository.HistoryScope{}).Return(entries, nil).Once()
6768
6869 uc := usecase.NewSearchHistoryUseCase(historyRepo)
6970 result, err := uc.GetRecent(ctx, 0, 0)
@@ -75,7 +76,7 @@ func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) {
7576 func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testing.T) {
7677 ctx := testContext()
7778 historyRepo := repomocks.NewMockHistoryRepository(t)
78- historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0).Return([]*entity.HistoryEntry{}, nil).Once()
79+ historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0, repository.HistoryScope{}).Return([]*entity.HistoryEntry{}, nil).Once()
7980
8081 uc := usecase.NewSearchHistoryUseCase(historyRepo)
8182 result, err := uc.GetRecent(ctx, -1, 0)
@@ -87,7 +88,7 @@ func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testi
8788 func TestSearchHistoryUseCase_GetRecent_ClampsOversizedLimit(t *testing.T) {
8889 ctx := testContext()
8990 historyRepo := repomocks.NewMockHistoryRepository(t)
90- historyRepo.EXPECT().GetRecent(mock.Anything, 500, 10).Return([]*entity.HistoryEntry{}, nil).Once()
91+ historyRepo.EXPECT().GetRecent(mock.Anything, 500, 10, repository.HistoryScope{}).Return([]*entity.HistoryEntry{}, nil).Once()
9192
9293 uc := usecase.NewSearchHistoryUseCase(historyRepo)
9394 result, err := uc.GetRecent(ctx, 10_000, 10)
@@ -99,7 +100,7 @@ func TestSearchHistoryUseCase_GetRecent_ClampsOversizedLimit(t *testing.T) {
99100 func TestSearchHistoryUseCase_Search_ClampsOversizedLimit(t *testing.T) {
100101 ctx := testContext()
101102 historyRepo := repomocks.NewMockHistoryRepository(t)
102- historyRepo.EXPECT().Search(mock.Anything, "dumber", 100).Return([]entity.HistoryMatch{}, nil).Once()
103+ historyRepo.EXPECT().Search(mock.Anything, "dumber", 100, repository.HistoryScope{}).Return([]entity.HistoryMatch{}, nil).Once()
103104
104105 uc := usecase.NewSearchHistoryUseCase(historyRepo)
105106 result, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 10_000})
@@ -109,6 +110,99 @@ func TestSearchHistoryUseCase_Search_ClampsOversizedLimit(t *testing.T) {
109110 assert.Empty(t, result.Matches)
110111 }
111112
113+func TestSearchHistoryUseCase_Search_AppliesOptionalAgeScope(t *testing.T) {
114+ ctx := testContext()
115+ historyRepo := repomocks.NewMockHistoryRepository(t)
116+ var gotScope repository.HistoryScope
117+ historyRepo.EXPECT().
118+ Search(mock.Anything, "dumber", 20, mock.Anything).
119+ Run(func(_ context.Context, _ string, _ int, scope repository.HistoryScope) { gotScope = scope }).
120+ Return([]entity.HistoryMatch{}, nil).
121+ Once()
122+
123+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
124+ _, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: 30})
125+
126+ require.NoError(t, err)
127+ assert.WithinDuration(t, time.Now().AddDate(0, 0, -30), gotScope.Cutoff, time.Minute)
128+}
129+
130+func TestSearchHistoryUseCase_Search_ZeroMaxAgeMeansAllHistory(t *testing.T) {
131+ ctx := testContext()
132+ historyRepo := repomocks.NewMockHistoryRepository(t)
133+ historyRepo.EXPECT().
134+ Search(mock.Anything, "dumber", 20, repository.HistoryScope{}).
135+ Return([]entity.HistoryMatch{}, nil).
136+ Once()
137+
138+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
139+ _, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: 0})
140+
141+ require.NoError(t, err)
142+}
143+
144+func TestSearchHistoryUseCase_GetRecentWithin_AppliesScopeAndLimit(t *testing.T) {
145+ ctx := testContext()
146+ historyRepo := repomocks.NewMockHistoryRepository(t)
147+ var gotScope repository.HistoryScope
148+ historyRepo.EXPECT().
149+ GetRecent(mock.Anything, 8, 0, mock.Anything).
150+ Run(func(_ context.Context, _ int, _ int, scope repository.HistoryScope) { gotScope = scope }).
151+ Return([]*entity.HistoryEntry{}, nil).
152+ Once()
153+
154+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
155+ _, err := uc.GetRecentWithin(ctx, 8, 14)
156+
157+ require.NoError(t, err)
158+ assert.WithinDuration(t, time.Now().AddDate(0, 0, -14), gotScope.Cutoff, time.Minute)
159+}
160+
161+func TestSearchHistoryUseCase_GetRecentWithin_ZeroMeansAllHistory(t *testing.T) {
162+ ctx := testContext()
163+ historyRepo := repomocks.NewMockHistoryRepository(t)
164+ historyRepo.EXPECT().
165+ GetRecent(mock.Anything, 8, 0, repository.HistoryScope{}).
166+ Return([]*entity.HistoryEntry{}, nil).
167+ Once()
168+
169+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
170+ _, err := uc.GetRecentWithin(ctx, 8, 0)
171+
172+ require.NoError(t, err)
173+}
174+
175+func TestSearchHistoryUseCase_GetMostVisitedWithin_AppliesScopeAndLimit(t *testing.T) {
176+ ctx := testContext()
177+ historyRepo := repomocks.NewMockHistoryRepository(t)
178+ var gotScope repository.HistoryScope
179+ historyRepo.EXPECT().
180+ GetMostVisitedWithin(mock.Anything, 8, mock.Anything).
181+ Run(func(_ context.Context, _ int, scope repository.HistoryScope) { gotScope = scope }).
182+ Return([]*entity.HistoryEntry{}, nil).
183+ Once()
184+
185+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
186+ _, err := uc.GetMostVisitedWithin(ctx, 8, 14)
187+
188+ require.NoError(t, err)
189+ assert.WithinDuration(t, time.Now().AddDate(0, 0, -14), gotScope.Cutoff, time.Minute)
190+}
191+
192+func TestSearchHistoryUseCase_GetMostVisitedWithin_ZeroMeansAllHistory(t *testing.T) {
193+ ctx := testContext()
194+ historyRepo := repomocks.NewMockHistoryRepository(t)
195+ historyRepo.EXPECT().
196+ GetMostVisitedWithin(mock.Anything, 8, repository.HistoryScope{}).
197+ Return([]*entity.HistoryEntry{}, nil).
198+ Once()
199+
200+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
201+ _, err := uc.GetMostVisitedWithin(ctx, 8, 0)
202+
203+ require.NoError(t, err)
204+}
205+
112206 func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationError(t *testing.T) {
113207 ctx := testContext()
114208 historyRepo := repomocks.NewMockHistoryRepository(t)
@@ -817,3 +911,81 @@ func TestSearchHistoryUseCase_DeleteByDomainAllowsUnderscoreDomains(t *testing.T
817911
818912 require.NoError(t, err)
819913 }
914+
915+func TestSearchHistoryUseCase_Search_NegativeMaxAgeMeansAllHistory(t *testing.T) {
916+ ctx := testContext()
917+ historyRepo := repomocks.NewMockHistoryRepository(t)
918+ historyRepo.EXPECT().
919+ Search(mock.Anything, "dumber", 20, repository.HistoryScope{}).
920+ Return([]entity.HistoryMatch{}, nil).
921+ Once()
922+
923+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
924+ _, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: -5})
925+
926+ require.NoError(t, err)
927+}
928+
929+func TestSearchHistoryUseCase_GetRecentWithin_NegativeMaxAgeMeansAllHistory(t *testing.T) {
930+ ctx := testContext()
931+ historyRepo := repomocks.NewMockHistoryRepository(t)
932+ historyRepo.EXPECT().
933+ GetRecent(mock.Anything, 8, 0, repository.HistoryScope{}).
934+ Return([]*entity.HistoryEntry{}, nil).
935+ Once()
936+
937+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
938+ _, err := uc.GetRecentWithin(ctx, 8, -5)
939+
940+ require.NoError(t, err)
941+}
942+
943+func TestSearchHistoryUseCase_GetRecentWithin_NonPositiveLimitReturnsEmpty(t *testing.T) {
944+ ctx := testContext()
945+ historyRepo := repomocks.NewMockHistoryRepository(t)
946+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
947+
948+ results, err := uc.GetRecentWithin(ctx, 0, 30)
949+
950+ require.NoError(t, err)
951+ assert.Empty(t, results)
952+}
953+
954+func TestSearchHistoryUseCase_GetMostVisitedWithin_NonPositiveLimitReturnsEmpty(t *testing.T) {
955+ ctx := testContext()
956+ historyRepo := repomocks.NewMockHistoryRepository(t)
957+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
958+
959+ results, err := uc.GetMostVisitedWithin(ctx, -3, 30)
960+
961+ require.NoError(t, err)
962+ assert.Empty(t, results)
963+}
964+
965+func TestSearchHistoryUseCase_GetRecentWithin_ClampsOversizedLimit(t *testing.T) {
966+ ctx := testContext()
967+ historyRepo := repomocks.NewMockHistoryRepository(t)
968+ historyRepo.EXPECT().
969+ GetRecent(mock.Anything, 500, 0, mock.Anything).
970+ Return([]*entity.HistoryEntry{}, nil).
971+ Once()
972+
973+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
974+ _, err := uc.GetRecentWithin(ctx, 10_000, 30)
975+
976+ require.NoError(t, err)
977+}
978+
979+func TestSearchHistoryUseCase_GetMostVisitedWithin_ClampsOversizedLimit(t *testing.T) {
980+ ctx := testContext()
981+ historyRepo := repomocks.NewMockHistoryRepository(t)
982+ historyRepo.EXPECT().
983+ GetMostVisitedWithin(mock.Anything, 500, mock.Anything).
984+ Return([]*entity.HistoryEntry{}, nil).
985+ Once()
986+
987+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
988+ _, err := uc.GetMostVisitedWithin(ctx, 10_000, 30)
989+
990+ require.NoError(t, err)
991+}
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/dumber/internal/application/dto"9 "github.com/bnema/dumber/internal/application/dto"
10 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12+ repository "github.com/bnema/dumber/internal/domain/repository"
12 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"13 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
13 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"15 "github.com/stretchr/testify/mock"
@@ -63,7 +64,7 @@ func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) {
63 ctx := testContext()64 ctx := testContext()
64 historyRepo := repomocks.NewMockHistoryRepository(t)65 historyRepo := repomocks.NewMockHistoryRepository(t)
65 entries := []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Example"}}66 entries := []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Example"}}
66- historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0).Return(entries, nil).Once()67+ historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0, repository.HistoryScope{}).Return(entries, nil).Once()
67 68
68 uc := usecase.NewSearchHistoryUseCase(historyRepo)69 uc := usecase.NewSearchHistoryUseCase(historyRepo)
69 result, err := uc.GetRecent(ctx, 0, 0)70 result, err := uc.GetRecent(ctx, 0, 0)
@@ -75,7 +76,7 @@ func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) {
75 func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testing.T) {76 func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testing.T) {
76 ctx := testContext()77 ctx := testContext()
77 historyRepo := repomocks.NewMockHistoryRepository(t)78 historyRepo := repomocks.NewMockHistoryRepository(t)
78- historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0).Return([]*entity.HistoryEntry{}, nil).Once()79+ historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0, repository.HistoryScope{}).Return([]*entity.HistoryEntry{}, nil).Once()
79 80
80 uc := usecase.NewSearchHistoryUseCase(historyRepo)81 uc := usecase.NewSearchHistoryUseCase(historyRepo)
81 result, err := uc.GetRecent(ctx, -1, 0)82 result, err := uc.GetRecent(ctx, -1, 0)
@@ -87,7 +88,7 @@ func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testi
87 func TestSearchHistoryUseCase_GetRecent_ClampsOversizedLimit(t *testing.T) {88 func TestSearchHistoryUseCase_GetRecent_ClampsOversizedLimit(t *testing.T) {
88 ctx := testContext()89 ctx := testContext()
89 historyRepo := repomocks.NewMockHistoryRepository(t)90 historyRepo := repomocks.NewMockHistoryRepository(t)
90- historyRepo.EXPECT().GetRecent(mock.Anything, 500, 10).Return([]*entity.HistoryEntry{}, nil).Once()91+ historyRepo.EXPECT().GetRecent(mock.Anything, 500, 10, repository.HistoryScope{}).Return([]*entity.HistoryEntry{}, nil).Once()
91 92
92 uc := usecase.NewSearchHistoryUseCase(historyRepo)93 uc := usecase.NewSearchHistoryUseCase(historyRepo)
93 result, err := uc.GetRecent(ctx, 10_000, 10)94 result, err := uc.GetRecent(ctx, 10_000, 10)
@@ -99,7 +100,7 @@ func TestSearchHistoryUseCase_GetRecent_ClampsOversizedLimit(t *testing.T) {
99 func TestSearchHistoryUseCase_Search_ClampsOversizedLimit(t *testing.T) {100 func TestSearchHistoryUseCase_Search_ClampsOversizedLimit(t *testing.T) {
100 ctx := testContext()101 ctx := testContext()
101 historyRepo := repomocks.NewMockHistoryRepository(t)102 historyRepo := repomocks.NewMockHistoryRepository(t)
102- historyRepo.EXPECT().Search(mock.Anything, "dumber", 100).Return([]entity.HistoryMatch{}, nil).Once()103+ historyRepo.EXPECT().Search(mock.Anything, "dumber", 100, repository.HistoryScope{}).Return([]entity.HistoryMatch{}, nil).Once()
103 104
104 uc := usecase.NewSearchHistoryUseCase(historyRepo)105 uc := usecase.NewSearchHistoryUseCase(historyRepo)
105 result, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 10_000})106 result, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 10_000})
@@ -109,6 +110,99 @@ func TestSearchHistoryUseCase_Search_ClampsOversizedLimit(t *testing.T) {
109 assert.Empty(t, result.Matches)110 assert.Empty(t, result.Matches)
110 }111 }
111 112
113+func TestSearchHistoryUseCase_Search_AppliesOptionalAgeScope(t *testing.T) {
114+ ctx := testContext()
115+ historyRepo := repomocks.NewMockHistoryRepository(t)
116+ var gotScope repository.HistoryScope
117+ historyRepo.EXPECT().
118+ Search(mock.Anything, "dumber", 20, mock.Anything).
119+ Run(func(_ context.Context, _ string, _ int, scope repository.HistoryScope) { gotScope = scope }).
120+ Return([]entity.HistoryMatch{}, nil).
121+ Once()
122+
123+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
124+ _, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: 30})
125+
126+ require.NoError(t, err)
127+ assert.WithinDuration(t, time.Now().AddDate(0, 0, -30), gotScope.Cutoff, time.Minute)
128+}
129+
130+func TestSearchHistoryUseCase_Search_ZeroMaxAgeMeansAllHistory(t *testing.T) {
131+ ctx := testContext()
132+ historyRepo := repomocks.NewMockHistoryRepository(t)
133+ historyRepo.EXPECT().
134+ Search(mock.Anything, "dumber", 20, repository.HistoryScope{}).
135+ Return([]entity.HistoryMatch{}, nil).
136+ Once()
137+
138+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
139+ _, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: 0})
140+
141+ require.NoError(t, err)
142+}
143+
144+func TestSearchHistoryUseCase_GetRecentWithin_AppliesScopeAndLimit(t *testing.T) {
145+ ctx := testContext()
146+ historyRepo := repomocks.NewMockHistoryRepository(t)
147+ var gotScope repository.HistoryScope
148+ historyRepo.EXPECT().
149+ GetRecent(mock.Anything, 8, 0, mock.Anything).
150+ Run(func(_ context.Context, _ int, _ int, scope repository.HistoryScope) { gotScope = scope }).
151+ Return([]*entity.HistoryEntry{}, nil).
152+ Once()
153+
154+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
155+ _, err := uc.GetRecentWithin(ctx, 8, 14)
156+
157+ require.NoError(t, err)
158+ assert.WithinDuration(t, time.Now().AddDate(0, 0, -14), gotScope.Cutoff, time.Minute)
159+}
160+
161+func TestSearchHistoryUseCase_GetRecentWithin_ZeroMeansAllHistory(t *testing.T) {
162+ ctx := testContext()
163+ historyRepo := repomocks.NewMockHistoryRepository(t)
164+ historyRepo.EXPECT().
165+ GetRecent(mock.Anything, 8, 0, repository.HistoryScope{}).
166+ Return([]*entity.HistoryEntry{}, nil).
167+ Once()
168+
169+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
170+ _, err := uc.GetRecentWithin(ctx, 8, 0)
171+
172+ require.NoError(t, err)
173+}
174+
175+func TestSearchHistoryUseCase_GetMostVisitedWithin_AppliesScopeAndLimit(t *testing.T) {
176+ ctx := testContext()
177+ historyRepo := repomocks.NewMockHistoryRepository(t)
178+ var gotScope repository.HistoryScope
179+ historyRepo.EXPECT().
180+ GetMostVisitedWithin(mock.Anything, 8, mock.Anything).
181+ Run(func(_ context.Context, _ int, scope repository.HistoryScope) { gotScope = scope }).
182+ Return([]*entity.HistoryEntry{}, nil).
183+ Once()
184+
185+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
186+ _, err := uc.GetMostVisitedWithin(ctx, 8, 14)
187+
188+ require.NoError(t, err)
189+ assert.WithinDuration(t, time.Now().AddDate(0, 0, -14), gotScope.Cutoff, time.Minute)
190+}
191+
192+func TestSearchHistoryUseCase_GetMostVisitedWithin_ZeroMeansAllHistory(t *testing.T) {
193+ ctx := testContext()
194+ historyRepo := repomocks.NewMockHistoryRepository(t)
195+ historyRepo.EXPECT().
196+ GetMostVisitedWithin(mock.Anything, 8, repository.HistoryScope{}).
197+ Return([]*entity.HistoryEntry{}, nil).
198+ Once()
199+
200+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
201+ _, err := uc.GetMostVisitedWithin(ctx, 8, 0)
202+
203+ require.NoError(t, err)
204+}
205+
112 func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationError(t *testing.T) {206 func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationError(t *testing.T) {
113 ctx := testContext()207 ctx := testContext()
114 historyRepo := repomocks.NewMockHistoryRepository(t)208 historyRepo := repomocks.NewMockHistoryRepository(t)
@@ -817,3 +911,81 @@ func TestSearchHistoryUseCase_DeleteByDomainAllowsUnderscoreDomains(t *testing.T
817 911
818 require.NoError(t, err)912 require.NoError(t, err)
819 }913 }
914+
915+func TestSearchHistoryUseCase_Search_NegativeMaxAgeMeansAllHistory(t *testing.T) {
916+ ctx := testContext()
917+ historyRepo := repomocks.NewMockHistoryRepository(t)
918+ historyRepo.EXPECT().
919+ Search(mock.Anything, "dumber", 20, repository.HistoryScope{}).
920+ Return([]entity.HistoryMatch{}, nil).
921+ Once()
922+
923+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
924+ _, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: -5})
925+
926+ require.NoError(t, err)
927+}
928+
929+func TestSearchHistoryUseCase_GetRecentWithin_NegativeMaxAgeMeansAllHistory(t *testing.T) {
930+ ctx := testContext()
931+ historyRepo := repomocks.NewMockHistoryRepository(t)
932+ historyRepo.EXPECT().
933+ GetRecent(mock.Anything, 8, 0, repository.HistoryScope{}).
934+ Return([]*entity.HistoryEntry{}, nil).
935+ Once()
936+
937+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
938+ _, err := uc.GetRecentWithin(ctx, 8, -5)
939+
940+ require.NoError(t, err)
941+}
942+
943+func TestSearchHistoryUseCase_GetRecentWithin_NonPositiveLimitReturnsEmpty(t *testing.T) {
944+ ctx := testContext()
945+ historyRepo := repomocks.NewMockHistoryRepository(t)
946+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
947+
948+ results, err := uc.GetRecentWithin(ctx, 0, 30)
949+
950+ require.NoError(t, err)
951+ assert.Empty(t, results)
952+}
953+
954+func TestSearchHistoryUseCase_GetMostVisitedWithin_NonPositiveLimitReturnsEmpty(t *testing.T) {
955+ ctx := testContext()
956+ historyRepo := repomocks.NewMockHistoryRepository(t)
957+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
958+
959+ results, err := uc.GetMostVisitedWithin(ctx, -3, 30)
960+
961+ require.NoError(t, err)
962+ assert.Empty(t, results)
963+}
964+
965+func TestSearchHistoryUseCase_GetRecentWithin_ClampsOversizedLimit(t *testing.T) {
966+ ctx := testContext()
967+ historyRepo := repomocks.NewMockHistoryRepository(t)
968+ historyRepo.EXPECT().
969+ GetRecent(mock.Anything, 500, 0, mock.Anything).
970+ Return([]*entity.HistoryEntry{}, nil).
971+ Once()
972+
973+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
974+ _, err := uc.GetRecentWithin(ctx, 10_000, 30)
975+
976+ require.NoError(t, err)
977+}
978+
979+func TestSearchHistoryUseCase_GetMostVisitedWithin_ClampsOversizedLimit(t *testing.T) {
980+ ctx := testContext()
981+ historyRepo := repomocks.NewMockHistoryRepository(t)
982+ historyRepo.EXPECT().
983+ GetMostVisitedWithin(mock.Anything, 500, mock.Anything).
984+ Return([]*entity.HistoryEntry{}, nil).
985+ Once()
986+
987+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
988+ _, err := uc.GetMostVisitedWithin(ctx, 10_000, 30)
989+
990+ require.NoError(t, err)
991+}
M internal/bootstrap/runtime_config.go +1 -1
@@ -98,7 +98,7 @@ func RuntimeConfigSnapshotFromConfig(cfg *config.Config) entity.RuntimeConfigSna
9898 Omnibox: entity.RuntimeOmniboxConfig{
9999 Style: cfg.Omnibox.Style,
100100 InitialBehavior: cfg.Omnibox.InitialBehavior,
101- MostVisitedDays: cfg.Omnibox.MostVisitedDays,
101+ MaxHistoryDays: cfg.Omnibox.MaxHistoryDays,
102102 AutoOpenOnNewPane: cfg.Omnibox.AutoOpenOnNewPane,
103103 },
104104 Update: entity.RuntimeUpdateConfig{
@@ -98,7 +98,7 @@ func RuntimeConfigSnapshotFromConfig(cfg *config.Config) entity.RuntimeConfigSna
98 Omnibox: entity.RuntimeOmniboxConfig{98 Omnibox: entity.RuntimeOmniboxConfig{
99 Style: cfg.Omnibox.Style,99 Style: cfg.Omnibox.Style,
100 InitialBehavior: cfg.Omnibox.InitialBehavior,100 InitialBehavior: cfg.Omnibox.InitialBehavior,
101- MostVisitedDays: cfg.Omnibox.MostVisitedDays,101+ MaxHistoryDays: cfg.Omnibox.MaxHistoryDays,
102 AutoOpenOnNewPane: cfg.Omnibox.AutoOpenOnNewPane,102 AutoOpenOnNewPane: cfg.Omnibox.AutoOpenOnNewPane,
103 },103 },
104 Update: entity.RuntimeUpdateConfig{104 Update: entity.RuntimeUpdateConfig{
M internal/domain/entity/runtime_config.go +1 -1
@@ -66,7 +66,7 @@ type RuntimeSearchShortcut struct {
6666 type RuntimeOmniboxConfig struct {
6767 Style string
6868 InitialBehavior OmniboxInitialBehavior
69- MostVisitedDays int
69+ MaxHistoryDays int
7070 AutoOpenOnNewPane bool
7171 }
7272
@@ -66,7 +66,7 @@ type RuntimeSearchShortcut struct {
66 type RuntimeOmniboxConfig struct {66 type RuntimeOmniboxConfig struct {
67 Style string67 Style string
68 InitialBehavior OmniboxInitialBehavior68 InitialBehavior OmniboxInitialBehavior
69- MostVisitedDays int69+ MaxHistoryDays int
70 AutoOpenOnNewPane bool70 AutoOpenOnNewPane bool
71 }71 }
72 72
M internal/domain/repository/history.go +20 -4
@@ -7,6 +7,13 @@ import (
77 "github.com/bnema/dumber/internal/domain/entity"
88 )
99
10+// HistoryScope constrains a history query to an optional age window. Cutoff is
11+// the inclusive lower bound for an entry's last_visited timestamp. The zero
12+// time disables age filtering, so all stored history is eligible.
13+type HistoryScope struct {
14+ Cutoff time.Time
15+}
16+
1017 // HistoryRepository defines operations for browsing history persistence.
1118 type HistoryRepository interface {
1219 // Save creates or updates a history entry (upsert).
@@ -15,11 +22,15 @@ type HistoryRepository interface {
1522 // FindByURL retrieves a history entry by its URL.
1623 FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error)
1724
18- // Search performs a fuzzy search on history entries.
19- Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error)
25+ // Search performs a fuzzy search on history entries. Results older than
26+ // scope.Cutoff are excluded in SQL before the result limit is applied. A
27+ // non-positive limit returns no matches.
28+ Search(ctx context.Context, query string, limit int, scope HistoryScope) ([]entity.HistoryMatch, error)
2029
21- // GetRecent retrieves recent history entries with pagination. A zero limit means all entries.
22- GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
30+ // GetRecent retrieves recent history entries with pagination. A non-positive
31+ // limit means all entries. Results older than scope.Cutoff are excluded in
32+ // SQL before the limit is applied.
33+ GetRecent(ctx context.Context, limit, offset int, scope HistoryScope) ([]*entity.HistoryEntry, error)
2334
2435 // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries.
2536 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
@@ -38,6 +49,11 @@ type HistoryRepository interface {
3849 // days must be > 0.
3950 GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
4051
52+ // GetMostVisitedWithin retrieves history entries ordered by visit count,
53+ // optionally restricted to scope.Cutoff and capped by limit in SQL. A
54+ // non-positive limit returns an empty slice.
55+ GetMostVisitedWithin(ctx context.Context, limit int, scope HistoryScope) ([]*entity.HistoryEntry, error)
56+
4157 // GetAllRecentHistory retrieves all history entries sorted by recency.
4258 GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error)
4359
@@ -7,6 +7,13 @@ import (
7 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
8 )8 )
9 9
10+// HistoryScope constrains a history query to an optional age window. Cutoff is
11+// the inclusive lower bound for an entry's last_visited timestamp. The zero
12+// time disables age filtering, so all stored history is eligible.
13+type HistoryScope struct {
14+ Cutoff time.Time
15+}
16+
10 // HistoryRepository defines operations for browsing history persistence.17 // HistoryRepository defines operations for browsing history persistence.
11 type HistoryRepository interface {18 type HistoryRepository interface {
12 // Save creates or updates a history entry (upsert).19 // Save creates or updates a history entry (upsert).
@@ -15,11 +22,15 @@ type HistoryRepository interface {
15 // FindByURL retrieves a history entry by its URL.22 // FindByURL retrieves a history entry by its URL.
16 FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error)23 FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error)
17 24
18- // Search performs a fuzzy search on history entries.25+ // Search performs a fuzzy search on history entries. Results older than
19- Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error)26+ // scope.Cutoff are excluded in SQL before the result limit is applied. A
27+ // non-positive limit returns no matches.
28+ Search(ctx context.Context, query string, limit int, scope HistoryScope) ([]entity.HistoryMatch, error)
20 29
21- // GetRecent retrieves recent history entries with pagination. A zero limit means all entries.30+ // GetRecent retrieves recent history entries with pagination. A non-positive
22- GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)31+ // limit means all entries. Results older than scope.Cutoff are excluded in
32+ // SQL before the limit is applied.
33+ GetRecent(ctx context.Context, limit, offset int, scope HistoryScope) ([]*entity.HistoryEntry, error)
23 34
24 // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries.35 // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries.
25 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)36 GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
@@ -38,6 +49,11 @@ type HistoryRepository interface {
38 // days must be > 0.49 // days must be > 0.
39 GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error)50 GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
40 51
52+ // GetMostVisitedWithin retrieves history entries ordered by visit count,
53+ // optionally restricted to scope.Cutoff and capped by limit in SQL. A
54+ // non-positive limit returns an empty slice.
55+ GetMostVisitedWithin(ctx context.Context, limit int, scope HistoryScope) ([]*entity.HistoryEntry, error)
56+
41 // GetAllRecentHistory retrieves all history entries sorted by recency.57 // GetAllRecentHistory retrieves all history entries sorted by recency.
42 GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error)58 GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error)
43 59
M internal/domain/repository/mocks/history.go +111 -24
@@ -9,6 +9,7 @@ import (
99 "time"
1010
1111 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/domain/repository"
1213 mock "github.com/stretchr/testify/mock"
1314 )
1415
@@ -776,9 +777,83 @@ func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx c
776777 return _c
777778 }
778779
780+// GetMostVisitedWithin provides a mock function for the type MockHistoryRepository
781+func (_mock *MockHistoryRepository) GetMostVisitedWithin(ctx context.Context, limit int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error) {
782+ ret := _mock.Called(ctx, limit, scope)
783+
784+ if len(ret) == 0 {
785+ panic("no return value specified for GetMostVisitedWithin")
786+ }
787+
788+ var r0 []*entity.HistoryEntry
789+ var r1 error
790+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, repository.HistoryScope) ([]*entity.HistoryEntry, error)); ok {
791+ return returnFunc(ctx, limit, scope)
792+ }
793+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, repository.HistoryScope) []*entity.HistoryEntry); ok {
794+ r0 = returnFunc(ctx, limit, scope)
795+ } else {
796+ if ret.Get(0) != nil {
797+ r0 = ret.Get(0).([]*entity.HistoryEntry)
798+ }
799+ }
800+ if returnFunc, ok := ret.Get(1).(func(context.Context, int, repository.HistoryScope) error); ok {
801+ r1 = returnFunc(ctx, limit, scope)
802+ } else {
803+ r1 = ret.Error(1)
804+ }
805+ return r0, r1
806+}
807+
808+// MockHistoryRepository_GetMostVisitedWithin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisitedWithin'
809+type MockHistoryRepository_GetMostVisitedWithin_Call struct {
810+ *mock.Call
811+}
812+
813+// GetMostVisitedWithin is a helper method to define mock.On call
814+// - ctx context.Context
815+// - limit int
816+// - scope repository.HistoryScope
817+func (_e *MockHistoryRepository_Expecter) GetMostVisitedWithin(ctx any, limit any, scope any) *MockHistoryRepository_GetMostVisitedWithin_Call {
818+ return &MockHistoryRepository_GetMostVisitedWithin_Call{Call: _e.mock.On("GetMostVisitedWithin", ctx, limit, scope)}
819+}
820+
821+func (_c *MockHistoryRepository_GetMostVisitedWithin_Call) Run(run func(ctx context.Context, limit int, scope repository.HistoryScope)) *MockHistoryRepository_GetMostVisitedWithin_Call {
822+ _c.Call.Run(func(args mock.Arguments) {
823+ var arg0 context.Context
824+ if args[0] != nil {
825+ arg0 = args[0].(context.Context)
826+ }
827+ var arg1 int
828+ if args[1] != nil {
829+ arg1 = args[1].(int)
830+ }
831+ var arg2 repository.HistoryScope
832+ if args[2] != nil {
833+ arg2 = args[2].(repository.HistoryScope)
834+ }
835+ run(
836+ arg0,
837+ arg1,
838+ arg2,
839+ )
840+ })
841+ return _c
842+}
843+
844+func (_c *MockHistoryRepository_GetMostVisitedWithin_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisitedWithin_Call {
845+ _c.Call.Return(historyEntrys, err)
846+ return _c
847+}
848+
849+func (_c *MockHistoryRepository_GetMostVisitedWithin_Call) RunAndReturn(run func(ctx context.Context, limit int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisitedWithin_Call {
850+ _c.Call.Return(run)
851+ return _c
852+}
853+
779854 // GetRecent provides a mock function for the type MockHistoryRepository
780-func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {
781- ret := _mock.Called(ctx, limit, offset)
855+func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error) {
856+ ret := _mock.Called(ctx, limit, offset, scope)
782857
783858 if len(ret) == 0 {
784859 panic("no return value specified for GetRecent")
@@ -786,18 +861,18 @@ func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, of
786861
787862 var r0 []*entity.HistoryEntry
788863 var r1 error
789- if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) ([]*entity.HistoryEntry, error)); ok {
790- return returnFunc(ctx, limit, offset)
864+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, int, repository.HistoryScope) ([]*entity.HistoryEntry, error)); ok {
865+ return returnFunc(ctx, limit, offset, scope)
791866 }
792- if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) []*entity.HistoryEntry); ok {
793- r0 = returnFunc(ctx, limit, offset)
867+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, int, repository.HistoryScope) []*entity.HistoryEntry); ok {
868+ r0 = returnFunc(ctx, limit, offset, scope)
794869 } else {
795870 if ret.Get(0) != nil {
796871 r0 = ret.Get(0).([]*entity.HistoryEntry)
797872 }
798873 }
799- if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok {
800- r1 = returnFunc(ctx, limit, offset)
874+ if returnFunc, ok := ret.Get(1).(func(context.Context, int, int, repository.HistoryScope) error); ok {
875+ r1 = returnFunc(ctx, limit, offset, scope)
801876 } else {
802877 r1 = ret.Error(1)
803878 }
@@ -813,11 +888,12 @@ type MockHistoryRepository_GetRecent_Call struct {
813888 // - ctx context.Context
814889 // - limit int
815890 // - offset int
816-func (_e *MockHistoryRepository_Expecter) GetRecent(ctx any, limit any, offset any) *MockHistoryRepository_GetRecent_Call {
817- return &MockHistoryRepository_GetRecent_Call{Call: _e.mock.On("GetRecent", ctx, limit, offset)}
891+// - scope repository.HistoryScope
892+func (_e *MockHistoryRepository_Expecter) GetRecent(ctx any, limit any, offset any, scope any) *MockHistoryRepository_GetRecent_Call {
893+ return &MockHistoryRepository_GetRecent_Call{Call: _e.mock.On("GetRecent", ctx, limit, offset, scope)}
818894 }
819895
820-func (_c *MockHistoryRepository_GetRecent_Call) Run(run func(ctx context.Context, limit int, offset int)) *MockHistoryRepository_GetRecent_Call {
896+func (_c *MockHistoryRepository_GetRecent_Call) Run(run func(ctx context.Context, limit int, offset int, scope repository.HistoryScope)) *MockHistoryRepository_GetRecent_Call {
821897 _c.Call.Run(func(args mock.Arguments) {
822898 var arg0 context.Context
823899 if args[0] != nil {
@@ -831,10 +907,15 @@ func (_c *MockHistoryRepository_GetRecent_Call) Run(run func(ctx context.Context
831907 if args[2] != nil {
832908 arg2 = args[2].(int)
833909 }
910+ var arg3 repository.HistoryScope
911+ if args[3] != nil {
912+ arg3 = args[3].(repository.HistoryScope)
913+ }
834914 run(
835915 arg0,
836916 arg1,
837917 arg2,
918+ arg3,
838919 )
839920 })
840921 return _c
@@ -845,7 +926,7 @@ func (_c *MockHistoryRepository_GetRecent_Call) Return(historyEntrys []*entity.H
845926 return _c
846927 }
847928
848-func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecent_Call {
929+func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx context.Context, limit int, offset int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecent_Call {
849930 _c.Call.Return(run)
850931 return _c
851932 }
@@ -1341,8 +1422,8 @@ func (_c *MockHistoryRepository_Save_Call) RunAndReturn(run func(ctx context.Con
13411422 }
13421423
13431424 // Search provides a mock function for the type MockHistoryRepository
1344-func (_mock *MockHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
1345- ret := _mock.Called(ctx, query, limit)
1425+func (_mock *MockHistoryRepository) Search(ctx context.Context, query string, limit int, scope repository.HistoryScope) ([]entity.HistoryMatch, error) {
1426+ ret := _mock.Called(ctx, query, limit, scope)
13461427
13471428 if len(ret) == 0 {
13481429 panic("no return value specified for Search")
@@ -1350,18 +1431,18 @@ func (_mock *MockHistoryRepository) Search(ctx context.Context, query string, li
13501431
13511432 var r0 []entity.HistoryMatch
13521433 var r1 error
1353- if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) ([]entity.HistoryMatch, error)); ok {
1354- return returnFunc(ctx, query, limit)
1434+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, repository.HistoryScope) ([]entity.HistoryMatch, error)); ok {
1435+ return returnFunc(ctx, query, limit, scope)
13551436 }
1356- if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) []entity.HistoryMatch); ok {
1357- r0 = returnFunc(ctx, query, limit)
1437+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, repository.HistoryScope) []entity.HistoryMatch); ok {
1438+ r0 = returnFunc(ctx, query, limit, scope)
13581439 } else {
13591440 if ret.Get(0) != nil {
13601441 r0 = ret.Get(0).([]entity.HistoryMatch)
13611442 }
13621443 }
1363- if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok {
1364- r1 = returnFunc(ctx, query, limit)
1444+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, repository.HistoryScope) error); ok {
1445+ r1 = returnFunc(ctx, query, limit, scope)
13651446 } else {
13661447 r1 = ret.Error(1)
13671448 }
@@ -1377,11 +1458,12 @@ type MockHistoryRepository_Search_Call struct {
13771458 // - ctx context.Context
13781459 // - query string
13791460 // - limit int
1380-func (_e *MockHistoryRepository_Expecter) Search(ctx any, query any, limit any) *MockHistoryRepository_Search_Call {
1381- return &MockHistoryRepository_Search_Call{Call: _e.mock.On("Search", ctx, query, limit)}
1461+// - scope repository.HistoryScope
1462+func (_e *MockHistoryRepository_Expecter) Search(ctx any, query any, limit any, scope any) *MockHistoryRepository_Search_Call {
1463+ return &MockHistoryRepository_Search_Call{Call: _e.mock.On("Search", ctx, query, limit, scope)}
13821464 }
13831465
1384-func (_c *MockHistoryRepository_Search_Call) Run(run func(ctx context.Context, query string, limit int)) *MockHistoryRepository_Search_Call {
1466+func (_c *MockHistoryRepository_Search_Call) Run(run func(ctx context.Context, query string, limit int, scope repository.HistoryScope)) *MockHistoryRepository_Search_Call {
13851467 _c.Call.Run(func(args mock.Arguments) {
13861468 var arg0 context.Context
13871469 if args[0] != nil {
@@ -1395,10 +1477,15 @@ func (_c *MockHistoryRepository_Search_Call) Run(run func(ctx context.Context, q
13951477 if args[2] != nil {
13961478 arg2 = args[2].(int)
13971479 }
1480+ var arg3 repository.HistoryScope
1481+ if args[3] != nil {
1482+ arg3 = args[3].(repository.HistoryScope)
1483+ }
13981484 run(
13991485 arg0,
14001486 arg1,
14011487 arg2,
1488+ arg3,
14021489 )
14031490 })
14041491 return _c
@@ -1409,7 +1496,7 @@ func (_c *MockHistoryRepository_Search_Call) Return(historyMatchs []entity.Histo
14091496 return _c
14101497 }
14111498
1412-func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error)) *MockHistoryRepository_Search_Call {
1499+func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.Context, query string, limit int, scope repository.HistoryScope) ([]entity.HistoryMatch, error)) *MockHistoryRepository_Search_Call {
14131500 _c.Call.Return(run)
14141501 return _c
14151502 }
@@ -9,6 +9,7 @@ import (
9 "time"9 "time"
10 10
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/domain/repository"
12 mock "github.com/stretchr/testify/mock"13 mock "github.com/stretchr/testify/mock"
13 )14 )
14 15
@@ -776,9 +777,83 @@ func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx c
776 return _c777 return _c
777 }778 }
778 779
780+// GetMostVisitedWithin provides a mock function for the type MockHistoryRepository
781+func (_mock *MockHistoryRepository) GetMostVisitedWithin(ctx context.Context, limit int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error) {
782+ ret := _mock.Called(ctx, limit, scope)
783+
784+ if len(ret) == 0 {
785+ panic("no return value specified for GetMostVisitedWithin")
786+ }
787+
788+ var r0 []*entity.HistoryEntry
789+ var r1 error
790+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, repository.HistoryScope) ([]*entity.HistoryEntry, error)); ok {
791+ return returnFunc(ctx, limit, scope)
792+ }
793+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, repository.HistoryScope) []*entity.HistoryEntry); ok {
794+ r0 = returnFunc(ctx, limit, scope)
795+ } else {
796+ if ret.Get(0) != nil {
797+ r0 = ret.Get(0).([]*entity.HistoryEntry)
798+ }
799+ }
800+ if returnFunc, ok := ret.Get(1).(func(context.Context, int, repository.HistoryScope) error); ok {
801+ r1 = returnFunc(ctx, limit, scope)
802+ } else {
803+ r1 = ret.Error(1)
804+ }
805+ return r0, r1
806+}
807+
808+// MockHistoryRepository_GetMostVisitedWithin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisitedWithin'
809+type MockHistoryRepository_GetMostVisitedWithin_Call struct {
810+ *mock.Call
811+}
812+
813+// GetMostVisitedWithin is a helper method to define mock.On call
814+// - ctx context.Context
815+// - limit int
816+// - scope repository.HistoryScope
817+func (_e *MockHistoryRepository_Expecter) GetMostVisitedWithin(ctx any, limit any, scope any) *MockHistoryRepository_GetMostVisitedWithin_Call {
818+ return &MockHistoryRepository_GetMostVisitedWithin_Call{Call: _e.mock.On("GetMostVisitedWithin", ctx, limit, scope)}
819+}
820+
821+func (_c *MockHistoryRepository_GetMostVisitedWithin_Call) Run(run func(ctx context.Context, limit int, scope repository.HistoryScope)) *MockHistoryRepository_GetMostVisitedWithin_Call {
822+ _c.Call.Run(func(args mock.Arguments) {
823+ var arg0 context.Context
824+ if args[0] != nil {
825+ arg0 = args[0].(context.Context)
826+ }
827+ var arg1 int
828+ if args[1] != nil {
829+ arg1 = args[1].(int)
830+ }
831+ var arg2 repository.HistoryScope
832+ if args[2] != nil {
833+ arg2 = args[2].(repository.HistoryScope)
834+ }
835+ run(
836+ arg0,
837+ arg1,
838+ arg2,
839+ )
840+ })
841+ return _c
842+}
843+
844+func (_c *MockHistoryRepository_GetMostVisitedWithin_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisitedWithin_Call {
845+ _c.Call.Return(historyEntrys, err)
846+ return _c
847+}
848+
849+func (_c *MockHistoryRepository_GetMostVisitedWithin_Call) RunAndReturn(run func(ctx context.Context, limit int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisitedWithin_Call {
850+ _c.Call.Return(run)
851+ return _c
852+}
853+
779 // GetRecent provides a mock function for the type MockHistoryRepository854 // GetRecent provides a mock function for the type MockHistoryRepository
780-func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {855+func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error) {
781- ret := _mock.Called(ctx, limit, offset)856+ ret := _mock.Called(ctx, limit, offset, scope)
782 857
783 if len(ret) == 0 {858 if len(ret) == 0 {
784 panic("no return value specified for GetRecent")859 panic("no return value specified for GetRecent")
@@ -786,18 +861,18 @@ func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, of
786 861
787 var r0 []*entity.HistoryEntry862 var r0 []*entity.HistoryEntry
788 var r1 error863 var r1 error
789- if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) ([]*entity.HistoryEntry, error)); ok {864+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, int, repository.HistoryScope) ([]*entity.HistoryEntry, error)); ok {
790- return returnFunc(ctx, limit, offset)865+ return returnFunc(ctx, limit, offset, scope)
791 }866 }
792- if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) []*entity.HistoryEntry); ok {867+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, int, repository.HistoryScope) []*entity.HistoryEntry); ok {
793- r0 = returnFunc(ctx, limit, offset)868+ r0 = returnFunc(ctx, limit, offset, scope)
794 } else {869 } else {
795 if ret.Get(0) != nil {870 if ret.Get(0) != nil {
796 r0 = ret.Get(0).([]*entity.HistoryEntry)871 r0 = ret.Get(0).([]*entity.HistoryEntry)
797 }872 }
798 }873 }
799- if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok {874+ if returnFunc, ok := ret.Get(1).(func(context.Context, int, int, repository.HistoryScope) error); ok {
800- r1 = returnFunc(ctx, limit, offset)875+ r1 = returnFunc(ctx, limit, offset, scope)
801 } else {876 } else {
802 r1 = ret.Error(1)877 r1 = ret.Error(1)
803 }878 }
@@ -813,11 +888,12 @@ type MockHistoryRepository_GetRecent_Call struct {
813 // - ctx context.Context888 // - ctx context.Context
814 // - limit int889 // - limit int
815 // - offset int890 // - offset int
816-func (_e *MockHistoryRepository_Expecter) GetRecent(ctx any, limit any, offset any) *MockHistoryRepository_GetRecent_Call {891+// - scope repository.HistoryScope
817- return &MockHistoryRepository_GetRecent_Call{Call: _e.mock.On("GetRecent", ctx, limit, offset)}892+func (_e *MockHistoryRepository_Expecter) GetRecent(ctx any, limit any, offset any, scope any) *MockHistoryRepository_GetRecent_Call {
893+ return &MockHistoryRepository_GetRecent_Call{Call: _e.mock.On("GetRecent", ctx, limit, offset, scope)}
818 }894 }
819 895
820-func (_c *MockHistoryRepository_GetRecent_Call) Run(run func(ctx context.Context, limit int, offset int)) *MockHistoryRepository_GetRecent_Call {896+func (_c *MockHistoryRepository_GetRecent_Call) Run(run func(ctx context.Context, limit int, offset int, scope repository.HistoryScope)) *MockHistoryRepository_GetRecent_Call {
821 _c.Call.Run(func(args mock.Arguments) {897 _c.Call.Run(func(args mock.Arguments) {
822 var arg0 context.Context898 var arg0 context.Context
823 if args[0] != nil {899 if args[0] != nil {
@@ -831,10 +907,15 @@ func (_c *MockHistoryRepository_GetRecent_Call) Run(run func(ctx context.Context
831 if args[2] != nil {907 if args[2] != nil {
832 arg2 = args[2].(int)908 arg2 = args[2].(int)
833 }909 }
910+ var arg3 repository.HistoryScope
911+ if args[3] != nil {
912+ arg3 = args[3].(repository.HistoryScope)
913+ }
834 run(914 run(
835 arg0,915 arg0,
836 arg1,916 arg1,
837 arg2,917 arg2,
918+ arg3,
838 )919 )
839 })920 })
840 return _c921 return _c
@@ -845,7 +926,7 @@ func (_c *MockHistoryRepository_GetRecent_Call) Return(historyEntrys []*entity.H
845 return _c926 return _c
846 }927 }
847 928
848-func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecent_Call {929+func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx context.Context, limit int, offset int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecent_Call {
849 _c.Call.Return(run)930 _c.Call.Return(run)
850 return _c931 return _c
851 }932 }
@@ -1341,8 +1422,8 @@ func (_c *MockHistoryRepository_Save_Call) RunAndReturn(run func(ctx context.Con
1341 }1422 }
1342 1423
1343 // Search provides a mock function for the type MockHistoryRepository1424 // Search provides a mock function for the type MockHistoryRepository
1344-func (_mock *MockHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {1425+func (_mock *MockHistoryRepository) Search(ctx context.Context, query string, limit int, scope repository.HistoryScope) ([]entity.HistoryMatch, error) {
1345- ret := _mock.Called(ctx, query, limit)1426+ ret := _mock.Called(ctx, query, limit, scope)
1346 1427
1347 if len(ret) == 0 {1428 if len(ret) == 0 {
1348 panic("no return value specified for Search")1429 panic("no return value specified for Search")
@@ -1350,18 +1431,18 @@ func (_mock *MockHistoryRepository) Search(ctx context.Context, query string, li
1350 1431
1351 var r0 []entity.HistoryMatch1432 var r0 []entity.HistoryMatch
1352 var r1 error1433 var r1 error
1353- if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) ([]entity.HistoryMatch, error)); ok {1434+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, repository.HistoryScope) ([]entity.HistoryMatch, error)); ok {
1354- return returnFunc(ctx, query, limit)1435+ return returnFunc(ctx, query, limit, scope)
1355 }1436 }
1356- if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) []entity.HistoryMatch); ok {1437+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, repository.HistoryScope) []entity.HistoryMatch); ok {
1357- r0 = returnFunc(ctx, query, limit)1438+ r0 = returnFunc(ctx, query, limit, scope)
1358 } else {1439 } else {
1359 if ret.Get(0) != nil {1440 if ret.Get(0) != nil {
1360 r0 = ret.Get(0).([]entity.HistoryMatch)1441 r0 = ret.Get(0).([]entity.HistoryMatch)
1361 }1442 }
1362 }1443 }
1363- if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok {1444+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, repository.HistoryScope) error); ok {
1364- r1 = returnFunc(ctx, query, limit)1445+ r1 = returnFunc(ctx, query, limit, scope)
1365 } else {1446 } else {
1366 r1 = ret.Error(1)1447 r1 = ret.Error(1)
1367 }1448 }
@@ -1377,11 +1458,12 @@ type MockHistoryRepository_Search_Call struct {
1377 // - ctx context.Context1458 // - ctx context.Context
1378 // - query string1459 // - query string
1379 // - limit int1460 // - limit int
1380-func (_e *MockHistoryRepository_Expecter) Search(ctx any, query any, limit any) *MockHistoryRepository_Search_Call {1461+// - scope repository.HistoryScope
1381- return &MockHistoryRepository_Search_Call{Call: _e.mock.On("Search", ctx, query, limit)}1462+func (_e *MockHistoryRepository_Expecter) Search(ctx any, query any, limit any, scope any) *MockHistoryRepository_Search_Call {
1463+ return &MockHistoryRepository_Search_Call{Call: _e.mock.On("Search", ctx, query, limit, scope)}
1382 }1464 }
1383 1465
1384-func (_c *MockHistoryRepository_Search_Call) Run(run func(ctx context.Context, query string, limit int)) *MockHistoryRepository_Search_Call {1466+func (_c *MockHistoryRepository_Search_Call) Run(run func(ctx context.Context, query string, limit int, scope repository.HistoryScope)) *MockHistoryRepository_Search_Call {
1385 _c.Call.Run(func(args mock.Arguments) {1467 _c.Call.Run(func(args mock.Arguments) {
1386 var arg0 context.Context1468 var arg0 context.Context
1387 if args[0] != nil {1469 if args[0] != nil {
@@ -1395,10 +1477,15 @@ func (_c *MockHistoryRepository_Search_Call) Run(run func(ctx context.Context, q
1395 if args[2] != nil {1477 if args[2] != nil {
1396 arg2 = args[2].(int)1478 arg2 = args[2].(int)
1397 }1479 }
1480+ var arg3 repository.HistoryScope
1481+ if args[3] != nil {
1482+ arg3 = args[3].(repository.HistoryScope)
1483+ }
1398 run(1484 run(
1399 arg0,1485 arg0,
1400 arg1,1486 arg1,
1401 arg2,1487 arg2,
1488+ arg3,
1402 )1489 )
1403 })1490 })
1404 return _c1491 return _c
@@ -1409,7 +1496,7 @@ func (_c *MockHistoryRepository_Search_Call) Return(historyMatchs []entity.Histo
1409 return _c1496 return _c
1410 }1497 }
1411 1498
1412-func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error)) *MockHistoryRepository_Search_Call {1499+func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.Context, query string, limit int, scope repository.HistoryScope) ([]entity.HistoryMatch, error)) *MockHistoryRepository_Search_Call {
1413 _c.Call.Return(run)1500 _c.Call.Return(run)
1414 return _c1501 return _c
1415 }1502 }
M internal/infrastructure/config/defaults.go +2 -2
@@ -35,7 +35,7 @@ const (
3535 // Omnibox defaults
3636 defaultOmniboxStyle = "command"
3737 defaultOmniboxInitialBehavior = OmniboxInitialBehaviorRecent
38- defaultOmniboxMostVisitedDays = 30
38+ defaultOmniboxMaxHistoryDays = 30
3939 defaultOmniboxAutoOpenOnNewPane = false
4040
4141 // Workspace defaults
@@ -408,7 +408,7 @@ func DefaultConfig() *Config {
408408 Omnibox: OmniboxConfig{
409409 Style: defaultOmniboxStyle,
410410 InitialBehavior: defaultOmniboxInitialBehavior,
411- MostVisitedDays: defaultOmniboxMostVisitedDays,
411+ MaxHistoryDays: defaultOmniboxMaxHistoryDays,
412412 AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane,
413413 },
414414 Session: SessionConfig{
@@ -35,7 +35,7 @@ const (
35 // Omnibox defaults35 // Omnibox defaults
36 defaultOmniboxStyle = "command"36 defaultOmniboxStyle = "command"
37 defaultOmniboxInitialBehavior = OmniboxInitialBehaviorRecent37 defaultOmniboxInitialBehavior = OmniboxInitialBehaviorRecent
38- defaultOmniboxMostVisitedDays = 3038+ defaultOmniboxMaxHistoryDays = 30
39 defaultOmniboxAutoOpenOnNewPane = false39 defaultOmniboxAutoOpenOnNewPane = false
40 40
41 // Workspace defaults41 // Workspace defaults
@@ -408,7 +408,7 @@ func DefaultConfig() *Config {
408 Omnibox: OmniboxConfig{408 Omnibox: OmniboxConfig{
409 Style: defaultOmniboxStyle,409 Style: defaultOmniboxStyle,
410 InitialBehavior: defaultOmniboxInitialBehavior,410 InitialBehavior: defaultOmniboxInitialBehavior,
411- MostVisitedDays: defaultOmniboxMostVisitedDays,411+ MaxHistoryDays: defaultOmniboxMaxHistoryDays,
412 AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane,412 AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane,
413 },413 },
414 Session: SessionConfig{414 Session: SessionConfig{
M internal/infrastructure/config/loader.go +1 -1
@@ -755,7 +755,7 @@ func (m *Manager) setClipboardDefaults(defaults *Config) {
755755 func (m *Manager) setOmniboxDefaults(defaults *Config) {
756756 m.viper.SetDefault("omnibox.style", defaults.Omnibox.Style)
757757 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
758- m.viper.SetDefault("omnibox.most_visited_days", defaults.Omnibox.MostVisitedDays)
758+ m.viper.SetDefault("omnibox.max_history_days", defaults.Omnibox.MaxHistoryDays)
759759 m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
760760 }
761761
@@ -755,7 +755,7 @@ func (m *Manager) setClipboardDefaults(defaults *Config) {
755 func (m *Manager) setOmniboxDefaults(defaults *Config) {755 func (m *Manager) setOmniboxDefaults(defaults *Config) {
756 m.viper.SetDefault("omnibox.style", defaults.Omnibox.Style)756 m.viper.SetDefault("omnibox.style", defaults.Omnibox.Style)
757 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)757 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
758- m.viper.SetDefault("omnibox.most_visited_days", defaults.Omnibox.MostVisitedDays)758+ m.viper.SetDefault("omnibox.max_history_days", defaults.Omnibox.MaxHistoryDays)
759 m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)759 m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
760 }760 }
761 761
M internal/infrastructure/config/migrate.go +24 -0
@@ -124,6 +124,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
124124 m.detectLegacyFavoritesSidebarRename(userKeysWithValues)...,
125125 )
126126 legacyRenames = append(legacyRenames, m.detectLegacyVimModeRenames(rawUserKeysWithValues)...)
127+ legacyRenames = append(legacyRenames, m.detectLegacyOmniboxMaxHistoryDaysRename(rawUserKeysWithValues)...)
127128 var changes []port.KeyChange
128129 changes = append(changes, legacyRenames...)
129130
@@ -790,6 +791,29 @@ func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any
790791 return changes
791792 }
792793
794+func (m *Migrator) detectLegacyOmniboxMaxHistoryDaysRename(rawUserKeys map[string]any) []port.KeyChange {
795+ const (
796+ oldKey = "omnibox.most_visited_days"
797+ newKey = "omnibox.max_history_days"
798+ )
799+
800+ oldValue, hasOld := rawUserKeys[oldKey]
801+ if !hasOld {
802+ return nil
803+ }
804+ if _, hasNew := rawUserKeys[newKey]; hasNew {
805+ return nil
806+ }
807+
808+ return []port.KeyChange{{
809+ Type: port.KeyChangeRenamed,
810+ OldKey: oldKey,
811+ NewKey: newKey,
812+ OldValue: m.formatValue(oldValue),
813+ NewValue: m.formatValue(m.defaultValueForKey(newKey)),
814+ }}
815+}
816+
793817 func (m *Migrator) detectLegacyVimModeRenames(rawUserKeys map[string]any) []port.KeyChange {
794818 changes := make([]port.KeyChange, 0, 3)
795819 for oldKey, oldValue := range rawUserKeys {
@@ -124,6 +124,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
124 m.detectLegacyFavoritesSidebarRename(userKeysWithValues)...,124 m.detectLegacyFavoritesSidebarRename(userKeysWithValues)...,
125 )125 )
126 legacyRenames = append(legacyRenames, m.detectLegacyVimModeRenames(rawUserKeysWithValues)...)126 legacyRenames = append(legacyRenames, m.detectLegacyVimModeRenames(rawUserKeysWithValues)...)
127+ legacyRenames = append(legacyRenames, m.detectLegacyOmniboxMaxHistoryDaysRename(rawUserKeysWithValues)...)
127 var changes []port.KeyChange128 var changes []port.KeyChange
128 changes = append(changes, legacyRenames...)129 changes = append(changes, legacyRenames...)
129 130
@@ -790,6 +791,29 @@ func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any
790 return changes791 return changes
791 }792 }
792 793
794+func (m *Migrator) detectLegacyOmniboxMaxHistoryDaysRename(rawUserKeys map[string]any) []port.KeyChange {
795+ const (
796+ oldKey = "omnibox.most_visited_days"
797+ newKey = "omnibox.max_history_days"
798+ )
799+
800+ oldValue, hasOld := rawUserKeys[oldKey]
801+ if !hasOld {
802+ return nil
803+ }
804+ if _, hasNew := rawUserKeys[newKey]; hasNew {
805+ return nil
806+ }
807+
808+ return []port.KeyChange{{
809+ Type: port.KeyChangeRenamed,
810+ OldKey: oldKey,
811+ NewKey: newKey,
812+ OldValue: m.formatValue(oldValue),
813+ NewValue: m.formatValue(m.defaultValueForKey(newKey)),
814+ }}
815+}
816+
793 func (m *Migrator) detectLegacyVimModeRenames(rawUserKeys map[string]any) []port.KeyChange {817 func (m *Migrator) detectLegacyVimModeRenames(rawUserKeys map[string]any) []port.KeyChange {
794 changes := make([]port.KeyChange, 0, 3)818 changes := make([]port.KeyChange, 0, 3)
795 for oldKey, oldValue := range rawUserKeys {819 for oldKey, oldValue := range rawUserKeys {
M internal/infrastructure/config/migrate_test.go +76 -0
@@ -1541,3 +1541,79 @@ desc = "Toggle Favorites in right split"
15411541 assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys)
15421542 assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys)
15431543 }
1544+
1545+func writeMigratorUserConfig(t *testing.T, content string) string {
1546+ t.Helper()
1547+ home := t.TempDir()
1548+ t.Setenv("HOME", home)
1549+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1550+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1551+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1552+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1553+
1554+ configFile, err := GetConfigFile()
1555+ require.NoError(t, err)
1556+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1557+ require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1558+ return configFile
1559+}
1560+
1561+func TestMigrator_DetectChanges_ReportsLegacyOmniboxMaxHistoryDaysRename(t *testing.T) {
1562+ writeMigratorUserConfig(t, "[omnibox]\nmost_visited_days = 0\n")
1563+
1564+ changes, err := NewMigrator().DetectChanges()
1565+ require.NoError(t, err)
1566+
1567+ found := false
1568+ for _, change := range changes {
1569+ if change.Type == port.KeyChangeRenamed &&
1570+ change.OldKey == "omnibox.most_visited_days" &&
1571+ change.NewKey == "omnibox.max_history_days" {
1572+ found = true
1573+ }
1574+ if change.OldKey == "omnibox.most_visited_days" && change.Type == port.KeyChangeRemoved {
1575+ t.Fatal("legacy key must be reported as a rename, not a removal")
1576+ }
1577+ if change.NewKey == "omnibox.max_history_days" && change.Type == port.KeyChangeAdded {
1578+ t.Fatal("migrated key must be reported as a rename, not an addition")
1579+ }
1580+ }
1581+ assert.True(t, found, "expected legacy omnibox.most_visited_days rename to be reported")
1582+}
1583+
1584+func TestMigrator_Migrate_RenamesLegacyOmniboxMaxHistoryDaysPreservingValue(t *testing.T) {
1585+ tests := []struct {
1586+ name string
1587+ value string
1588+ }{
1589+ {name: "zero is preserved", value: "0"},
1590+ {name: "custom value is preserved", value: "7"},
1591+ }
1592+
1593+ for _, tt := range tests {
1594+ t.Run(tt.name, func(t *testing.T) {
1595+ configFile := writeMigratorUserConfig(t, "[omnibox]\nmost_visited_days = "+tt.value+"\n")
1596+
1597+ applied, err := NewMigrator().Migrate()
1598+ require.NoError(t, err)
1599+ assert.Contains(t, applied, "omnibox.most_visited_days -> omnibox.max_history_days")
1600+
1601+ migrated, err := os.ReadFile(configFile)
1602+ require.NoError(t, err)
1603+ assert.Contains(t, string(migrated), "max_history_days = "+tt.value)
1604+ assert.NotContains(t, string(migrated), "most_visited_days")
1605+ })
1606+ }
1607+}
1608+
1609+func TestMigrator_Migrate_KeepsNewOmniboxMaxHistoryDaysOverLegacyKey(t *testing.T) {
1610+ configFile := writeMigratorUserConfig(t, "[omnibox]\nmax_history_days = 7\nmost_visited_days = 99\n")
1611+
1612+ _, err := NewMigrator().Migrate()
1613+ require.NoError(t, err)
1614+
1615+ migrated, err := os.ReadFile(configFile)
1616+ require.NoError(t, err)
1617+ assert.Contains(t, string(migrated), "max_history_days = 7")
1618+ assert.NotContains(t, string(migrated), "most_visited_days")
1619+}
@@ -1541,3 +1541,79 @@ desc = "Toggle Favorites in right split"
1541 assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys)1541 assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys)
1542 assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys)1542 assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys)
1543 }1543 }
1544+
1545+func writeMigratorUserConfig(t *testing.T, content string) string {
1546+ t.Helper()
1547+ home := t.TempDir()
1548+ t.Setenv("HOME", home)
1549+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1550+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1551+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1552+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1553+
1554+ configFile, err := GetConfigFile()
1555+ require.NoError(t, err)
1556+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1557+ require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1558+ return configFile
1559+}
1560+
1561+func TestMigrator_DetectChanges_ReportsLegacyOmniboxMaxHistoryDaysRename(t *testing.T) {
1562+ writeMigratorUserConfig(t, "[omnibox]\nmost_visited_days = 0\n")
1563+
1564+ changes, err := NewMigrator().DetectChanges()
1565+ require.NoError(t, err)
1566+
1567+ found := false
1568+ for _, change := range changes {
1569+ if change.Type == port.KeyChangeRenamed &&
1570+ change.OldKey == "omnibox.most_visited_days" &&
1571+ change.NewKey == "omnibox.max_history_days" {
1572+ found = true
1573+ }
1574+ if change.OldKey == "omnibox.most_visited_days" && change.Type == port.KeyChangeRemoved {
1575+ t.Fatal("legacy key must be reported as a rename, not a removal")
1576+ }
1577+ if change.NewKey == "omnibox.max_history_days" && change.Type == port.KeyChangeAdded {
1578+ t.Fatal("migrated key must be reported as a rename, not an addition")
1579+ }
1580+ }
1581+ assert.True(t, found, "expected legacy omnibox.most_visited_days rename to be reported")
1582+}
1583+
1584+func TestMigrator_Migrate_RenamesLegacyOmniboxMaxHistoryDaysPreservingValue(t *testing.T) {
1585+ tests := []struct {
1586+ name string
1587+ value string
1588+ }{
1589+ {name: "zero is preserved", value: "0"},
1590+ {name: "custom value is preserved", value: "7"},
1591+ }
1592+
1593+ for _, tt := range tests {
1594+ t.Run(tt.name, func(t *testing.T) {
1595+ configFile := writeMigratorUserConfig(t, "[omnibox]\nmost_visited_days = "+tt.value+"\n")
1596+
1597+ applied, err := NewMigrator().Migrate()
1598+ require.NoError(t, err)
1599+ assert.Contains(t, applied, "omnibox.most_visited_days -> omnibox.max_history_days")
1600+
1601+ migrated, err := os.ReadFile(configFile)
1602+ require.NoError(t, err)
1603+ assert.Contains(t, string(migrated), "max_history_days = "+tt.value)
1604+ assert.NotContains(t, string(migrated), "most_visited_days")
1605+ })
1606+ }
1607+}
1608+
1609+func TestMigrator_Migrate_KeepsNewOmniboxMaxHistoryDaysOverLegacyKey(t *testing.T) {
1610+ configFile := writeMigratorUserConfig(t, "[omnibox]\nmax_history_days = 7\nmost_visited_days = 99\n")
1611+
1612+ _, err := NewMigrator().Migrate()
1613+ require.NoError(t, err)
1614+
1615+ migrated, err := os.ReadFile(configFile)
1616+ require.NoError(t, err)
1617+ assert.Contains(t, string(migrated), "max_history_days = 7")
1618+ assert.NotContains(t, string(migrated), "most_visited_days")
1619+}
M internal/infrastructure/config/schema.go +4 -3
@@ -394,10 +394,11 @@ type OmniboxConfig struct {
394394 // InitialBehavior controls what to show when omnibox opens with empty input
395395 // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing)
396396 InitialBehavior OmniboxInitialBehavior `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"`
397- // MostVisitedDays controls how many days of history are considered when initial behavior is most_visited.
398- // 0 means all history.
397+ // MaxHistoryDays controls how many days of history the omnibox considers for
398+ // typed searches and for the empty-input initial list (recent or most_visited).
399+ // 0 means all stored history.
399400 // Default: 30.
400- MostVisitedDays int `mapstructure:"most_visited_days" yaml:"most_visited_days" toml:"most_visited_days"`
401+ MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"`
401402 // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created.
402403 // Default: false
403404 AutoOpenOnNewPane bool `mapstructure:"auto_open_on_new_pane" yaml:"auto_open_on_new_pane" toml:"auto_open_on_new_pane"`
@@ -394,10 +394,11 @@ type OmniboxConfig struct {
394 // InitialBehavior controls what to show when omnibox opens with empty input394 // InitialBehavior controls what to show when omnibox opens with empty input
395 // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing)395 // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing)
396 InitialBehavior OmniboxInitialBehavior `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"`396 InitialBehavior OmniboxInitialBehavior `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"`
397- // MostVisitedDays controls how many days of history are considered when initial behavior is most_visited.397+ // MaxHistoryDays controls how many days of history the omnibox considers for
398- // 0 means all history.398+ // typed searches and for the empty-input initial list (recent or most_visited).
399+ // 0 means all stored history.
399 // Default: 30.400 // Default: 30.
400- MostVisitedDays int `mapstructure:"most_visited_days" yaml:"most_visited_days" toml:"most_visited_days"`401+ MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"`
401 // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created.402 // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created.
402 // Default: false403 // Default: false
403 AutoOpenOnNewPane bool `mapstructure:"auto_open_on_new_pane" yaml:"auto_open_on_new_pane" toml:"auto_open_on_new_pane"`404 AutoOpenOnNewPane bool `mapstructure:"auto_open_on_new_pane" yaml:"auto_open_on_new_pane" toml:"auto_open_on_new_pane"`
M internal/infrastructure/config/schema_provider.go +3 -3
@@ -800,10 +800,10 @@ func (*SchemaProvider) getOmniboxKeys(defaults *Config) []entity.ConfigKeyInfo {
800800 Section: SectionOmnibox,
801801 },
802802 {
803- Key: "omnibox.most_visited_days",
803+ Key: "omnibox.max_history_days",
804804 Type: "int",
805- Default: fmt.Sprintf("%d", defaults.Omnibox.MostVisitedDays),
806- Description: "Days of history to consider for most_visited (0 = all history)",
805+ Default: fmt.Sprintf("%d", defaults.Omnibox.MaxHistoryDays),
806+ Description: "Days of omnibox suggestion history (typed and initial); 0 = all history, never deletes",
807807 Range: ">=0",
808808 Section: SectionOmnibox,
809809 },
@@ -800,10 +800,10 @@ func (*SchemaProvider) getOmniboxKeys(defaults *Config) []entity.ConfigKeyInfo {
800 Section: SectionOmnibox,800 Section: SectionOmnibox,
801 },801 },
802 {802 {
803- Key: "omnibox.most_visited_days",803+ Key: "omnibox.max_history_days",
804 Type: "int",804 Type: "int",
805- Default: fmt.Sprintf("%d", defaults.Omnibox.MostVisitedDays),805+ Default: fmt.Sprintf("%d", defaults.Omnibox.MaxHistoryDays),
806- Description: "Days of history to consider for most_visited (0 = all history)",806+ Description: "Days of omnibox suggestion history (typed and initial); 0 = all history, never deletes",
807 Range: ">=0",807 Range: ">=0",
808 Section: SectionOmnibox,808 Section: SectionOmnibox,
809 },809 },
M internal/infrastructure/config/validation.go +2 -2
@@ -654,8 +654,8 @@ func validateOmnibox(config *Config) []string {
654654 config.Omnibox.Style,
655655 ))
656656 }
657- if config.Omnibox.MostVisitedDays < 0 {
658- validationErrors = append(validationErrors, "omnibox.most_visited_days must be non-negative")
657+ if config.Omnibox.MaxHistoryDays < 0 {
658+ validationErrors = append(validationErrors, "omnibox.max_history_days must be non-negative")
659659 }
660660 switch config.Omnibox.InitialBehavior {
661661 case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone:
@@ -654,8 +654,8 @@ func validateOmnibox(config *Config) []string {
654 config.Omnibox.Style,654 config.Omnibox.Style,
655 ))655 ))
656 }656 }
657- if config.Omnibox.MostVisitedDays < 0 {657+ if config.Omnibox.MaxHistoryDays < 0 {
658- validationErrors = append(validationErrors, "omnibox.most_visited_days must be non-negative")658+ validationErrors = append(validationErrors, "omnibox.max_history_days must be non-negative")
659 }659 }
660 switch config.Omnibox.InitialBehavior {660 switch config.Omnibox.InitialBehavior {
661 case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone:661 case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone:
M internal/infrastructure/config/validation_test.go +23 -0
@@ -482,3 +482,26 @@ func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T)
482482 err := validateConfig(cfg)
483483 require.NoError(t, err)
484484 }
485+
486+func TestValidateConfig_OmniboxMaxHistoryDays(t *testing.T) {
487+ t.Run("default is valid", func(t *testing.T) {
488+ cfg := DefaultConfig()
489+ require.NoError(t, validateConfig(cfg))
490+ })
491+
492+ t.Run("zero means all history and is valid", func(t *testing.T) {
493+ cfg := DefaultConfig()
494+ cfg.Omnibox.MaxHistoryDays = 0
495+ require.NoError(t, validateConfig(cfg))
496+ })
497+
498+ t.Run("negative is rejected", func(t *testing.T) {
499+ cfg := DefaultConfig()
500+ cfg.Omnibox.MaxHistoryDays = -1
501+
502+ err := validateConfig(cfg)
503+
504+ require.Error(t, err)
505+ assert.Contains(t, err.Error(), "omnibox.max_history_days must be non-negative")
506+ })
507+}
@@ -482,3 +482,26 @@ func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T)
482 err := validateConfig(cfg)482 err := validateConfig(cfg)
483 require.NoError(t, err)483 require.NoError(t, err)
484 }484 }
485+
486+func TestValidateConfig_OmniboxMaxHistoryDays(t *testing.T) {
487+ t.Run("default is valid", func(t *testing.T) {
488+ cfg := DefaultConfig()
489+ require.NoError(t, validateConfig(cfg))
490+ })
491+
492+ t.Run("zero means all history and is valid", func(t *testing.T) {
493+ cfg := DefaultConfig()
494+ cfg.Omnibox.MaxHistoryDays = 0
495+ require.NoError(t, validateConfig(cfg))
496+ })
497+
498+ t.Run("negative is rejected", func(t *testing.T) {
499+ cfg := DefaultConfig()
500+ cfg.Omnibox.MaxHistoryDays = -1
501+
502+ err := validateConfig(cfg)
503+
504+ require.Error(t, err)
505+ assert.Contains(t, err.Error(), "omnibox.max_history_days must be non-negative")
506+ })
507+}
M internal/infrastructure/persistence/sqlite/history_repo.go +86 -16
@@ -44,6 +44,30 @@ const (
4444
4545 var expectedFTSFailureCount atomic.Uint64
4646
47+// boundedHistoryCutoff normalizes a real cutoff to UTC at the second precision
48+// SQLite stores in last_visited, so the inclusive `>=` comparison matches an
49+// entry exactly on the cutoff.
50+//
51+// sqlc models the nullable last_visited column comparison as sql.NullTime, so
52+// Valid is always true here: only bounded callers use this helper. Unbounded
53+// recent/most-visited lists use separate queries with no cutoff predicate, and
54+// the FTS path uses historyCutoffParam.
55+func boundedHistoryCutoff(cutoff time.Time) sql.NullTime {
56+ return sql.NullTime{Time: cutoff.UTC().Truncate(time.Second), Valid: true}
57+}
58+
59+// historyCutoffParam returns nil for an unbounded scope and a concrete cutoff
60+// otherwise. It feeds the FTS queries, where MATCH already narrows the
61+// candidate set and a single nullable predicate avoids duplicating the URL and
62+// title variants across bounded and unbounded windows. sqlc types that
63+// predicate as any because the `IS NULL` branch prevents a concrete inference.
64+func historyCutoffParam(scope repository.HistoryScope) any {
65+ if scope.Cutoff.IsZero() {
66+ return nil
67+ }
68+ return boundedHistoryCutoff(scope.Cutoff)
69+}
70+
4771 type historyRepo struct {
4872 db *sql.DB
4973 queries *sqlc.Queries
@@ -115,7 +139,7 @@ func (r *historyRepo) UpdateMetadata(ctx context.Context, entry *entity.HistoryE
115139 return err
116140 }
117141
118-func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
142+func (r *historyRepo) Search(ctx context.Context, query string, limit int, scope repository.HistoryScope) ([]entity.HistoryMatch, error) {
119143 if limit <= 0 {
120144 return []entity.HistoryMatch{}, nil
121145 }
@@ -136,9 +160,10 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
136160 // Use domain-boosted query for URL search to prioritize domain matches
137161 // Use sanitized first word for domain boost (intentional: domain boost targets first search term)
138162 urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{
139- Term: sql.NullString{String: words[0], Valid: true},
140- Query: ftsQuery,
141- Limit: int64(limit),
163+ Term: sql.NullString{String: words[0], Valid: true},
164+ Query: ftsQuery,
165+ Cutoff: historyCutoffParam(scope),
166+ Limit: int64(limit),
142167 })
143168 if urlErr != nil {
144169 logFTSSearchError(ctx, "url", query, urlErr)
@@ -146,8 +171,9 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
146171 }
147172
148173 titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{
149- Query: ftsQuery,
150- Limit: int64(limit),
174+ Query: ftsQuery,
175+ Cutoff: historyCutoffParam(scope),
176+ Limit: int64(limit),
151177 })
152178 if titleErr != nil {
153179 logFTSSearchError(ctx, "title", query, titleErr)
@@ -482,18 +508,32 @@ func isExpectedFTSError(err error) bool {
482508 strings.Contains(msg, "unterminated string")
483509 }
484510
485-func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
511+func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error) {
512+ // SQLite treats a negative LIMIT as "no limit", which preserves the
513+ // historical zero-limit-means-all behavior while applying the age scope.
514+ sqlLimit := int64(limit)
486515 if limit <= 0 {
487- rows, err := r.queries.GetAllRecentHistory(ctx)
488- if err != nil {
489- return nil, err
490- }
491- return historyEntriesFromRows(rows), nil
516+ sqlLimit = -1
517+ }
518+
519+ // Bounded and unbounded windows use separate queries so the bounded path
520+ // compares last_visited directly and keeps the index usable.
521+ var (
522+ rows []sqlc.History
523+ err error
524+ )
525+ if scope.Cutoff.IsZero() {
526+ rows, err = r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
527+ Limit: sqlLimit,
528+ Offset: int64(offset),
529+ })
530+ } else {
531+ rows, err = r.queries.GetRecentHistorySinceCutoff(ctx, sqlc.GetRecentHistorySinceCutoffParams{
532+ Cutoff: boundedHistoryCutoff(scope.Cutoff),
533+ Limit: sqlLimit,
534+ Offset: int64(offset),
535+ })
492536 }
493- rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
494- Limit: int64(limit),
495- Offset: int64(offset),
496- })
497537 if err != nil {
498538 return nil, err
499539 }
@@ -612,6 +652,36 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE
612652 return historyEntriesFromRows(rows), nil
613653 }
614654
655+func (r *historyRepo) GetMostVisitedWithin(
656+ ctx context.Context,
657+ limit int,
658+ scope repository.HistoryScope,
659+) ([]*entity.HistoryEntry, error) {
660+ if limit <= 0 {
661+ return []*entity.HistoryEntry{}, nil
662+ }
663+
664+ // Bounded and unbounded windows use separate queries so the bounded path
665+ // compares last_visited directly and keeps the index usable.
666+ var (
667+ rows []sqlc.History
668+ err error
669+ )
670+ if scope.Cutoff.IsZero() {
671+ rows, err = r.queries.GetMostVisitedHistory(ctx, int64(limit))
672+ } else {
673+ rows, err = r.queries.GetMostVisitedHistorySinceCutoff(ctx, sqlc.GetMostVisitedHistorySinceCutoffParams{
674+ Cutoff: boundedHistoryCutoff(scope.Cutoff),
675+ Limit: int64(limit),
676+ })
677+ }
678+ if err != nil {
679+ return nil, err
680+ }
681+
682+ return historyEntriesFromRows(rows), nil
683+}
684+
615685 func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {
616686 // Skip incrementing about:blank - it should always have visit_count = 1
617687 if rawURL == aboutBlankURL {
@@ -44,6 +44,30 @@ const (
44 44
45 var expectedFTSFailureCount atomic.Uint6445 var expectedFTSFailureCount atomic.Uint64
46 46
47+// boundedHistoryCutoff normalizes a real cutoff to UTC at the second precision
48+// SQLite stores in last_visited, so the inclusive `>=` comparison matches an
49+// entry exactly on the cutoff.
50+//
51+// sqlc models the nullable last_visited column comparison as sql.NullTime, so
52+// Valid is always true here: only bounded callers use this helper. Unbounded
53+// recent/most-visited lists use separate queries with no cutoff predicate, and
54+// the FTS path uses historyCutoffParam.
55+func boundedHistoryCutoff(cutoff time.Time) sql.NullTime {
56+ return sql.NullTime{Time: cutoff.UTC().Truncate(time.Second), Valid: true}
57+}
58+
59+// historyCutoffParam returns nil for an unbounded scope and a concrete cutoff
60+// otherwise. It feeds the FTS queries, where MATCH already narrows the
61+// candidate set and a single nullable predicate avoids duplicating the URL and
62+// title variants across bounded and unbounded windows. sqlc types that
63+// predicate as any because the `IS NULL` branch prevents a concrete inference.
64+func historyCutoffParam(scope repository.HistoryScope) any {
65+ if scope.Cutoff.IsZero() {
66+ return nil
67+ }
68+ return boundedHistoryCutoff(scope.Cutoff)
69+}
70+
47 type historyRepo struct {71 type historyRepo struct {
48 db *sql.DB72 db *sql.DB
49 queries *sqlc.Queries73 queries *sqlc.Queries
@@ -115,7 +139,7 @@ func (r *historyRepo) UpdateMetadata(ctx context.Context, entry *entity.HistoryE
115 return err139 return err
116 }140 }
117 141
118-func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {142+func (r *historyRepo) Search(ctx context.Context, query string, limit int, scope repository.HistoryScope) ([]entity.HistoryMatch, error) {
119 if limit <= 0 {143 if limit <= 0 {
120 return []entity.HistoryMatch{}, nil144 return []entity.HistoryMatch{}, nil
121 }145 }
@@ -136,9 +160,10 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
136 // Use domain-boosted query for URL search to prioritize domain matches160 // Use domain-boosted query for URL search to prioritize domain matches
137 // Use sanitized first word for domain boost (intentional: domain boost targets first search term)161 // Use sanitized first word for domain boost (intentional: domain boost targets first search term)
138 urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{162 urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{
139- Term: sql.NullString{String: words[0], Valid: true},163+ Term: sql.NullString{String: words[0], Valid: true},
140- Query: ftsQuery,164+ Query: ftsQuery,
141- Limit: int64(limit),165+ Cutoff: historyCutoffParam(scope),
166+ Limit: int64(limit),
142 })167 })
143 if urlErr != nil {168 if urlErr != nil {
144 logFTSSearchError(ctx, "url", query, urlErr)169 logFTSSearchError(ctx, "url", query, urlErr)
@@ -146,8 +171,9 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
146 }171 }
147 172
148 titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{173 titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{
149- Query: ftsQuery,174+ Query: ftsQuery,
150- Limit: int64(limit),175+ Cutoff: historyCutoffParam(scope),
176+ Limit: int64(limit),
151 })177 })
152 if titleErr != nil {178 if titleErr != nil {
153 logFTSSearchError(ctx, "title", query, titleErr)179 logFTSSearchError(ctx, "title", query, titleErr)
@@ -482,18 +508,32 @@ func isExpectedFTSError(err error) bool {
482 strings.Contains(msg, "unterminated string")508 strings.Contains(msg, "unterminated string")
483 }509 }
484 510
485-func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {511+func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error) {
512+ // SQLite treats a negative LIMIT as "no limit", which preserves the
513+ // historical zero-limit-means-all behavior while applying the age scope.
514+ sqlLimit := int64(limit)
486 if limit <= 0 {515 if limit <= 0 {
487- rows, err := r.queries.GetAllRecentHistory(ctx)516+ sqlLimit = -1
488- if err != nil {517+ }
489- return nil, err518+
490- }519+ // Bounded and unbounded windows use separate queries so the bounded path
491- return historyEntriesFromRows(rows), nil520+ // compares last_visited directly and keeps the index usable.
521+ var (
522+ rows []sqlc.History
523+ err error
524+ )
525+ if scope.Cutoff.IsZero() {
526+ rows, err = r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
527+ Limit: sqlLimit,
528+ Offset: int64(offset),
529+ })
530+ } else {
531+ rows, err = r.queries.GetRecentHistorySinceCutoff(ctx, sqlc.GetRecentHistorySinceCutoffParams{
532+ Cutoff: boundedHistoryCutoff(scope.Cutoff),
533+ Limit: sqlLimit,
534+ Offset: int64(offset),
535+ })
492 }536 }
493- rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
494- Limit: int64(limit),
495- Offset: int64(offset),
496- })
497 if err != nil {537 if err != nil {
498 return nil, err538 return nil, err
499 }539 }
@@ -612,6 +652,36 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE
612 return historyEntriesFromRows(rows), nil652 return historyEntriesFromRows(rows), nil
613 }653 }
614 654
655+func (r *historyRepo) GetMostVisitedWithin(
656+ ctx context.Context,
657+ limit int,
658+ scope repository.HistoryScope,
659+) ([]*entity.HistoryEntry, error) {
660+ if limit <= 0 {
661+ return []*entity.HistoryEntry{}, nil
662+ }
663+
664+ // Bounded and unbounded windows use separate queries so the bounded path
665+ // compares last_visited directly and keeps the index usable.
666+ var (
667+ rows []sqlc.History
668+ err error
669+ )
670+ if scope.Cutoff.IsZero() {
671+ rows, err = r.queries.GetMostVisitedHistory(ctx, int64(limit))
672+ } else {
673+ rows, err = r.queries.GetMostVisitedHistorySinceCutoff(ctx, sqlc.GetMostVisitedHistorySinceCutoffParams{
674+ Cutoff: boundedHistoryCutoff(scope.Cutoff),
675+ Limit: int64(limit),
676+ })
677+ }
678+ if err != nil {
679+ return nil, err
680+ }
681+
682+ return historyEntriesFromRows(rows), nil
683+}
684+
615 func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {685 func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {
616 // Skip incrementing about:blank - it should always have visit_count = 1686 // Skip incrementing about:blank - it should always have visit_count = 1
617 if rawURL == aboutBlankURL {687 if rawURL == aboutBlankURL {
M internal/infrastructure/persistence/sqlite/history_repo_benchmark_test.go +301 -24
@@ -1,70 +1,347 @@
11 package sqlite_test
22
33 import (
4+ "context"
5+ "database/sql"
46 "fmt"
57 "path/filepath"
8+ "strings"
69 "testing"
10+ "time"
711
8- "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/application/usecase"
913 "github.com/bnema/dumber/internal/domain/repository"
1014 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
15+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
1116 )
1217
13-// newHistoryBenchmarkRepository creates a representative local history corpus
14-// outside the measured operation. Sidebar reads use GetRecent and omnibox
15-// history suggestions use Search.
16-func newHistoryBenchmarkRepository(b *testing.B) (repository.HistoryRepository, func()) {
18+const (
19+ // historyBenchmarkCorpusSize is the number of rows seeded for the
20+ // recent/search persistence benchmarks.
21+ historyBenchmarkCorpusSize = 200
22+ // historyBenchmarkEligibleRows is the number of rows left inside the bounded
23+ // window; the remainder are stale so bounded benchmarks still exercise a
24+ // large history with few eligible rows.
25+ historyBenchmarkEligibleRows = 40
26+ // historyBenchmarkStaleRows is the number of stale, high-visit-count rows
27+ // seeded for bounded most-visited benchmarks. They outrank every eligible
28+ // row, so an unfiltered most-visited scan would never reach the eligible rows
29+ // within the result limit.
30+ historyBenchmarkStaleRows = 2000
31+
32+ historyBenchmarkRecentLimit = 50
33+ historyBenchmarkSearchLimit = 10
34+ historyBenchmarkMostVisitedLimit = 10
35+ historyBenchmarkMaxAgeDays = 30
36+ // historyBenchmarkStaleAgeDays places corpus rows well outside the window.
37+ historyBenchmarkStaleAgeDays = 400
38+)
39+
40+// historyBenchmarkRow is one explicitly categorized corpus row. Category is
41+// encoded in the URL, title, and timestamp rather than an AUTOINCREMENT id so
42+// benchmarks do not depend on insertion order.
43+type historyBenchmarkRow struct {
44+ url string
45+ title string
46+ lastVisited time.Time
47+ visitCount int
48+}
49+
50+// historyBenchmarkURL returns the stable URL for corpus row i.
51+func historyBenchmarkURL(i int) string {
52+ return fmt.Sprintf("https://example%d.test/dumber/history/%d", i%20, i)
53+}
54+
55+// newHistoryBenchmarkDB opens an isolated benchmark database and registers
56+// cleanup.
57+func newHistoryBenchmarkDB(b *testing.B) *sql.DB {
1758 b.Helper()
1859
19- ctx := historyTestCtx()
20- db, err := sqlite.NewConnection(ctx, filepath.Join(b.TempDir(), "dumber.db"))
60+ db, err := sqlite.NewConnection(historyTestCtx(), filepath.Join(b.TempDir(), "dumber.db"))
2161 if err != nil {
2262 b.Fatal(err)
2363 }
24-
2564 b.Cleanup(func() {
26- if err := db.Close(); err != nil {
27- b.Errorf("close benchmark database: %v", err)
65+ if closeErr := db.Close(); closeErr != nil {
66+ b.Errorf("close benchmark database: %v", closeErr)
2867 }
2968 })
3069
31- repo := sqlite.NewHistoryRepository(db)
32- for i := range 200 {
33- entry := &entity.HistoryEntry{
34- URL: fmt.Sprintf("https://example%d.test/dumber/history/%d", i%20, i),
35- Title: fmt.Sprintf("Dumber history entry %d", i),
36- }
37- if err := repo.Save(ctx, entry); err != nil {
70+ return db
71+}
72+
73+// seedHistoryBenchmarkRows inserts corpus rows in a single transaction without
74+// going through the logging repository path.
75+func seedHistoryBenchmarkRows(b *testing.B, ctx context.Context, db *sql.DB, rows []historyBenchmarkRow) {
76+ b.Helper()
77+
78+ tx, err := db.BeginTx(ctx, nil)
79+ if err != nil {
80+ b.Fatal(err)
81+ }
82+ stmt, err := tx.PrepareContext(ctx, `INSERT INTO history (url, title, last_visited, visit_count) VALUES (?, ?, ?, ?)`)
83+ if err != nil {
84+ _ = tx.Rollback()
85+ b.Fatal(err)
86+ }
87+ defer func() { _ = stmt.Close() }()
88+
89+ for _, row := range rows {
90+ if _, err := stmt.ExecContext(
91+ ctx,
92+ row.url,
93+ row.title,
94+ sqliteTimestamp(row.lastVisited),
95+ row.visitCount,
96+ ); err != nil {
97+ _ = tx.Rollback()
3898 b.Fatal(err)
3999 }
40100 }
101+ if err := tx.Commit(); err != nil {
102+ b.Fatal(err)
103+ }
104+}
105+
106+// historyBenchmarkCorpusRows builds historyBenchmarkCorpusSize rows with the
107+// first eligibleRows rows recent and the rest stale.
108+func historyBenchmarkCorpusRows(now time.Time, eligibleRows int) []historyBenchmarkRow {
109+ recent := now.Add(-time.Hour)
110+ stale := now.AddDate(0, 0, -historyBenchmarkStaleAgeDays)
111+
112+ rows := make([]historyBenchmarkRow, 0, historyBenchmarkCorpusSize)
113+ for i := range historyBenchmarkCorpusSize {
114+ lastVisited := stale
115+ if i < eligibleRows {
116+ lastVisited = recent
117+ }
118+ rows = append(rows, historyBenchmarkRow{
119+ url: historyBenchmarkURL(i),
120+ title: fmt.Sprintf("Dumber history entry %d", i),
121+ lastVisited: lastVisited,
122+ visitCount: 1,
123+ })
124+ }
125+ return rows
126+}
127+
128+// newHistoryBenchmarkRepository seeds an unbounded corpus where every row is
129+// inside the window.
130+func newHistoryBenchmarkRepository(b *testing.B) repository.HistoryRepository {
131+ b.Helper()
132+
133+ db := newHistoryBenchmarkDB(b)
134+ seedHistoryBenchmarkRows(b, historyTestCtx(), db, historyBenchmarkCorpusRows(time.Now().UTC(), historyBenchmarkCorpusSize))
41135
42- return repo, func() {}
136+ return sqlite.NewHistoryRepository(db)
137+}
138+
139+// newBoundedHistoryBenchmarkRepository seeds a corpus where only
140+// historyBenchmarkEligibleRows rows fall inside the window, and returns the
141+// cutoff for the bounded repository paths.
142+func newBoundedHistoryBenchmarkRepository(b *testing.B) (repository.HistoryRepository, time.Time) {
143+ b.Helper()
144+
145+ now := time.Now().UTC()
146+ db := newHistoryBenchmarkDB(b)
147+ seedHistoryBenchmarkRows(b, historyTestCtx(), db, historyBenchmarkCorpusRows(now, historyBenchmarkEligibleRows))
148+
149+ return sqlite.NewHistoryRepository(db), now.AddDate(0, 0, -historyBenchmarkMaxAgeDays)
150+}
151+
152+// newMostVisitedBenchmarkUseCase seeds thousands of stale high-count rows plus
153+// eligibleRows recent low-count rows, then returns the application use case so
154+// benchmarks exercise the real omnibox most-visited path.
155+func newMostVisitedBenchmarkUseCase(b *testing.B, eligibleRows int) *usecase.SearchHistoryUseCase {
156+ b.Helper()
157+
158+ now := time.Now().UTC()
159+ db := newHistoryBenchmarkDB(b)
160+
161+ rows := make([]historyBenchmarkRow, 0, historyBenchmarkStaleRows+eligibleRows)
162+ for i := range historyBenchmarkStaleRows {
163+ rows = append(rows, historyBenchmarkRow{
164+ url: fmt.Sprintf("https://stale%d.test/page", i),
165+ title: fmt.Sprintf("Stale %d", i),
166+ lastVisited: now.AddDate(0, 0, -historyBenchmarkStaleAgeDays),
167+ visitCount: 10_000 + i,
168+ })
169+ }
170+ for i := range eligibleRows {
171+ rows = append(rows, historyBenchmarkRow{
172+ url: fmt.Sprintf("https://eligible%d.test/page", i),
173+ title: fmt.Sprintf("Eligible %d", i),
174+ lastVisited: now.Add(-time.Hour),
175+ visitCount: 1 + i,
176+ })
177+ }
178+ seedHistoryBenchmarkRows(b, historyTestCtx(), db, rows)
179+
180+ logBoundedMostVisitedPlan(b, historyTestCtx(), db, now.AddDate(0, 0, -historyBenchmarkMaxAgeDays))
181+
182+ return usecase.NewSearchHistoryUseCase(sqlite.NewHistoryRepository(db))
183+}
184+
185+// requireBenchmarkLen fails the benchmark if a fixture or query regression
186+// changes the expected result count, so benchmarks never silently measure empty
187+// work.
188+func requireBenchmarkLen(b *testing.B, got, want int, label string) {
189+ b.Helper()
190+
191+ if got != want {
192+ b.Fatalf("%s: got %d results, want %d", label, got, want)
193+ }
194+}
195+
196+// logBoundedMostVisitedPlan reports (without asserting) the planner choice for
197+// the bounded most-visited query, which is informational and not part of the
198+// contract.
199+func logBoundedMostVisitedPlan(b *testing.B, ctx context.Context, db *sql.DB, cutoff time.Time) {
200+ b.Helper()
201+
202+ rows, err := db.QueryContext(
203+ ctx,
204+ "EXPLAIN QUERY PLAN "+sqlc.GetMostVisitedHistorySinceCutoff,
205+ sqliteTimestamp(cutoff),
206+ historyBenchmarkMostVisitedLimit,
207+ )
208+ if err != nil {
209+ b.Logf("bounded most-visited explain failed: %v", err)
210+ return
211+ }
212+ defer func() { _ = rows.Close() }()
213+
214+ var details []string
215+ for rows.Next() {
216+ var id, parent, notUsed int
217+ var detail string
218+ if err := rows.Scan(&id, &parent, &notUsed, &detail); err != nil {
219+ b.Logf("bounded most-visited explain scan failed: %v", err)
220+ return
221+ }
222+ details = append(details, detail)
223+ }
224+ b.Logf("bounded most-visited plan: %s", strings.Join(details, "; "))
43225 }
44226
45227 func BenchmarkHistorySQLiteSidebarRecent(b *testing.B) {
46- repo, closeDB := newHistoryBenchmarkRepository(b)
47- defer closeDB()
228+ repo := newHistoryBenchmarkRepository(b)
48229 ctx := historyTestCtx()
49230
231+ results, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, repository.HistoryScope{})
232+ if err != nil {
233+ b.Fatal(err)
234+ }
235+ requireBenchmarkLen(b, len(results), historyBenchmarkRecentLimit, "unbounded recent")
236+
237+ b.ReportAllocs()
238+ b.ResetTimer()
239+ for range b.N {
240+ if _, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, repository.HistoryScope{}); err != nil {
241+ b.Fatal(err)
242+ }
243+ }
244+}
245+
246+func BenchmarkHistorySQLiteSidebarRecentBounded(b *testing.B) {
247+ repo, cutoff := newBoundedHistoryBenchmarkRepository(b)
248+ ctx := historyTestCtx()
249+ scope := repository.HistoryScope{Cutoff: cutoff}
250+
251+ results, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, scope)
252+ if err != nil {
253+ b.Fatal(err)
254+ }
255+ requireBenchmarkLen(b, len(results), historyBenchmarkEligibleRows, "bounded recent")
256+
257+ b.ReportAllocs()
258+ b.ResetTimer()
259+ for range b.N {
260+ if _, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, scope); err != nil {
261+ b.Fatal(err)
262+ }
263+ }
264+}
265+
266+// BenchmarkHistorySQLiteOmniboxMostVisitedBoundedFewEligible measures the
267+// bounded most-visited path when only a handful of rows fall inside the window
268+// despite thousands of higher-count stale rows.
269+func BenchmarkHistorySQLiteOmniboxMostVisitedBoundedFewEligible(b *testing.B) {
270+ const eligibleRows = 5
271+
272+ uc := newMostVisitedBenchmarkUseCase(b, eligibleRows)
273+ ctx := historyTestCtx()
274+
275+ results, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays)
276+ if err != nil {
277+ b.Fatal(err)
278+ }
279+ requireBenchmarkLen(b, len(results), eligibleRows, "bounded most-visited few eligible")
280+
281+ b.ReportAllocs()
282+ b.ResetTimer()
283+ for range b.N {
284+ if _, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays); err != nil {
285+ b.Fatal(err)
286+ }
287+ }
288+}
289+
290+// BenchmarkHistorySQLiteOmniboxMostVisitedBoundedNoEligible measures the
291+// bounded most-visited path when no row falls inside the window.
292+func BenchmarkHistorySQLiteOmniboxMostVisitedBoundedNoEligible(b *testing.B) {
293+ uc := newMostVisitedBenchmarkUseCase(b, 0)
294+ ctx := historyTestCtx()
295+
296+ results, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays)
297+ if err != nil {
298+ b.Fatal(err)
299+ }
300+ requireBenchmarkLen(b, len(results), 0, "bounded most-visited no eligible")
301+
50302 b.ReportAllocs()
51303 b.ResetTimer()
52304 for range b.N {
53- if _, err := repo.GetRecent(ctx, 50, 0); err != nil {
305+ if _, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays); err != nil {
54306 b.Fatal(err)
55307 }
56308 }
57309 }
58310
59311 func BenchmarkHistorySQLiteOmniboxSearch(b *testing.B) {
60- repo, closeDB := newHistoryBenchmarkRepository(b)
61- defer closeDB()
312+ repo := newHistoryBenchmarkRepository(b)
62313 ctx := historyTestCtx()
63314
315+ results, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, repository.HistoryScope{})
316+ if err != nil {
317+ b.Fatal(err)
318+ }
319+ requireBenchmarkLen(b, len(results), historyBenchmarkSearchLimit, "unbounded search")
320+
321+ b.ReportAllocs()
322+ b.ResetTimer()
323+ for range b.N {
324+ if _, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, repository.HistoryScope{}); err != nil {
325+ b.Fatal(err)
326+ }
327+ }
328+}
329+
330+func BenchmarkHistorySQLiteOmniboxSearchBounded(b *testing.B) {
331+ repo, cutoff := newBoundedHistoryBenchmarkRepository(b)
332+ ctx := historyTestCtx()
333+ scope := repository.HistoryScope{Cutoff: cutoff}
334+
335+ results, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, scope)
336+ if err != nil {
337+ b.Fatal(err)
338+ }
339+ requireBenchmarkLen(b, len(results), historyBenchmarkSearchLimit, "bounded search")
340+
64341 b.ReportAllocs()
65342 b.ResetTimer()
66343 for range b.N {
67- if _, err := repo.Search(ctx, "dumber history", 10); err != nil {
344+ if _, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, scope); err != nil {
68345 b.Fatal(err)
69346 }
70347 }
@@ -1,70 +1,347 @@
1 package sqlite_test1 package sqlite_test
2 2
3 import (3 import (
4+ "context"
5+ "database/sql"
4 "fmt"6 "fmt"
5 "path/filepath"7 "path/filepath"
8+ "strings"
6 "testing"9 "testing"
10+ "time"
7 11
8- "github.com/bnema/dumber/internal/domain/entity"12+ "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/repository"13 "github.com/bnema/dumber/internal/domain/repository"
10 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"14 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
15+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
11 )16 )
12 17
13-// newHistoryBenchmarkRepository creates a representative local history corpus18+const (
14-// outside the measured operation. Sidebar reads use GetRecent and omnibox19+ // historyBenchmarkCorpusSize is the number of rows seeded for the
15-// history suggestions use Search.20+ // recent/search persistence benchmarks.
16-func newHistoryBenchmarkRepository(b *testing.B) (repository.HistoryRepository, func()) {21+ historyBenchmarkCorpusSize = 200
22+ // historyBenchmarkEligibleRows is the number of rows left inside the bounded
23+ // window; the remainder are stale so bounded benchmarks still exercise a
24+ // large history with few eligible rows.
25+ historyBenchmarkEligibleRows = 40
26+ // historyBenchmarkStaleRows is the number of stale, high-visit-count rows
27+ // seeded for bounded most-visited benchmarks. They outrank every eligible
28+ // row, so an unfiltered most-visited scan would never reach the eligible rows
29+ // within the result limit.
30+ historyBenchmarkStaleRows = 2000
31+
32+ historyBenchmarkRecentLimit = 50
33+ historyBenchmarkSearchLimit = 10
34+ historyBenchmarkMostVisitedLimit = 10
35+ historyBenchmarkMaxAgeDays = 30
36+ // historyBenchmarkStaleAgeDays places corpus rows well outside the window.
37+ historyBenchmarkStaleAgeDays = 400
38+)
39+
40+// historyBenchmarkRow is one explicitly categorized corpus row. Category is
41+// encoded in the URL, title, and timestamp rather than an AUTOINCREMENT id so
42+// benchmarks do not depend on insertion order.
43+type historyBenchmarkRow struct {
44+ url string
45+ title string
46+ lastVisited time.Time
47+ visitCount int
48+}
49+
50+// historyBenchmarkURL returns the stable URL for corpus row i.
51+func historyBenchmarkURL(i int) string {
52+ return fmt.Sprintf("https://example%d.test/dumber/history/%d", i%20, i)
53+}
54+
55+// newHistoryBenchmarkDB opens an isolated benchmark database and registers
56+// cleanup.
57+func newHistoryBenchmarkDB(b *testing.B) *sql.DB {
17 b.Helper()58 b.Helper()
18 59
19- ctx := historyTestCtx()60+ db, err := sqlite.NewConnection(historyTestCtx(), filepath.Join(b.TempDir(), "dumber.db"))
20- db, err := sqlite.NewConnection(ctx, filepath.Join(b.TempDir(), "dumber.db"))
21 if err != nil {61 if err != nil {
22 b.Fatal(err)62 b.Fatal(err)
23 }63 }
24-
25 b.Cleanup(func() {64 b.Cleanup(func() {
26- if err := db.Close(); err != nil {65+ if closeErr := db.Close(); closeErr != nil {
27- b.Errorf("close benchmark database: %v", err)66+ b.Errorf("close benchmark database: %v", closeErr)
28 }67 }
29 })68 })
30 69
31- repo := sqlite.NewHistoryRepository(db)70+ return db
32- for i := range 200 {71+}
33- entry := &entity.HistoryEntry{72+
34- URL: fmt.Sprintf("https://example%d.test/dumber/history/%d", i%20, i),73+// seedHistoryBenchmarkRows inserts corpus rows in a single transaction without
35- Title: fmt.Sprintf("Dumber history entry %d", i),74+// going through the logging repository path.
36- }75+func seedHistoryBenchmarkRows(b *testing.B, ctx context.Context, db *sql.DB, rows []historyBenchmarkRow) {
37- if err := repo.Save(ctx, entry); err != nil {76+ b.Helper()
77+
78+ tx, err := db.BeginTx(ctx, nil)
79+ if err != nil {
80+ b.Fatal(err)
81+ }
82+ stmt, err := tx.PrepareContext(ctx, `INSERT INTO history (url, title, last_visited, visit_count) VALUES (?, ?, ?, ?)`)
83+ if err != nil {
84+ _ = tx.Rollback()
85+ b.Fatal(err)
86+ }
87+ defer func() { _ = stmt.Close() }()
88+
89+ for _, row := range rows {
90+ if _, err := stmt.ExecContext(
91+ ctx,
92+ row.url,
93+ row.title,
94+ sqliteTimestamp(row.lastVisited),
95+ row.visitCount,
96+ ); err != nil {
97+ _ = tx.Rollback()
38 b.Fatal(err)98 b.Fatal(err)
39 }99 }
40 }100 }
101+ if err := tx.Commit(); err != nil {
102+ b.Fatal(err)
103+ }
104+}
105+
106+// historyBenchmarkCorpusRows builds historyBenchmarkCorpusSize rows with the
107+// first eligibleRows rows recent and the rest stale.
108+func historyBenchmarkCorpusRows(now time.Time, eligibleRows int) []historyBenchmarkRow {
109+ recent := now.Add(-time.Hour)
110+ stale := now.AddDate(0, 0, -historyBenchmarkStaleAgeDays)
111+
112+ rows := make([]historyBenchmarkRow, 0, historyBenchmarkCorpusSize)
113+ for i := range historyBenchmarkCorpusSize {
114+ lastVisited := stale
115+ if i < eligibleRows {
116+ lastVisited = recent
117+ }
118+ rows = append(rows, historyBenchmarkRow{
119+ url: historyBenchmarkURL(i),
120+ title: fmt.Sprintf("Dumber history entry %d", i),
121+ lastVisited: lastVisited,
122+ visitCount: 1,
123+ })
124+ }
125+ return rows
126+}
127+
128+// newHistoryBenchmarkRepository seeds an unbounded corpus where every row is
129+// inside the window.
130+func newHistoryBenchmarkRepository(b *testing.B) repository.HistoryRepository {
131+ b.Helper()
132+
133+ db := newHistoryBenchmarkDB(b)
134+ seedHistoryBenchmarkRows(b, historyTestCtx(), db, historyBenchmarkCorpusRows(time.Now().UTC(), historyBenchmarkCorpusSize))
41 135
42- return repo, func() {}136+ return sqlite.NewHistoryRepository(db)
137+}
138+
139+// newBoundedHistoryBenchmarkRepository seeds a corpus where only
140+// historyBenchmarkEligibleRows rows fall inside the window, and returns the
141+// cutoff for the bounded repository paths.
142+func newBoundedHistoryBenchmarkRepository(b *testing.B) (repository.HistoryRepository, time.Time) {
143+ b.Helper()
144+
145+ now := time.Now().UTC()
146+ db := newHistoryBenchmarkDB(b)
147+ seedHistoryBenchmarkRows(b, historyTestCtx(), db, historyBenchmarkCorpusRows(now, historyBenchmarkEligibleRows))
148+
149+ return sqlite.NewHistoryRepository(db), now.AddDate(0, 0, -historyBenchmarkMaxAgeDays)
150+}
151+
152+// newMostVisitedBenchmarkUseCase seeds thousands of stale high-count rows plus
153+// eligibleRows recent low-count rows, then returns the application use case so
154+// benchmarks exercise the real omnibox most-visited path.
155+func newMostVisitedBenchmarkUseCase(b *testing.B, eligibleRows int) *usecase.SearchHistoryUseCase {
156+ b.Helper()
157+
158+ now := time.Now().UTC()
159+ db := newHistoryBenchmarkDB(b)
160+
161+ rows := make([]historyBenchmarkRow, 0, historyBenchmarkStaleRows+eligibleRows)
162+ for i := range historyBenchmarkStaleRows {
163+ rows = append(rows, historyBenchmarkRow{
164+ url: fmt.Sprintf("https://stale%d.test/page", i),
165+ title: fmt.Sprintf("Stale %d", i),
166+ lastVisited: now.AddDate(0, 0, -historyBenchmarkStaleAgeDays),
167+ visitCount: 10_000 + i,
168+ })
169+ }
170+ for i := range eligibleRows {
171+ rows = append(rows, historyBenchmarkRow{
172+ url: fmt.Sprintf("https://eligible%d.test/page", i),
173+ title: fmt.Sprintf("Eligible %d", i),
174+ lastVisited: now.Add(-time.Hour),
175+ visitCount: 1 + i,
176+ })
177+ }
178+ seedHistoryBenchmarkRows(b, historyTestCtx(), db, rows)
179+
180+ logBoundedMostVisitedPlan(b, historyTestCtx(), db, now.AddDate(0, 0, -historyBenchmarkMaxAgeDays))
181+
182+ return usecase.NewSearchHistoryUseCase(sqlite.NewHistoryRepository(db))
183+}
184+
185+// requireBenchmarkLen fails the benchmark if a fixture or query regression
186+// changes the expected result count, so benchmarks never silently measure empty
187+// work.
188+func requireBenchmarkLen(b *testing.B, got, want int, label string) {
189+ b.Helper()
190+
191+ if got != want {
192+ b.Fatalf("%s: got %d results, want %d", label, got, want)
193+ }
194+}
195+
196+// logBoundedMostVisitedPlan reports (without asserting) the planner choice for
197+// the bounded most-visited query, which is informational and not part of the
198+// contract.
199+func logBoundedMostVisitedPlan(b *testing.B, ctx context.Context, db *sql.DB, cutoff time.Time) {
200+ b.Helper()
201+
202+ rows, err := db.QueryContext(
203+ ctx,
204+ "EXPLAIN QUERY PLAN "+sqlc.GetMostVisitedHistorySinceCutoff,
205+ sqliteTimestamp(cutoff),
206+ historyBenchmarkMostVisitedLimit,
207+ )
208+ if err != nil {
209+ b.Logf("bounded most-visited explain failed: %v", err)
210+ return
211+ }
212+ defer func() { _ = rows.Close() }()
213+
214+ var details []string
215+ for rows.Next() {
216+ var id, parent, notUsed int
217+ var detail string
218+ if err := rows.Scan(&id, &parent, &notUsed, &detail); err != nil {
219+ b.Logf("bounded most-visited explain scan failed: %v", err)
220+ return
221+ }
222+ details = append(details, detail)
223+ }
224+ b.Logf("bounded most-visited plan: %s", strings.Join(details, "; "))
43 }225 }
44 226
45 func BenchmarkHistorySQLiteSidebarRecent(b *testing.B) {227 func BenchmarkHistorySQLiteSidebarRecent(b *testing.B) {
46- repo, closeDB := newHistoryBenchmarkRepository(b)228+ repo := newHistoryBenchmarkRepository(b)
47- defer closeDB()
48 ctx := historyTestCtx()229 ctx := historyTestCtx()
49 230
231+ results, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, repository.HistoryScope{})
232+ if err != nil {
233+ b.Fatal(err)
234+ }
235+ requireBenchmarkLen(b, len(results), historyBenchmarkRecentLimit, "unbounded recent")
236+
237+ b.ReportAllocs()
238+ b.ResetTimer()
239+ for range b.N {
240+ if _, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, repository.HistoryScope{}); err != nil {
241+ b.Fatal(err)
242+ }
243+ }
244+}
245+
246+func BenchmarkHistorySQLiteSidebarRecentBounded(b *testing.B) {
247+ repo, cutoff := newBoundedHistoryBenchmarkRepository(b)
248+ ctx := historyTestCtx()
249+ scope := repository.HistoryScope{Cutoff: cutoff}
250+
251+ results, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, scope)
252+ if err != nil {
253+ b.Fatal(err)
254+ }
255+ requireBenchmarkLen(b, len(results), historyBenchmarkEligibleRows, "bounded recent")
256+
257+ b.ReportAllocs()
258+ b.ResetTimer()
259+ for range b.N {
260+ if _, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, scope); err != nil {
261+ b.Fatal(err)
262+ }
263+ }
264+}
265+
266+// BenchmarkHistorySQLiteOmniboxMostVisitedBoundedFewEligible measures the
267+// bounded most-visited path when only a handful of rows fall inside the window
268+// despite thousands of higher-count stale rows.
269+func BenchmarkHistorySQLiteOmniboxMostVisitedBoundedFewEligible(b *testing.B) {
270+ const eligibleRows = 5
271+
272+ uc := newMostVisitedBenchmarkUseCase(b, eligibleRows)
273+ ctx := historyTestCtx()
274+
275+ results, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays)
276+ if err != nil {
277+ b.Fatal(err)
278+ }
279+ requireBenchmarkLen(b, len(results), eligibleRows, "bounded most-visited few eligible")
280+
281+ b.ReportAllocs()
282+ b.ResetTimer()
283+ for range b.N {
284+ if _, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays); err != nil {
285+ b.Fatal(err)
286+ }
287+ }
288+}
289+
290+// BenchmarkHistorySQLiteOmniboxMostVisitedBoundedNoEligible measures the
291+// bounded most-visited path when no row falls inside the window.
292+func BenchmarkHistorySQLiteOmniboxMostVisitedBoundedNoEligible(b *testing.B) {
293+ uc := newMostVisitedBenchmarkUseCase(b, 0)
294+ ctx := historyTestCtx()
295+
296+ results, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays)
297+ if err != nil {
298+ b.Fatal(err)
299+ }
300+ requireBenchmarkLen(b, len(results), 0, "bounded most-visited no eligible")
301+
50 b.ReportAllocs()302 b.ReportAllocs()
51 b.ResetTimer()303 b.ResetTimer()
52 for range b.N {304 for range b.N {
53- if _, err := repo.GetRecent(ctx, 50, 0); err != nil {305+ if _, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays); err != nil {
54 b.Fatal(err)306 b.Fatal(err)
55 }307 }
56 }308 }
57 }309 }
58 310
59 func BenchmarkHistorySQLiteOmniboxSearch(b *testing.B) {311 func BenchmarkHistorySQLiteOmniboxSearch(b *testing.B) {
60- repo, closeDB := newHistoryBenchmarkRepository(b)312+ repo := newHistoryBenchmarkRepository(b)
61- defer closeDB()
62 ctx := historyTestCtx()313 ctx := historyTestCtx()
63 314
315+ results, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, repository.HistoryScope{})
316+ if err != nil {
317+ b.Fatal(err)
318+ }
319+ requireBenchmarkLen(b, len(results), historyBenchmarkSearchLimit, "unbounded search")
320+
321+ b.ReportAllocs()
322+ b.ResetTimer()
323+ for range b.N {
324+ if _, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, repository.HistoryScope{}); err != nil {
325+ b.Fatal(err)
326+ }
327+ }
328+}
329+
330+func BenchmarkHistorySQLiteOmniboxSearchBounded(b *testing.B) {
331+ repo, cutoff := newBoundedHistoryBenchmarkRepository(b)
332+ ctx := historyTestCtx()
333+ scope := repository.HistoryScope{Cutoff: cutoff}
334+
335+ results, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, scope)
336+ if err != nil {
337+ b.Fatal(err)
338+ }
339+ requireBenchmarkLen(b, len(results), historyBenchmarkSearchLimit, "bounded search")
340+
64 b.ReportAllocs()341 b.ReportAllocs()
65 b.ResetTimer()342 b.ResetTimer()
66 for range b.N {343 for range b.N {
67- if _, err := repo.Search(ctx, "dumber history", 10); err != nil {344+ if _, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, scope); err != nil {
68 b.Fatal(err)345 b.Fatal(err)
69 }346 }
70 }347 }
M internal/infrastructure/persistence/sqlite/history_repo_test.go +390 -19
@@ -2,13 +2,17 @@ package sqlite_test
22
33 import (
44 "context"
5+ "database/sql"
56 "fmt"
67 "path/filepath"
8+ "strings"
79 "testing"
810 "time"
911
1012 "github.com/bnema/dumber/internal/domain/entity"
13+ repository "github.com/bnema/dumber/internal/domain/repository"
1114 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
15+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
1216 "github.com/bnema/dumber/internal/logging"
1317 "github.com/stretchr/testify/assert"
1418 "github.com/stretchr/testify/require"
@@ -188,11 +192,11 @@ func TestHistoryRepository_CRUD(t *testing.T) {
188192 assert.Equal(t, int64(2), found2.VisitCount)
189193
190194 // Get recent
191- recent, err := repo.GetRecent(ctx, 10, 0)
195+ recent, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{})
192196 require.NoError(t, err)
193197 require.Len(t, recent, 1)
194198
195- allRecent, err := repo.GetRecent(ctx, 0, 0)
199+ allRecent, err := repo.GetRecent(ctx, 0, 0, repository.HistoryScope{})
196200 require.NoError(t, err)
197201 require.Len(t, allRecent, 1)
198202
@@ -350,7 +354,7 @@ func TestHistoryRepository_Search_SingleWord(t *testing.T) {
350354 }
351355
352356 // Search for "github" - should find the github entry
353- results, err := repo.Search(ctx, "github", 10)
357+ results, err := repo.Search(ctx, "github", 10, repository.HistoryScope{})
354358 require.NoError(t, err)
355359 require.Len(t, results, 1)
356360 assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL)
@@ -377,7 +381,7 @@ func TestHistoryRepository_Search_MultiWord(t *testing.T) {
377381 }
378382
379383 // Search for "github issues" - should find only the github issues entry
380- results, err := repo.Search(ctx, "github issues", 10)
384+ results, err := repo.Search(ctx, "github issues", 10, repository.HistoryScope{})
381385 require.NoError(t, err)
382386 require.Len(t, results, 1)
383387 assert.Equal(t, "https://github.com/issues", results[0].Entry.URL)
@@ -401,7 +405,7 @@ func TestHistoryRepository_Search_MatchesTitle(t *testing.T) {
401405 require.NoError(t, repo.Save(ctx, entry))
402406
403407 // Search for "Documentation" - should find by title
404- results, err := repo.Search(ctx, "Documentation", 10)
408+ results, err := repo.Search(ctx, "Documentation", 10, repository.HistoryScope{})
405409 require.NoError(t, err)
406410 require.Len(t, results, 1)
407411 assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL)
@@ -424,12 +428,12 @@ func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
424428 }))
425429
426430 // Empty query should return empty results, no error
427- results, err := repo.Search(ctx, "", 10)
431+ results, err := repo.Search(ctx, "", 10, repository.HistoryScope{})
428432 require.NoError(t, err)
429433 assert.Empty(t, results)
430434
431435 // Whitespace-only query should also return empty results
432- results, err = repo.Search(ctx, " ", 10)
436+ results, err = repo.Search(ctx, " ", 10, repository.HistoryScope{})
433437 require.NoError(t, err)
434438 assert.Empty(t, results)
435439 }
@@ -457,7 +461,7 @@ func TestHistoryRepository_Search_NoValidTokens(t *testing.T) {
457461 }
458462
459463 for _, q := range invalidQueries {
460- results, err := repo.Search(ctx, q, 10)
464+ results, err := repo.Search(ctx, q, 10, repository.HistoryScope{})
461465 require.NoError(t, err, "query %q should return early without SQL errors", q)
462466 assert.Empty(t, results, "query %q should have no valid tokens", q)
463467 }
@@ -480,7 +484,7 @@ func TestHistoryRepository_Search_NoResults(t *testing.T) {
480484 }))
481485
482486 // Search for something that doesn't exist
483- results, err := repo.Search(ctx, "nonexistent", 10)
487+ results, err := repo.Search(ctx, "nonexistent", 10, repository.HistoryScope{})
484488 require.NoError(t, err)
485489 assert.Empty(t, results)
486490 }
@@ -506,7 +510,7 @@ func TestHistoryRepository_Search_PrefixMatching(t *testing.T) {
506510 }
507511
508512 // Search for "git" - should find both github and gitlab (prefix match)
509- results, err := repo.Search(ctx, "git", 10)
513+ results, err := repo.Search(ctx, "git", 10, repository.HistoryScope{})
510514 require.NoError(t, err)
511515 require.Len(t, results, 2)
512516
@@ -541,7 +545,7 @@ func TestHistoryRepository_Search_DomainLikeQuery(t *testing.T) {
541545
542546 // Search with period in query (domain-like) - periods should be treated as separators
543547 // "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema)
544- results, err := repo.Search(ctx, "gordon.bnem", 10)
548+ results, err := repo.Search(ctx, "gordon.bnem", 10, repository.HistoryScope{})
545549 require.NoError(t, err)
546550 require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev")
547551
@@ -584,7 +588,7 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
584588 }
585589
586590 for _, q := range specialQueries {
587- results, err := repo.Search(ctx, q, 10)
591+ results, err := repo.Search(ctx, q, 10, repository.HistoryScope{})
588592 require.NoError(t, err, "query %q should not cause error", q)
589593 // May or may not find results, but should not error
590594 _ = results
@@ -606,7 +610,7 @@ func TestHistoryRepository_Search_IgnoresFTSOperatorsAsTokens(t *testing.T) {
606610 Title: "Dumber",
607611 }))
608612
609- results, err := repo.Search(ctx, "AND github OR", 10)
613+ results, err := repo.Search(ctx, "AND github OR", 10, repository.HistoryScope{})
610614 require.NoError(t, err)
611615 require.Len(t, results, 1)
612616 assert.Equal(t, "https://github.com/bnema/dumber", results[0].Entry.URL)
@@ -627,7 +631,7 @@ func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
627631 Title: "Dumber",
628632 }))
629633
630- results, err := repo.Search(ctx, "github.com/bnema", 10)
634+ results, err := repo.Search(ctx, "github.com/bnema", 10, repository.HistoryScope{})
631635 require.NoError(t, err)
632636 require.NotEmpty(t, results, "slash-separated query should match history")
633637 }
@@ -651,7 +655,7 @@ func TestHistoryRepository_Search_PrefersURLMatchOverTitleOnlyMatch(t *testing.T
651655 Title: "GitHub Issues Mirror",
652656 }))
653657
654- results, err := repo.Search(ctx, "github issues", 10)
658+ results, err := repo.Search(ctx, "github issues", 10, repository.HistoryScope{})
655659 require.NoError(t, err)
656660 require.Len(t, results, 2)
657661 assert.Equal(t, "https://github.com/bnema/dumber/issues", results[0].Entry.URL)
@@ -676,7 +680,7 @@ func TestHistoryRepository_Search_PrefersHostPrefixOverTitleContains(t *testing.
676680 Title: "GitHub documentation mirror",
677681 }))
678682
679- results, err := repo.Search(ctx, "git", 10)
683+ results, err := repo.Search(ctx, "git", 10, repository.HistoryScope{})
680684 require.NoError(t, err)
681685 require.Len(t, results, 2)
682686 assert.Equal(t, "https://github.com", results[0].Entry.URL)
@@ -701,7 +705,7 @@ func TestHistoryRepository_Search_PrefersShorterRootURLForHostQuery(t *testing.T
701705 Title: "Issue 123",
702706 }))
703707
704- results, err := repo.Search(ctx, "github", 10)
708+ results, err := repo.Search(ctx, "github", 10, repository.HistoryScope{})
705709 require.NoError(t, err)
706710 require.Len(t, results, 2)
707711 assert.Equal(t, "https://github.com", results[0].Entry.URL)
@@ -829,11 +833,11 @@ func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T)
829833 Title: "GitHub",
830834 }))
831835
832- results, err := repo.Search(ctx, "git", 0)
836+ results, err := repo.Search(ctx, "git", 0, repository.HistoryScope{})
833837 require.NoError(t, err)
834838 assert.Empty(t, results)
835839
836- results, err = repo.Search(ctx, "git", -1)
840+ results, err = repo.Search(ctx, "git", -1, repository.HistoryScope{})
837841 require.NoError(t, err)
838842 assert.Empty(t, results)
839843 }
@@ -937,3 +941,370 @@ func historyIDs(entries []*entity.HistoryEntry) []int64 {
937941 }
938942 return ids
939943 }
944+
945+// insertHistoryRow seeds a history row with an explicit visit count and
946+// last_visited so age-scope tests can use stable, non-now timestamps. The
947+// timestamp is written in the same UTC second-precision format that SQLite's
948+// CURRENT_TIMESTAMP uses in production.
949+func insertHistoryRow(
950+ t *testing.T,
951+ ctx context.Context,
952+ db *sql.DB,
953+ url, title string,
954+ visitCount int,
955+ lastVisited time.Time,
956+) {
957+ t.Helper()
958+ timestamp := sqliteTimestamp(lastVisited)
959+ _, err := db.ExecContext(ctx,
960+ `INSERT INTO history (url, title, last_visited, created_at, visit_count) VALUES (?, ?, ?, ?, ?)`,
961+ url, title, timestamp, timestamp, visitCount)
962+ require.NoError(t, err)
963+}
964+
965+// sqliteTimestamp formats a time the way SQLite's CURRENT_TIMESTAMP stores it:
966+// UTC at second precision without a timezone suffix.
967+func sqliteTimestamp(t time.Time) string {
968+ return t.UTC().Format("2006-01-02 15:04:05")
969+}
970+
971+func TestHistoryRepository_GetMostVisitedWithin_ExcludesOldHighCountURL(t *testing.T) {
972+ ctx := historyTestCtx()
973+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
974+ require.NoError(t, err)
975+ t.Cleanup(func() { _ = db.Close() })
976+ repo := sqlite.NewHistoryRepository(db)
977+
978+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
979+ cutoff := now.AddDate(0, 0, -30)
980+ insertHistoryRow(t, ctx, db, "https://old.example.com", "Old Popular", 50, now.AddDate(0, 0, -40))
981+ insertHistoryRow(t, ctx, db, "https://recent.example.com", "Recent", 2, now.AddDate(0, 0, -1))
982+
983+ results, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{Cutoff: cutoff})
984+ require.NoError(t, err)
985+ require.Len(t, results, 1)
986+ assert.Equal(t, "https://recent.example.com", results[0].URL)
987+
988+ // A zero cutoff means all stored history, so the old popular URL returns.
989+ all, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{})
990+ require.NoError(t, err)
991+ require.Len(t, all, 2)
992+ assert.Equal(t, "https://old.example.com", all[0].URL)
993+ assert.Equal(t, int64(50), all[0].VisitCount)
994+}
995+
996+func TestHistoryRepository_GetMostVisitedWithin_BoundsResults(t *testing.T) {
997+ ctx := historyTestCtx()
998+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
999+ require.NoError(t, err)
1000+ t.Cleanup(func() { _ = db.Close() })
1001+ repo := sqlite.NewHistoryRepository(db)
1002+
1003+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1004+ insertHistoryRow(t, ctx, db, "https://one.example.com", "One", 5, now.Add(-time.Hour))
1005+ insertHistoryRow(t, ctx, db, "https://two.example.com", "Two", 3, now.Add(-2*time.Hour))
1006+ insertHistoryRow(t, ctx, db, "https://three.example.com", "Three", 1, now.Add(-3*time.Hour))
1007+
1008+ results, err := repo.GetMostVisitedWithin(ctx, 2, repository.HistoryScope{})
1009+ require.NoError(t, err)
1010+ require.Len(t, results, 2)
1011+ assert.Equal(t, []string{"https://one.example.com", "https://two.example.com"}, historyURLs(results))
1012+}
1013+
1014+func TestHistoryRepository_GetRecent_ScopeExcludesOldEntries(t *testing.T) {
1015+ ctx := historyTestCtx()
1016+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1017+ require.NoError(t, err)
1018+ t.Cleanup(func() { _ = db.Close() })
1019+ repo := sqlite.NewHistoryRepository(db)
1020+
1021+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1022+ cutoff := now.AddDate(0, 0, -30)
1023+ insertHistoryRow(t, ctx, db, "https://old.example.com", "Old", 1, now.AddDate(0, 0, -40))
1024+ insertHistoryRow(t, ctx, db, "https://recent.example.com", "Recent", 1, now.AddDate(0, 0, -1))
1025+
1026+ scoped, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff})
1027+ require.NoError(t, err)
1028+ require.Len(t, scoped, 1)
1029+ assert.Equal(t, "https://recent.example.com", scoped[0].URL)
1030+
1031+ all, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{})
1032+ require.NoError(t, err)
1033+ require.Len(t, all, 2)
1034+
1035+ bounded, err := repo.GetRecent(ctx, 1, 0, repository.HistoryScope{})
1036+ require.NoError(t, err)
1037+ require.Len(t, bounded, 1)
1038+}
1039+
1040+func TestHistoryRepository_Search_ScopeExcludesOldTitleOnlyMatch(t *testing.T) {
1041+ ctx := historyTestCtx()
1042+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1043+ require.NoError(t, err)
1044+ t.Cleanup(func() { _ = db.Close() })
1045+ repo := sqlite.NewHistoryRepository(db)
1046+
1047+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1048+ cutoff := now.AddDate(0, 0, -30)
1049+ // Matches only in the title, but is older than the window.
1050+ insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Widget Old Guide", 1, now.AddDate(0, 0, -40))
1051+ // Matches in the title and is inside the window.
1052+ insertHistoryRow(t, ctx, db, "https://recent.example.com/guide", "Widget Recent Guide", 1, now.AddDate(0, 0, -1))
1053+ // Matches in the URL, but is older than the window.
1054+ insertHistoryRow(t, ctx, db, "https://widget.example.com/page", "No match here", 1, now.AddDate(0, 0, -40))
1055+
1056+ scoped, err := repo.Search(ctx, "widget", 10, repository.HistoryScope{Cutoff: cutoff})
1057+ require.NoError(t, err)
1058+ require.Len(t, scoped, 1)
1059+ assert.Equal(t, "https://recent.example.com/guide", scoped[0].Entry.URL)
1060+
1061+ // A zero cutoff keeps the general, unrestricted search behavior.
1062+ all, err := repo.Search(ctx, "widget", 10, repository.HistoryScope{})
1063+ require.NoError(t, err)
1064+ require.Len(t, all, 3)
1065+}
1066+
1067+func historyURLs(entries []*entity.HistoryEntry) []string {
1068+ urls := make([]string, len(entries))
1069+ for i, entry := range entries {
1070+ urls[i] = entry.URL
1071+ }
1072+ return urls
1073+}
1074+
1075+func TestHistoryRepository_ScopeCutoffIsInclusiveAtSecondPrecision(t *testing.T) {
1076+ ctx := historyTestCtx()
1077+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1078+ require.NoError(t, err)
1079+ t.Cleanup(func() { _ = db.Close() })
1080+ repo := sqlite.NewHistoryRepository(db)
1081+
1082+ cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1083+ insertHistoryRow(t, ctx, db, "https://exact.example.com", "Exact", 1, cutoff)
1084+ insertHistoryRow(t, ctx, db, "https://before.example.com", "Before", 1, cutoff.Add(-time.Second))
1085+
1086+ results, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff})
1087+ require.NoError(t, err)
1088+ require.Len(t, results, 1, "the entry exactly on the cutoff must be included")
1089+ assert.Equal(t, "https://exact.example.com", results[0].URL)
1090+}
1091+
1092+func TestHistoryRepository_UnboundedScopePreservesNullLastVisited(t *testing.T) {
1093+ ctx := historyTestCtx()
1094+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1095+ require.NoError(t, err)
1096+ t.Cleanup(func() { _ = db.Close() })
1097+ repo := sqlite.NewHistoryRepository(db)
1098+
1099+ _, err = db.ExecContext(
1100+ ctx,
1101+ `INSERT INTO history (url, title, last_visited, visit_count) VALUES (?, ?, NULL, ?)`,
1102+ "https://null-last-visited.example.com",
1103+ "Legacy Null Row",
1104+ 1,
1105+ )
1106+ require.NoError(t, err)
1107+
1108+ cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1109+
1110+ recent, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{})
1111+ require.NoError(t, err)
1112+ require.Len(t, recent, 1, "unbounded recent history must keep NULL last_visited rows")
1113+
1114+ mostVisited, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{})
1115+ require.NoError(t, err)
1116+ require.Len(t, mostVisited, 1, "unbounded most-visited must keep NULL last_visited rows")
1117+
1118+ matches, err := repo.Search(ctx, "legacy", 10, repository.HistoryScope{})
1119+ require.NoError(t, err)
1120+ require.Len(t, matches, 1, "unbounded search must keep NULL last_visited rows")
1121+
1122+ scoped, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff})
1123+ require.NoError(t, err)
1124+ assert.Empty(t, scoped, "a bounded window must not include rows without a last_visited timestamp")
1125+}
1126+
1127+func TestHistoryRepository_Search_FiltersBeforeLimitOnURLMatch(t *testing.T) {
1128+ ctx := historyTestCtx()
1129+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1130+ require.NoError(t, err)
1131+ t.Cleanup(func() { _ = db.Close() })
1132+ repo := sqlite.NewHistoryRepository(db)
1133+
1134+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1135+ cutoff := now.AddDate(0, 0, -30)
1136+ // The old row outranks the recent row (higher visit count and domain boost),
1137+ // so it would consume the single LIMIT slot if filtering happened after LIMIT.
1138+ insertHistoryRow(t, ctx, db, "https://urlsecret.example.com/", "Old", 50, now.AddDate(0, 0, -40))
1139+ insertHistoryRow(t, ctx, db, "https://urlsecret-recent.example.com/", "Recent", 1, now.AddDate(0, 0, -1))
1140+
1141+ results, err := repo.Search(ctx, "urlsecret", 1, repository.HistoryScope{Cutoff: cutoff})
1142+ require.NoError(t, err)
1143+ require.Len(t, results, 1, "URL FTS must filter by age before applying LIMIT")
1144+ assert.Equal(t, "https://urlsecret-recent.example.com/", results[0].Entry.URL)
1145+
1146+ // Without a scope the old high-priority row wins the same query.
1147+ all, err := repo.Search(ctx, "urlsecret", 10, repository.HistoryScope{})
1148+ require.NoError(t, err)
1149+ require.Len(t, all, 2)
1150+ assert.Equal(t, "https://urlsecret.example.com/", all[0].Entry.URL)
1151+}
1152+
1153+func TestHistoryRepository_Search_FiltersBeforeLimitOnTitleMatch(t *testing.T) {
1154+ ctx := historyTestCtx()
1155+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1156+ require.NoError(t, err)
1157+ t.Cleanup(func() { _ = db.Close() })
1158+ repo := sqlite.NewHistoryRepository(db)
1159+
1160+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1161+ cutoff := now.AddDate(0, 0, -30)
1162+ // The query only matches titles, so only the title FTS path is exercised.
1163+ // The old row has the higher visit count and would win an unfiltered LIMIT.
1164+ insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Titlesecret Old Guide", 50, now.AddDate(0, 0, -40))
1165+ insertHistoryRow(t, ctx, db, "https://recent.example.com/entry", "Titlesecret Recent Guide", 1, now.AddDate(0, 0, -1))
1166+
1167+ results, err := repo.Search(ctx, "titlesecret", 1, repository.HistoryScope{Cutoff: cutoff})
1168+ require.NoError(t, err)
1169+ require.Len(t, results, 1, "title FTS must filter by age before applying LIMIT")
1170+ assert.Equal(t, "https://recent.example.com/entry", results[0].Entry.URL)
1171+
1172+ all, err := repo.Search(ctx, "titlesecret", 10, repository.HistoryScope{})
1173+ require.NoError(t, err)
1174+ require.Len(t, all, 2)
1175+}
1176+
1177+func TestHistoryRepository_GetRecent_BoundedLimitOffsetAndCutoff(t *testing.T) {
1178+ ctx := historyTestCtx()
1179+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1180+ require.NoError(t, err)
1181+ t.Cleanup(func() { _ = db.Close() })
1182+ repo := sqlite.NewHistoryRepository(db)
1183+
1184+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1185+ cutoff := now.AddDate(0, 0, -30)
1186+ insertHistoryRow(t, ctx, db, "https://one.example.com", "One", 1, now.Add(-time.Hour))
1187+ insertHistoryRow(t, ctx, db, "https://two.example.com", "Two", 1, now.Add(-2*time.Hour))
1188+ insertHistoryRow(t, ctx, db, "https://three.example.com", "Three", 1, now.Add(-3*time.Hour))
1189+ insertHistoryRow(t, ctx, db, "https://stale.example.com", "Stale", 1, now.AddDate(0, 0, -40))
1190+
1191+ scope := repository.HistoryScope{Cutoff: cutoff}
1192+
1193+ page, err := repo.GetRecent(ctx, 1, 1, scope)
1194+ require.NoError(t, err)
1195+ require.Len(t, page, 1)
1196+ assert.Equal(t, "https://two.example.com", page[0].URL)
1197+
1198+ // A non-positive limit means all eligible rows, with the offset applied and
1199+ // the stale row excluded by the cutoff.
1200+ rest, err := repo.GetRecent(ctx, 0, 1, scope)
1201+ require.NoError(t, err)
1202+ require.Len(t, rest, 2)
1203+ assert.Equal(t, []string{"https://two.example.com", "https://three.example.com"}, historyURLs(rest))
1204+}
1205+
1206+func TestHistoryRepository_Search_BoundedScopeEmptyResult(t *testing.T) {
1207+ ctx := historyTestCtx()
1208+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1209+ require.NoError(t, err)
1210+ t.Cleanup(func() { _ = db.Close() })
1211+ repo := sqlite.NewHistoryRepository(db)
1212+
1213+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1214+ cutoff := now.AddDate(0, 0, -30)
1215+ insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Ephemeral Secret", 1, now.AddDate(0, 0, -40))
1216+
1217+ scoped, err := repo.Search(ctx, "ephemeral", 10, repository.HistoryScope{Cutoff: cutoff})
1218+ require.NoError(t, err)
1219+ assert.Empty(t, scoped, "a bounded search must not return age-excluded matches")
1220+
1221+ all, err := repo.Search(ctx, "ephemeral", 10, repository.HistoryScope{})
1222+ require.NoError(t, err)
1223+ require.Len(t, all, 1)
1224+}
1225+
1226+func TestHistoryRepository_GetMostVisitedWithin_NonPositiveLimitIsEmpty(t *testing.T) {
1227+ ctx := historyTestCtx()
1228+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1229+ require.NoError(t, err)
1230+ t.Cleanup(func() { _ = db.Close() })
1231+ repo := sqlite.NewHistoryRepository(db)
1232+
1233+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1234+ insertHistoryRow(t, ctx, db, "https://example.com", "Example", 5, now.Add(-time.Hour))
1235+
1236+ unbounded, err := repo.GetMostVisitedWithin(ctx, 0, repository.HistoryScope{})
1237+ require.NoError(t, err)
1238+ assert.Empty(t, unbounded)
1239+
1240+ bounded, err := repo.GetMostVisitedWithin(ctx, -5, repository.HistoryScope{Cutoff: now.AddDate(0, 0, -30)})
1241+ require.NoError(t, err)
1242+ assert.Empty(t, bounded)
1243+}
1244+
1245+// queryPlan records the actual driver's EXPLAIN QUERY PLAN output for a query.
1246+func queryPlan(t *testing.T, ctx context.Context, db *sql.DB, query string, args ...any) []string {
1247+ t.Helper()
1248+
1249+ rows, err := db.QueryContext(ctx, "EXPLAIN QUERY PLAN "+query, args...)
1250+ require.NoError(t, err)
1251+ defer func() { _ = rows.Close() }()
1252+
1253+ var details []string
1254+ for rows.Next() {
1255+ var id, parent, notUsed int
1256+ var detail string
1257+ require.NoError(t, rows.Scan(&id, &parent, &notUsed, &detail))
1258+ details = append(details, detail)
1259+ }
1260+ require.NoError(t, rows.Err())
1261+ return details
1262+}
1263+
1264+func TestHistoryRepository_BoundedRecentPlanUsesCutoffIndex(t *testing.T) {
1265+ ctx := historyTestCtx()
1266+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1267+ require.NoError(t, err)
1268+ t.Cleanup(func() { _ = db.Close() })
1269+
1270+ // Exercise the exact generated statement that historyRepo.GetRecent runs for
1271+ // a bounded scope (see GetRecentHistorySinceCutoff), passed verbatim including
1272+ // sqlc's leading `-- name:` comment. This fails to compile if the bounded
1273+ // variant disappears, and the plan assertions fail if it regresses to a
1274+ // nullable `(? IS NULL OR last_visited >= ?)` predicate, which plans as a
1275+ // SCAN using idx_history_last_visited rather than a SEARCH.
1276+ cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1277+ plan := queryPlan(t, ctx, db, sqlc.GetRecentHistorySinceCutoff, sql.NullTime{Time: cutoff.UTC(), Valid: true}, 0, 50)
1278+
1279+ joined := strings.Join(plan, "\n")
1280+ require.Contains(t, joined, "SEARCH", "bounded recent query must use an index search")
1281+ require.Contains(t, joined, "idx_history_last_visited", "bounded recent query must use the last_visited index")
1282+}
1283+
1284+func TestHistoryRepository_ScopeCutoffSubSecondNormalization(t *testing.T) {
1285+ ctx := historyTestCtx()
1286+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1287+ require.NoError(t, err)
1288+ t.Cleanup(func() { _ = db.Close() })
1289+ repo := sqlite.NewHistoryRepository(db)
1290+
1291+ // Stored at exactly 12:00:05 with no fractional part, matching
1292+ // CURRENT_TIMESTAMP.
1293+ stored := time.Date(2026, 6, 24, 12, 0, 5, 0, time.UTC)
1294+ insertHistoryRow(t, ctx, db, "https://subsecond.example.com/marker", "Subsecond Marker", 1, stored)
1295+ scope := repository.HistoryScope{Cutoff: stored.Add(500 * time.Millisecond)}
1296+
1297+ recent, err := repo.GetRecent(ctx, 10, 0, scope)
1298+ require.NoError(t, err)
1299+ require.Len(t, recent, 1, "a sub-second cutoff must normalize to the stored second and include the row")
1300+ assert.Equal(t, "https://subsecond.example.com/marker", recent[0].URL)
1301+
1302+ mostVisited, err := repo.GetMostVisitedWithin(ctx, 10, scope)
1303+ require.NoError(t, err)
1304+ require.Len(t, mostVisited, 1)
1305+
1306+ matches, err := repo.Search(ctx, "subsecond", 10, scope)
1307+ require.NoError(t, err)
1308+ require.Len(t, matches, 1, "the shared FTS cutoff helper must apply the same normalization")
1309+ assert.Equal(t, "https://subsecond.example.com/marker", matches[0].Entry.URL)
1310+}
@@ -2,13 +2,17 @@ package sqlite_test
2 2
3 import (3 import (
4 "context"4 "context"
5+ "database/sql"
5 "fmt"6 "fmt"
6 "path/filepath"7 "path/filepath"
8+ "strings"
7 "testing"9 "testing"
8 "time"10 "time"
9 11
10 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
13+ repository "github.com/bnema/dumber/internal/domain/repository"
11 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"14 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
15+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
12 "github.com/bnema/dumber/internal/logging"16 "github.com/bnema/dumber/internal/logging"
13 "github.com/stretchr/testify/assert"17 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
@@ -188,11 +192,11 @@ func TestHistoryRepository_CRUD(t *testing.T) {
188 assert.Equal(t, int64(2), found2.VisitCount)192 assert.Equal(t, int64(2), found2.VisitCount)
189 193
190 // Get recent194 // Get recent
191- recent, err := repo.GetRecent(ctx, 10, 0)195+ recent, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{})
192 require.NoError(t, err)196 require.NoError(t, err)
193 require.Len(t, recent, 1)197 require.Len(t, recent, 1)
194 198
195- allRecent, err := repo.GetRecent(ctx, 0, 0)199+ allRecent, err := repo.GetRecent(ctx, 0, 0, repository.HistoryScope{})
196 require.NoError(t, err)200 require.NoError(t, err)
197 require.Len(t, allRecent, 1)201 require.Len(t, allRecent, 1)
198 202
@@ -350,7 +354,7 @@ func TestHistoryRepository_Search_SingleWord(t *testing.T) {
350 }354 }
351 355
352 // Search for "github" - should find the github entry356 // Search for "github" - should find the github entry
353- results, err := repo.Search(ctx, "github", 10)357+ results, err := repo.Search(ctx, "github", 10, repository.HistoryScope{})
354 require.NoError(t, err)358 require.NoError(t, err)
355 require.Len(t, results, 1)359 require.Len(t, results, 1)
356 assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL)360 assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL)
@@ -377,7 +381,7 @@ func TestHistoryRepository_Search_MultiWord(t *testing.T) {
377 }381 }
378 382
379 // Search for "github issues" - should find only the github issues entry383 // Search for "github issues" - should find only the github issues entry
380- results, err := repo.Search(ctx, "github issues", 10)384+ results, err := repo.Search(ctx, "github issues", 10, repository.HistoryScope{})
381 require.NoError(t, err)385 require.NoError(t, err)
382 require.Len(t, results, 1)386 require.Len(t, results, 1)
383 assert.Equal(t, "https://github.com/issues", results[0].Entry.URL)387 assert.Equal(t, "https://github.com/issues", results[0].Entry.URL)
@@ -401,7 +405,7 @@ func TestHistoryRepository_Search_MatchesTitle(t *testing.T) {
401 require.NoError(t, repo.Save(ctx, entry))405 require.NoError(t, repo.Save(ctx, entry))
402 406
403 // Search for "Documentation" - should find by title407 // Search for "Documentation" - should find by title
404- results, err := repo.Search(ctx, "Documentation", 10)408+ results, err := repo.Search(ctx, "Documentation", 10, repository.HistoryScope{})
405 require.NoError(t, err)409 require.NoError(t, err)
406 require.Len(t, results, 1)410 require.Len(t, results, 1)
407 assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL)411 assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL)
@@ -424,12 +428,12 @@ func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
424 }))428 }))
425 429
426 // Empty query should return empty results, no error430 // Empty query should return empty results, no error
427- results, err := repo.Search(ctx, "", 10)431+ results, err := repo.Search(ctx, "", 10, repository.HistoryScope{})
428 require.NoError(t, err)432 require.NoError(t, err)
429 assert.Empty(t, results)433 assert.Empty(t, results)
430 434
431 // Whitespace-only query should also return empty results435 // Whitespace-only query should also return empty results
432- results, err = repo.Search(ctx, " ", 10)436+ results, err = repo.Search(ctx, " ", 10, repository.HistoryScope{})
433 require.NoError(t, err)437 require.NoError(t, err)
434 assert.Empty(t, results)438 assert.Empty(t, results)
435 }439 }
@@ -457,7 +461,7 @@ func TestHistoryRepository_Search_NoValidTokens(t *testing.T) {
457 }461 }
458 462
459 for _, q := range invalidQueries {463 for _, q := range invalidQueries {
460- results, err := repo.Search(ctx, q, 10)464+ results, err := repo.Search(ctx, q, 10, repository.HistoryScope{})
461 require.NoError(t, err, "query %q should return early without SQL errors", q)465 require.NoError(t, err, "query %q should return early without SQL errors", q)
462 assert.Empty(t, results, "query %q should have no valid tokens", q)466 assert.Empty(t, results, "query %q should have no valid tokens", q)
463 }467 }
@@ -480,7 +484,7 @@ func TestHistoryRepository_Search_NoResults(t *testing.T) {
480 }))484 }))
481 485
482 // Search for something that doesn't exist486 // Search for something that doesn't exist
483- results, err := repo.Search(ctx, "nonexistent", 10)487+ results, err := repo.Search(ctx, "nonexistent", 10, repository.HistoryScope{})
484 require.NoError(t, err)488 require.NoError(t, err)
485 assert.Empty(t, results)489 assert.Empty(t, results)
486 }490 }
@@ -506,7 +510,7 @@ func TestHistoryRepository_Search_PrefixMatching(t *testing.T) {
506 }510 }
507 511
508 // Search for "git" - should find both github and gitlab (prefix match)512 // Search for "git" - should find both github and gitlab (prefix match)
509- results, err := repo.Search(ctx, "git", 10)513+ results, err := repo.Search(ctx, "git", 10, repository.HistoryScope{})
510 require.NoError(t, err)514 require.NoError(t, err)
511 require.Len(t, results, 2)515 require.Len(t, results, 2)
512 516
@@ -541,7 +545,7 @@ func TestHistoryRepository_Search_DomainLikeQuery(t *testing.T) {
541 545
542 // Search with period in query (domain-like) - periods should be treated as separators546 // Search with period in query (domain-like) - periods should be treated as separators
543 // "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema)547 // "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema)
544- results, err := repo.Search(ctx, "gordon.bnem", 10)548+ results, err := repo.Search(ctx, "gordon.bnem", 10, repository.HistoryScope{})
545 require.NoError(t, err)549 require.NoError(t, err)
546 require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev")550 require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev")
547 551
@@ -584,7 +588,7 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
584 }588 }
585 589
586 for _, q := range specialQueries {590 for _, q := range specialQueries {
587- results, err := repo.Search(ctx, q, 10)591+ results, err := repo.Search(ctx, q, 10, repository.HistoryScope{})
588 require.NoError(t, err, "query %q should not cause error", q)592 require.NoError(t, err, "query %q should not cause error", q)
589 // May or may not find results, but should not error593 // May or may not find results, but should not error
590 _ = results594 _ = results
@@ -606,7 +610,7 @@ func TestHistoryRepository_Search_IgnoresFTSOperatorsAsTokens(t *testing.T) {
606 Title: "Dumber",610 Title: "Dumber",
607 }))611 }))
608 612
609- results, err := repo.Search(ctx, "AND github OR", 10)613+ results, err := repo.Search(ctx, "AND github OR", 10, repository.HistoryScope{})
610 require.NoError(t, err)614 require.NoError(t, err)
611 require.Len(t, results, 1)615 require.Len(t, results, 1)
612 assert.Equal(t, "https://github.com/bnema/dumber", results[0].Entry.URL)616 assert.Equal(t, "https://github.com/bnema/dumber", results[0].Entry.URL)
@@ -627,7 +631,7 @@ func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
627 Title: "Dumber",631 Title: "Dumber",
628 }))632 }))
629 633
630- results, err := repo.Search(ctx, "github.com/bnema", 10)634+ results, err := repo.Search(ctx, "github.com/bnema", 10, repository.HistoryScope{})
631 require.NoError(t, err)635 require.NoError(t, err)
632 require.NotEmpty(t, results, "slash-separated query should match history")636 require.NotEmpty(t, results, "slash-separated query should match history")
633 }637 }
@@ -651,7 +655,7 @@ func TestHistoryRepository_Search_PrefersURLMatchOverTitleOnlyMatch(t *testing.T
651 Title: "GitHub Issues Mirror",655 Title: "GitHub Issues Mirror",
652 }))656 }))
653 657
654- results, err := repo.Search(ctx, "github issues", 10)658+ results, err := repo.Search(ctx, "github issues", 10, repository.HistoryScope{})
655 require.NoError(t, err)659 require.NoError(t, err)
656 require.Len(t, results, 2)660 require.Len(t, results, 2)
657 assert.Equal(t, "https://github.com/bnema/dumber/issues", results[0].Entry.URL)661 assert.Equal(t, "https://github.com/bnema/dumber/issues", results[0].Entry.URL)
@@ -676,7 +680,7 @@ func TestHistoryRepository_Search_PrefersHostPrefixOverTitleContains(t *testing.
676 Title: "GitHub documentation mirror",680 Title: "GitHub documentation mirror",
677 }))681 }))
678 682
679- results, err := repo.Search(ctx, "git", 10)683+ results, err := repo.Search(ctx, "git", 10, repository.HistoryScope{})
680 require.NoError(t, err)684 require.NoError(t, err)
681 require.Len(t, results, 2)685 require.Len(t, results, 2)
682 assert.Equal(t, "https://github.com", results[0].Entry.URL)686 assert.Equal(t, "https://github.com", results[0].Entry.URL)
@@ -701,7 +705,7 @@ func TestHistoryRepository_Search_PrefersShorterRootURLForHostQuery(t *testing.T
701 Title: "Issue 123",705 Title: "Issue 123",
702 }))706 }))
703 707
704- results, err := repo.Search(ctx, "github", 10)708+ results, err := repo.Search(ctx, "github", 10, repository.HistoryScope{})
705 require.NoError(t, err)709 require.NoError(t, err)
706 require.Len(t, results, 2)710 require.Len(t, results, 2)
707 assert.Equal(t, "https://github.com", results[0].Entry.URL)711 assert.Equal(t, "https://github.com", results[0].Entry.URL)
@@ -829,11 +833,11 @@ func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T)
829 Title: "GitHub",833 Title: "GitHub",
830 }))834 }))
831 835
832- results, err := repo.Search(ctx, "git", 0)836+ results, err := repo.Search(ctx, "git", 0, repository.HistoryScope{})
833 require.NoError(t, err)837 require.NoError(t, err)
834 assert.Empty(t, results)838 assert.Empty(t, results)
835 839
836- results, err = repo.Search(ctx, "git", -1)840+ results, err = repo.Search(ctx, "git", -1, repository.HistoryScope{})
837 require.NoError(t, err)841 require.NoError(t, err)
838 assert.Empty(t, results)842 assert.Empty(t, results)
839 }843 }
@@ -937,3 +941,370 @@ func historyIDs(entries []*entity.HistoryEntry) []int64 {
937 }941 }
938 return ids942 return ids
939 }943 }
944+
945+// insertHistoryRow seeds a history row with an explicit visit count and
946+// last_visited so age-scope tests can use stable, non-now timestamps. The
947+// timestamp is written in the same UTC second-precision format that SQLite's
948+// CURRENT_TIMESTAMP uses in production.
949+func insertHistoryRow(
950+ t *testing.T,
951+ ctx context.Context,
952+ db *sql.DB,
953+ url, title string,
954+ visitCount int,
955+ lastVisited time.Time,
956+) {
957+ t.Helper()
958+ timestamp := sqliteTimestamp(lastVisited)
959+ _, err := db.ExecContext(ctx,
960+ `INSERT INTO history (url, title, last_visited, created_at, visit_count) VALUES (?, ?, ?, ?, ?)`,
961+ url, title, timestamp, timestamp, visitCount)
962+ require.NoError(t, err)
963+}
964+
965+// sqliteTimestamp formats a time the way SQLite's CURRENT_TIMESTAMP stores it:
966+// UTC at second precision without a timezone suffix.
967+func sqliteTimestamp(t time.Time) string {
968+ return t.UTC().Format("2006-01-02 15:04:05")
969+}
970+
971+func TestHistoryRepository_GetMostVisitedWithin_ExcludesOldHighCountURL(t *testing.T) {
972+ ctx := historyTestCtx()
973+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
974+ require.NoError(t, err)
975+ t.Cleanup(func() { _ = db.Close() })
976+ repo := sqlite.NewHistoryRepository(db)
977+
978+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
979+ cutoff := now.AddDate(0, 0, -30)
980+ insertHistoryRow(t, ctx, db, "https://old.example.com", "Old Popular", 50, now.AddDate(0, 0, -40))
981+ insertHistoryRow(t, ctx, db, "https://recent.example.com", "Recent", 2, now.AddDate(0, 0, -1))
982+
983+ results, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{Cutoff: cutoff})
984+ require.NoError(t, err)
985+ require.Len(t, results, 1)
986+ assert.Equal(t, "https://recent.example.com", results[0].URL)
987+
988+ // A zero cutoff means all stored history, so the old popular URL returns.
989+ all, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{})
990+ require.NoError(t, err)
991+ require.Len(t, all, 2)
992+ assert.Equal(t, "https://old.example.com", all[0].URL)
993+ assert.Equal(t, int64(50), all[0].VisitCount)
994+}
995+
996+func TestHistoryRepository_GetMostVisitedWithin_BoundsResults(t *testing.T) {
997+ ctx := historyTestCtx()
998+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
999+ require.NoError(t, err)
1000+ t.Cleanup(func() { _ = db.Close() })
1001+ repo := sqlite.NewHistoryRepository(db)
1002+
1003+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1004+ insertHistoryRow(t, ctx, db, "https://one.example.com", "One", 5, now.Add(-time.Hour))
1005+ insertHistoryRow(t, ctx, db, "https://two.example.com", "Two", 3, now.Add(-2*time.Hour))
1006+ insertHistoryRow(t, ctx, db, "https://three.example.com", "Three", 1, now.Add(-3*time.Hour))
1007+
1008+ results, err := repo.GetMostVisitedWithin(ctx, 2, repository.HistoryScope{})
1009+ require.NoError(t, err)
1010+ require.Len(t, results, 2)
1011+ assert.Equal(t, []string{"https://one.example.com", "https://two.example.com"}, historyURLs(results))
1012+}
1013+
1014+func TestHistoryRepository_GetRecent_ScopeExcludesOldEntries(t *testing.T) {
1015+ ctx := historyTestCtx()
1016+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1017+ require.NoError(t, err)
1018+ t.Cleanup(func() { _ = db.Close() })
1019+ repo := sqlite.NewHistoryRepository(db)
1020+
1021+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1022+ cutoff := now.AddDate(0, 0, -30)
1023+ insertHistoryRow(t, ctx, db, "https://old.example.com", "Old", 1, now.AddDate(0, 0, -40))
1024+ insertHistoryRow(t, ctx, db, "https://recent.example.com", "Recent", 1, now.AddDate(0, 0, -1))
1025+
1026+ scoped, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff})
1027+ require.NoError(t, err)
1028+ require.Len(t, scoped, 1)
1029+ assert.Equal(t, "https://recent.example.com", scoped[0].URL)
1030+
1031+ all, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{})
1032+ require.NoError(t, err)
1033+ require.Len(t, all, 2)
1034+
1035+ bounded, err := repo.GetRecent(ctx, 1, 0, repository.HistoryScope{})
1036+ require.NoError(t, err)
1037+ require.Len(t, bounded, 1)
1038+}
1039+
1040+func TestHistoryRepository_Search_ScopeExcludesOldTitleOnlyMatch(t *testing.T) {
1041+ ctx := historyTestCtx()
1042+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1043+ require.NoError(t, err)
1044+ t.Cleanup(func() { _ = db.Close() })
1045+ repo := sqlite.NewHistoryRepository(db)
1046+
1047+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1048+ cutoff := now.AddDate(0, 0, -30)
1049+ // Matches only in the title, but is older than the window.
1050+ insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Widget Old Guide", 1, now.AddDate(0, 0, -40))
1051+ // Matches in the title and is inside the window.
1052+ insertHistoryRow(t, ctx, db, "https://recent.example.com/guide", "Widget Recent Guide", 1, now.AddDate(0, 0, -1))
1053+ // Matches in the URL, but is older than the window.
1054+ insertHistoryRow(t, ctx, db, "https://widget.example.com/page", "No match here", 1, now.AddDate(0, 0, -40))
1055+
1056+ scoped, err := repo.Search(ctx, "widget", 10, repository.HistoryScope{Cutoff: cutoff})
1057+ require.NoError(t, err)
1058+ require.Len(t, scoped, 1)
1059+ assert.Equal(t, "https://recent.example.com/guide", scoped[0].Entry.URL)
1060+
1061+ // A zero cutoff keeps the general, unrestricted search behavior.
1062+ all, err := repo.Search(ctx, "widget", 10, repository.HistoryScope{})
1063+ require.NoError(t, err)
1064+ require.Len(t, all, 3)
1065+}
1066+
1067+func historyURLs(entries []*entity.HistoryEntry) []string {
1068+ urls := make([]string, len(entries))
1069+ for i, entry := range entries {
1070+ urls[i] = entry.URL
1071+ }
1072+ return urls
1073+}
1074+
1075+func TestHistoryRepository_ScopeCutoffIsInclusiveAtSecondPrecision(t *testing.T) {
1076+ ctx := historyTestCtx()
1077+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1078+ require.NoError(t, err)
1079+ t.Cleanup(func() { _ = db.Close() })
1080+ repo := sqlite.NewHistoryRepository(db)
1081+
1082+ cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1083+ insertHistoryRow(t, ctx, db, "https://exact.example.com", "Exact", 1, cutoff)
1084+ insertHistoryRow(t, ctx, db, "https://before.example.com", "Before", 1, cutoff.Add(-time.Second))
1085+
1086+ results, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff})
1087+ require.NoError(t, err)
1088+ require.Len(t, results, 1, "the entry exactly on the cutoff must be included")
1089+ assert.Equal(t, "https://exact.example.com", results[0].URL)
1090+}
1091+
1092+func TestHistoryRepository_UnboundedScopePreservesNullLastVisited(t *testing.T) {
1093+ ctx := historyTestCtx()
1094+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1095+ require.NoError(t, err)
1096+ t.Cleanup(func() { _ = db.Close() })
1097+ repo := sqlite.NewHistoryRepository(db)
1098+
1099+ _, err = db.ExecContext(
1100+ ctx,
1101+ `INSERT INTO history (url, title, last_visited, visit_count) VALUES (?, ?, NULL, ?)`,
1102+ "https://null-last-visited.example.com",
1103+ "Legacy Null Row",
1104+ 1,
1105+ )
1106+ require.NoError(t, err)
1107+
1108+ cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1109+
1110+ recent, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{})
1111+ require.NoError(t, err)
1112+ require.Len(t, recent, 1, "unbounded recent history must keep NULL last_visited rows")
1113+
1114+ mostVisited, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{})
1115+ require.NoError(t, err)
1116+ require.Len(t, mostVisited, 1, "unbounded most-visited must keep NULL last_visited rows")
1117+
1118+ matches, err := repo.Search(ctx, "legacy", 10, repository.HistoryScope{})
1119+ require.NoError(t, err)
1120+ require.Len(t, matches, 1, "unbounded search must keep NULL last_visited rows")
1121+
1122+ scoped, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff})
1123+ require.NoError(t, err)
1124+ assert.Empty(t, scoped, "a bounded window must not include rows without a last_visited timestamp")
1125+}
1126+
1127+func TestHistoryRepository_Search_FiltersBeforeLimitOnURLMatch(t *testing.T) {
1128+ ctx := historyTestCtx()
1129+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1130+ require.NoError(t, err)
1131+ t.Cleanup(func() { _ = db.Close() })
1132+ repo := sqlite.NewHistoryRepository(db)
1133+
1134+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1135+ cutoff := now.AddDate(0, 0, -30)
1136+ // The old row outranks the recent row (higher visit count and domain boost),
1137+ // so it would consume the single LIMIT slot if filtering happened after LIMIT.
1138+ insertHistoryRow(t, ctx, db, "https://urlsecret.example.com/", "Old", 50, now.AddDate(0, 0, -40))
1139+ insertHistoryRow(t, ctx, db, "https://urlsecret-recent.example.com/", "Recent", 1, now.AddDate(0, 0, -1))
1140+
1141+ results, err := repo.Search(ctx, "urlsecret", 1, repository.HistoryScope{Cutoff: cutoff})
1142+ require.NoError(t, err)
1143+ require.Len(t, results, 1, "URL FTS must filter by age before applying LIMIT")
1144+ assert.Equal(t, "https://urlsecret-recent.example.com/", results[0].Entry.URL)
1145+
1146+ // Without a scope the old high-priority row wins the same query.
1147+ all, err := repo.Search(ctx, "urlsecret", 10, repository.HistoryScope{})
1148+ require.NoError(t, err)
1149+ require.Len(t, all, 2)
1150+ assert.Equal(t, "https://urlsecret.example.com/", all[0].Entry.URL)
1151+}
1152+
1153+func TestHistoryRepository_Search_FiltersBeforeLimitOnTitleMatch(t *testing.T) {
1154+ ctx := historyTestCtx()
1155+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1156+ require.NoError(t, err)
1157+ t.Cleanup(func() { _ = db.Close() })
1158+ repo := sqlite.NewHistoryRepository(db)
1159+
1160+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1161+ cutoff := now.AddDate(0, 0, -30)
1162+ // The query only matches titles, so only the title FTS path is exercised.
1163+ // The old row has the higher visit count and would win an unfiltered LIMIT.
1164+ insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Titlesecret Old Guide", 50, now.AddDate(0, 0, -40))
1165+ insertHistoryRow(t, ctx, db, "https://recent.example.com/entry", "Titlesecret Recent Guide", 1, now.AddDate(0, 0, -1))
1166+
1167+ results, err := repo.Search(ctx, "titlesecret", 1, repository.HistoryScope{Cutoff: cutoff})
1168+ require.NoError(t, err)
1169+ require.Len(t, results, 1, "title FTS must filter by age before applying LIMIT")
1170+ assert.Equal(t, "https://recent.example.com/entry", results[0].Entry.URL)
1171+
1172+ all, err := repo.Search(ctx, "titlesecret", 10, repository.HistoryScope{})
1173+ require.NoError(t, err)
1174+ require.Len(t, all, 2)
1175+}
1176+
1177+func TestHistoryRepository_GetRecent_BoundedLimitOffsetAndCutoff(t *testing.T) {
1178+ ctx := historyTestCtx()
1179+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1180+ require.NoError(t, err)
1181+ t.Cleanup(func() { _ = db.Close() })
1182+ repo := sqlite.NewHistoryRepository(db)
1183+
1184+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1185+ cutoff := now.AddDate(0, 0, -30)
1186+ insertHistoryRow(t, ctx, db, "https://one.example.com", "One", 1, now.Add(-time.Hour))
1187+ insertHistoryRow(t, ctx, db, "https://two.example.com", "Two", 1, now.Add(-2*time.Hour))
1188+ insertHistoryRow(t, ctx, db, "https://three.example.com", "Three", 1, now.Add(-3*time.Hour))
1189+ insertHistoryRow(t, ctx, db, "https://stale.example.com", "Stale", 1, now.AddDate(0, 0, -40))
1190+
1191+ scope := repository.HistoryScope{Cutoff: cutoff}
1192+
1193+ page, err := repo.GetRecent(ctx, 1, 1, scope)
1194+ require.NoError(t, err)
1195+ require.Len(t, page, 1)
1196+ assert.Equal(t, "https://two.example.com", page[0].URL)
1197+
1198+ // A non-positive limit means all eligible rows, with the offset applied and
1199+ // the stale row excluded by the cutoff.
1200+ rest, err := repo.GetRecent(ctx, 0, 1, scope)
1201+ require.NoError(t, err)
1202+ require.Len(t, rest, 2)
1203+ assert.Equal(t, []string{"https://two.example.com", "https://three.example.com"}, historyURLs(rest))
1204+}
1205+
1206+func TestHistoryRepository_Search_BoundedScopeEmptyResult(t *testing.T) {
1207+ ctx := historyTestCtx()
1208+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1209+ require.NoError(t, err)
1210+ t.Cleanup(func() { _ = db.Close() })
1211+ repo := sqlite.NewHistoryRepository(db)
1212+
1213+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1214+ cutoff := now.AddDate(0, 0, -30)
1215+ insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Ephemeral Secret", 1, now.AddDate(0, 0, -40))
1216+
1217+ scoped, err := repo.Search(ctx, "ephemeral", 10, repository.HistoryScope{Cutoff: cutoff})
1218+ require.NoError(t, err)
1219+ assert.Empty(t, scoped, "a bounded search must not return age-excluded matches")
1220+
1221+ all, err := repo.Search(ctx, "ephemeral", 10, repository.HistoryScope{})
1222+ require.NoError(t, err)
1223+ require.Len(t, all, 1)
1224+}
1225+
1226+func TestHistoryRepository_GetMostVisitedWithin_NonPositiveLimitIsEmpty(t *testing.T) {
1227+ ctx := historyTestCtx()
1228+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1229+ require.NoError(t, err)
1230+ t.Cleanup(func() { _ = db.Close() })
1231+ repo := sqlite.NewHistoryRepository(db)
1232+
1233+ now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1234+ insertHistoryRow(t, ctx, db, "https://example.com", "Example", 5, now.Add(-time.Hour))
1235+
1236+ unbounded, err := repo.GetMostVisitedWithin(ctx, 0, repository.HistoryScope{})
1237+ require.NoError(t, err)
1238+ assert.Empty(t, unbounded)
1239+
1240+ bounded, err := repo.GetMostVisitedWithin(ctx, -5, repository.HistoryScope{Cutoff: now.AddDate(0, 0, -30)})
1241+ require.NoError(t, err)
1242+ assert.Empty(t, bounded)
1243+}
1244+
1245+// queryPlan records the actual driver's EXPLAIN QUERY PLAN output for a query.
1246+func queryPlan(t *testing.T, ctx context.Context, db *sql.DB, query string, args ...any) []string {
1247+ t.Helper()
1248+
1249+ rows, err := db.QueryContext(ctx, "EXPLAIN QUERY PLAN "+query, args...)
1250+ require.NoError(t, err)
1251+ defer func() { _ = rows.Close() }()
1252+
1253+ var details []string
1254+ for rows.Next() {
1255+ var id, parent, notUsed int
1256+ var detail string
1257+ require.NoError(t, rows.Scan(&id, &parent, &notUsed, &detail))
1258+ details = append(details, detail)
1259+ }
1260+ require.NoError(t, rows.Err())
1261+ return details
1262+}
1263+
1264+func TestHistoryRepository_BoundedRecentPlanUsesCutoffIndex(t *testing.T) {
1265+ ctx := historyTestCtx()
1266+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1267+ require.NoError(t, err)
1268+ t.Cleanup(func() { _ = db.Close() })
1269+
1270+ // Exercise the exact generated statement that historyRepo.GetRecent runs for
1271+ // a bounded scope (see GetRecentHistorySinceCutoff), passed verbatim including
1272+ // sqlc's leading `-- name:` comment. This fails to compile if the bounded
1273+ // variant disappears, and the plan assertions fail if it regresses to a
1274+ // nullable `(? IS NULL OR last_visited >= ?)` predicate, which plans as a
1275+ // SCAN using idx_history_last_visited rather than a SEARCH.
1276+ cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1277+ plan := queryPlan(t, ctx, db, sqlc.GetRecentHistorySinceCutoff, sql.NullTime{Time: cutoff.UTC(), Valid: true}, 0, 50)
1278+
1279+ joined := strings.Join(plan, "\n")
1280+ require.Contains(t, joined, "SEARCH", "bounded recent query must use an index search")
1281+ require.Contains(t, joined, "idx_history_last_visited", "bounded recent query must use the last_visited index")
1282+}
1283+
1284+func TestHistoryRepository_ScopeCutoffSubSecondNormalization(t *testing.T) {
1285+ ctx := historyTestCtx()
1286+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1287+ require.NoError(t, err)
1288+ t.Cleanup(func() { _ = db.Close() })
1289+ repo := sqlite.NewHistoryRepository(db)
1290+
1291+ // Stored at exactly 12:00:05 with no fractional part, matching
1292+ // CURRENT_TIMESTAMP.
1293+ stored := time.Date(2026, 6, 24, 12, 0, 5, 0, time.UTC)
1294+ insertHistoryRow(t, ctx, db, "https://subsecond.example.com/marker", "Subsecond Marker", 1, stored)
1295+ scope := repository.HistoryScope{Cutoff: stored.Add(500 * time.Millisecond)}
1296+
1297+ recent, err := repo.GetRecent(ctx, 10, 0, scope)
1298+ require.NoError(t, err)
1299+ require.Len(t, recent, 1, "a sub-second cutoff must normalize to the stored second and include the row")
1300+ assert.Equal(t, "https://subsecond.example.com/marker", recent[0].URL)
1301+
1302+ mostVisited, err := repo.GetMostVisitedWithin(ctx, 10, scope)
1303+ require.NoError(t, err)
1304+ require.Len(t, mostVisited, 1)
1305+
1306+ matches, err := repo.Search(ctx, "subsecond", 10, scope)
1307+ require.NoError(t, err)
1308+ require.Len(t, matches, 1, "the shared FTS cutoff helper must apply the same normalization")
1309+ assert.Equal(t, "https://subsecond.example.com/marker", matches[0].Entry.URL)
1310+}
M internal/infrastructure/persistence/sqlite/lazy_repos.go +24 -4
@@ -135,18 +135,27 @@ func (r *LazyHistoryRepository) UpdateMetadata(ctx context.Context, entry *entit
135135 return r.repo.Save(ctx, entry)
136136 }
137137
138-func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
138+func (r *LazyHistoryRepository) Search(
139+ ctx context.Context,
140+ query string,
141+ limit int,
142+ scope repository.HistoryScope,
143+) ([]entity.HistoryMatch, error) {
139144 if err := r.init(ctx); err != nil {
140145 return nil, err
141146 }
142- return r.repo.Search(ctx, query, limit)
147+ return r.repo.Search(ctx, query, limit, scope)
143148 }
144149
145-func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
150+func (r *LazyHistoryRepository) GetRecent(
151+ ctx context.Context,
152+ limit, offset int,
153+ scope repository.HistoryScope,
154+) ([]*entity.HistoryEntry, error) {
146155 if err := r.init(ctx); err != nil {
147156 return nil, err
148157 }
149- return r.repo.GetRecent(ctx, limit, offset)
158+ return r.repo.GetRecent(ctx, limit, offset, scope)
150159 }
151160
152161 func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
@@ -195,6 +204,17 @@ func (r *LazyHistoryRepository) GetMostVisited(ctx context.Context, days int) ([
195204 return r.repo.GetMostVisited(ctx, days)
196205 }
197206
207+func (r *LazyHistoryRepository) GetMostVisitedWithin(
208+ ctx context.Context,
209+ limit int,
210+ scope repository.HistoryScope,
211+) ([]*entity.HistoryEntry, error) {
212+ if err := r.init(ctx); err != nil {
213+ return nil, err
214+ }
215+ return r.repo.GetMostVisitedWithin(ctx, limit, scope)
216+}
217+
198218 func (r *LazyHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
199219 if err := r.init(ctx); err != nil {
200220 return nil, err
@@ -135,18 +135,27 @@ func (r *LazyHistoryRepository) UpdateMetadata(ctx context.Context, entry *entit
135 return r.repo.Save(ctx, entry)135 return r.repo.Save(ctx, entry)
136 }136 }
137 137
138-func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {138+func (r *LazyHistoryRepository) Search(
139+ ctx context.Context,
140+ query string,
141+ limit int,
142+ scope repository.HistoryScope,
143+) ([]entity.HistoryMatch, error) {
139 if err := r.init(ctx); err != nil {144 if err := r.init(ctx); err != nil {
140 return nil, err145 return nil, err
141 }146 }
142- return r.repo.Search(ctx, query, limit)147+ return r.repo.Search(ctx, query, limit, scope)
143 }148 }
144 149
145-func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {150+func (r *LazyHistoryRepository) GetRecent(
151+ ctx context.Context,
152+ limit, offset int,
153+ scope repository.HistoryScope,
154+) ([]*entity.HistoryEntry, error) {
146 if err := r.init(ctx); err != nil {155 if err := r.init(ctx); err != nil {
147 return nil, err156 return nil, err
148 }157 }
149- return r.repo.GetRecent(ctx, limit, offset)158+ return r.repo.GetRecent(ctx, limit, offset, scope)
150 }159 }
151 160
152 func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {161 func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
@@ -195,6 +204,17 @@ func (r *LazyHistoryRepository) GetMostVisited(ctx context.Context, days int) ([
195 return r.repo.GetMostVisited(ctx, days)204 return r.repo.GetMostVisited(ctx, days)
196 }205 }
197 206
207+func (r *LazyHistoryRepository) GetMostVisitedWithin(
208+ ctx context.Context,
209+ limit int,
210+ scope repository.HistoryScope,
211+) ([]*entity.HistoryEntry, error) {
212+ if err := r.init(ctx); err != nil {
213+ return nil, err
214+ }
215+ return r.repo.GetMostVisitedWithin(ctx, limit, scope)
216+}
217+
198 func (r *LazyHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {218 func (r *LazyHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
199 if err := r.init(ctx); err != nil {219 if err := r.init(ctx); err != nil {
200 return nil, err220 return nil, err
M internal/infrastructure/persistence/sqlite/queries/history.sql +23 -8
@@ -3,6 +3,14 @@ SELECT * FROM history
33 ORDER BY last_visited DESC, id DESC
44 LIMIT ? OFFSET ?;
55
6+-- name: GetRecentHistorySinceCutoff :many
7+-- Bounded window variant: the direct comparison keeps the last_visited index
8+-- usable. Rows with a NULL last_visited are intentionally excluded.
9+SELECT * FROM history
10+WHERE last_visited >= @cutoff
11+ORDER BY last_visited DESC, id DESC
12+LIMIT @limit OFFSET @offset;
13+
614 -- name: GetRecentHistoryByDomain :many
715 SELECT * FROM history
816 WHERE domain = @domain
@@ -108,14 +116,6 @@ ORDER BY day ASC;
108116 -- name: DeleteHistoryByDomain :exec
109117 DELETE FROM history WHERE domain = @domain;
110118
111--- name: SearchHistoryFTSUrl :many
112-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
113-FROM history_fts fts
114-JOIN history h ON fts.rowid = h.id
115-WHERE fts.url MATCH @query
116-ORDER BY h.visit_count DESC, h.last_visited DESC
117-LIMIT @limit;
118-
119119 -- name: SearchHistoryFTSUrlWithDomainBoost :many
120120 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at,
121121 CASE
@@ -128,6 +128,7 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre
128128 FROM history_fts fts
129129 JOIN history h ON fts.rowid = h.id
130130 WHERE fts.url MATCH @query
131+ AND (@cutoff IS NULL OR h.last_visited >= @cutoff)
131132 ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC
132133 LIMIT @limit;
133134
@@ -136,6 +137,7 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre
136137 FROM history_fts fts
137138 JOIN history h ON fts.rowid = h.id
138139 WHERE fts.title MATCH @query
140+ AND (@cutoff IS NULL OR h.last_visited >= @cutoff)
139141 ORDER BY h.visit_count DESC, h.last_visited DESC
140142 LIMIT @limit;
141143
@@ -157,5 +159,18 @@ ORDER BY last_visited DESC, id DESC;
157159 SELECT * FROM history
158160 ORDER BY visit_count DESC, last_visited DESC;
159161
162+-- name: GetMostVisitedHistory :many
163+SELECT * FROM history
164+ORDER BY visit_count DESC, last_visited DESC
165+LIMIT @limit;
166+
167+-- name: GetMostVisitedHistorySinceCutoff :many
168+-- Bounded window variant: the direct comparison keeps the last_visited index
169+-- usable. Rows with a NULL last_visited are intentionally excluded.
170+SELECT * FROM history
171+WHERE last_visited >= @cutoff
172+ORDER BY visit_count DESC, last_visited DESC
173+LIMIT @limit;
174+
160175 -- name: CapVisitCount :exec
161176 UPDATE history SET visit_count = ? WHERE url = ? AND visit_count > ?;
@@ -3,6 +3,14 @@ SELECT * FROM history
3 ORDER BY last_visited DESC, id DESC3 ORDER BY last_visited DESC, id DESC
4 LIMIT ? OFFSET ?;4 LIMIT ? OFFSET ?;
5 5
6+-- name: GetRecentHistorySinceCutoff :many
7+-- Bounded window variant: the direct comparison keeps the last_visited index
8+-- usable. Rows with a NULL last_visited are intentionally excluded.
9+SELECT * FROM history
10+WHERE last_visited >= @cutoff
11+ORDER BY last_visited DESC, id DESC
12+LIMIT @limit OFFSET @offset;
13+
6 -- name: GetRecentHistoryByDomain :many14 -- name: GetRecentHistoryByDomain :many
7 SELECT * FROM history15 SELECT * FROM history
8 WHERE domain = @domain16 WHERE domain = @domain
@@ -108,14 +116,6 @@ ORDER BY day ASC;
108 -- name: DeleteHistoryByDomain :exec116 -- name: DeleteHistoryByDomain :exec
109 DELETE FROM history WHERE domain = @domain;117 DELETE FROM history WHERE domain = @domain;
110 118
111--- name: SearchHistoryFTSUrl :many
112-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
113-FROM history_fts fts
114-JOIN history h ON fts.rowid = h.id
115-WHERE fts.url MATCH @query
116-ORDER BY h.visit_count DESC, h.last_visited DESC
117-LIMIT @limit;
118-
119 -- name: SearchHistoryFTSUrlWithDomainBoost :many119 -- name: SearchHistoryFTSUrlWithDomainBoost :many
120 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at,120 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at,
121 CASE121 CASE
@@ -128,6 +128,7 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre
128 FROM history_fts fts128 FROM history_fts fts
129 JOIN history h ON fts.rowid = h.id129 JOIN history h ON fts.rowid = h.id
130 WHERE fts.url MATCH @query130 WHERE fts.url MATCH @query
131+ AND (@cutoff IS NULL OR h.last_visited >= @cutoff)
131 ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC132 ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC
132 LIMIT @limit;133 LIMIT @limit;
133 134
@@ -136,6 +137,7 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre
136 FROM history_fts fts137 FROM history_fts fts
137 JOIN history h ON fts.rowid = h.id138 JOIN history h ON fts.rowid = h.id
138 WHERE fts.title MATCH @query139 WHERE fts.title MATCH @query
140+ AND (@cutoff IS NULL OR h.last_visited >= @cutoff)
139 ORDER BY h.visit_count DESC, h.last_visited DESC141 ORDER BY h.visit_count DESC, h.last_visited DESC
140 LIMIT @limit;142 LIMIT @limit;
141 143
@@ -157,5 +159,18 @@ ORDER BY last_visited DESC, id DESC;
157 SELECT * FROM history159 SELECT * FROM history
158 ORDER BY visit_count DESC, last_visited DESC;160 ORDER BY visit_count DESC, last_visited DESC;
159 161
162+-- name: GetMostVisitedHistory :many
163+SELECT * FROM history
164+ORDER BY visit_count DESC, last_visited DESC
165+LIMIT @limit;
166+
167+-- name: GetMostVisitedHistorySinceCutoff :many
168+-- Bounded window variant: the direct comparison keeps the last_visited index
169+-- usable. Rows with a NULL last_visited are intentionally excluded.
170+SELECT * FROM history
171+WHERE last_visited >= @cutoff
172+ORDER BY visit_count DESC, last_visited DESC
173+LIMIT @limit;
174+
160 -- name: CapVisitCount :exec175 -- name: CapVisitCount :exec
161 UPDATE history SET visit_count = ? WHERE url = ? AND visit_count > ?;176 UPDATE history SET visit_count = ? WHERE url = ? AND visit_count > ?;
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +149 -55
@@ -386,6 +386,90 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H
386386 return items, nil
387387 }
388388
389+const GetMostVisitedHistory = `-- name: GetMostVisitedHistory :many
390+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
391+ORDER BY visit_count DESC, last_visited DESC
392+LIMIT ?1
393+`
394+
395+func (q *Queries) GetMostVisitedHistory(ctx context.Context, limit int64) ([]History, error) {
396+ rows, err := q.db.QueryContext(ctx, GetMostVisitedHistory, limit)
397+ if err != nil {
398+ return nil, err
399+ }
400+ defer rows.Close()
401+ items := []History{}
402+ for rows.Next() {
403+ var i History
404+ if err := rows.Scan(
405+ &i.ID,
406+ &i.Url,
407+ &i.Title,
408+ &i.FaviconUrl,
409+ &i.VisitCount,
410+ &i.LastVisited,
411+ &i.CreatedAt,
412+ &i.Domain,
413+ ); err != nil {
414+ return nil, err
415+ }
416+ items = append(items, i)
417+ }
418+ if err := rows.Close(); err != nil {
419+ return nil, err
420+ }
421+ if err := rows.Err(); err != nil {
422+ return nil, err
423+ }
424+ return items, nil
425+}
426+
427+const GetMostVisitedHistorySinceCutoff = `-- name: GetMostVisitedHistorySinceCutoff :many
428+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
429+WHERE last_visited >= ?1
430+ORDER BY visit_count DESC, last_visited DESC
431+LIMIT ?2
432+`
433+
434+type GetMostVisitedHistorySinceCutoffParams struct {
435+ Cutoff sql.NullTime `json:"cutoff"`
436+ Limit int64 `json:"limit"`
437+}
438+
439+// Bounded window variant: the direct comparison keeps the last_visited index
440+// usable. Rows with a NULL last_visited are intentionally excluded.
441+func (q *Queries) GetMostVisitedHistorySinceCutoff(ctx context.Context, arg GetMostVisitedHistorySinceCutoffParams) ([]History, error) {
442+ rows, err := q.db.QueryContext(ctx, GetMostVisitedHistorySinceCutoff, arg.Cutoff, arg.Limit)
443+ if err != nil {
444+ return nil, err
445+ }
446+ defer rows.Close()
447+ items := []History{}
448+ for rows.Next() {
449+ var i History
450+ if err := rows.Scan(
451+ &i.ID,
452+ &i.Url,
453+ &i.Title,
454+ &i.FaviconUrl,
455+ &i.VisitCount,
456+ &i.LastVisited,
457+ &i.CreatedAt,
458+ &i.Domain,
459+ ); err != nil {
460+ return nil, err
461+ }
462+ items = append(items, i)
463+ }
464+ if err := rows.Close(); err != nil {
465+ return nil, err
466+ }
467+ if err := rows.Err(); err != nil {
468+ return nil, err
469+ }
470+ return items, nil
471+}
472+
389473 const GetRecentHistory = `-- name: GetRecentHistory :many
390474 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
391475 ORDER BY last_visited DESC, id DESC
@@ -512,6 +596,53 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{
512596 return items, nil
513597 }
514598
599+const GetRecentHistorySinceCutoff = `-- name: GetRecentHistorySinceCutoff :many
600+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
601+WHERE last_visited >= ?1
602+ORDER BY last_visited DESC, id DESC
603+LIMIT ?3 OFFSET ?2
604+`
605+
606+type GetRecentHistorySinceCutoffParams struct {
607+ Cutoff sql.NullTime `json:"cutoff"`
608+ Offset int64 `json:"offset"`
609+ Limit int64 `json:"limit"`
610+}
611+
612+// Bounded window variant: the direct comparison keeps the last_visited index
613+// usable. Rows with a NULL last_visited are intentionally excluded.
614+func (q *Queries) GetRecentHistorySinceCutoff(ctx context.Context, arg GetRecentHistorySinceCutoffParams) ([]History, error) {
615+ rows, err := q.db.QueryContext(ctx, GetRecentHistorySinceCutoff, arg.Cutoff, arg.Offset, arg.Limit)
616+ if err != nil {
617+ return nil, err
618+ }
619+ defer rows.Close()
620+ items := []History{}
621+ for rows.Next() {
622+ var i History
623+ if err := rows.Scan(
624+ &i.ID,
625+ &i.Url,
626+ &i.Title,
627+ &i.FaviconUrl,
628+ &i.VisitCount,
629+ &i.LastVisited,
630+ &i.CreatedAt,
631+ &i.Domain,
632+ ); err != nil {
633+ return nil, err
634+ }
635+ items = append(items, i)
636+ }
637+ if err := rows.Close(); err != nil {
638+ return nil, err
639+ }
640+ if err := rows.Err(); err != nil {
641+ return nil, err
642+ }
643+ return items, nil
644+}
645+
515646 const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many
516647 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
517648 WHERE last_visited < ?1 OR (last_visited = ?1 AND id < ?2)
@@ -685,63 +816,19 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre
685816 FROM history_fts fts
686817 JOIN history h ON fts.rowid = h.id
687818 WHERE fts.title MATCH ?1
819+ AND (?2 IS NULL OR h.last_visited >= ?2)
688820 ORDER BY h.visit_count DESC, h.last_visited DESC
689-LIMIT ?2
821+LIMIT ?3
690822 `
691823
692824 type SearchHistoryFTSTitleParams struct {
693- Query string `json:"query"`
694- Limit int64 `json:"limit"`
825+ Query string `json:"query"`
826+ Cutoff interface{} `json:"cutoff"`
827+ Limit int64 `json:"limit"`
695828 }
696829
697830 func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) {
698- rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Limit)
699- if err != nil {
700- return nil, err
701- }
702- defer rows.Close()
703- items := []History{}
704- for rows.Next() {
705- var i History
706- if err := rows.Scan(
707- &i.ID,
708- &i.Url,
709- &i.Title,
710- &i.FaviconUrl,
711- &i.VisitCount,
712- &i.LastVisited,
713- &i.CreatedAt,
714- &i.Domain,
715- ); err != nil {
716- return nil, err
717- }
718- items = append(items, i)
719- }
720- if err := rows.Close(); err != nil {
721- return nil, err
722- }
723- if err := rows.Err(); err != nil {
724- return nil, err
725- }
726- return items, nil
727-}
728-
729-const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many
730-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
731-FROM history_fts fts
732-JOIN history h ON fts.rowid = h.id
733-WHERE fts.url MATCH ?1
734-ORDER BY h.visit_count DESC, h.last_visited DESC
735-LIMIT ?2
736-`
737-
738-type SearchHistoryFTSUrlParams struct {
739- Query string `json:"query"`
740- Limit int64 `json:"limit"`
741-}
742-
743-func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) {
744- rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit)
831+ rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Cutoff, arg.Limit)
745832 if err != nil {
746833 return nil, err
747834 }
@@ -784,14 +871,16 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre
784871 FROM history_fts fts
785872 JOIN history h ON fts.rowid = h.id
786873 WHERE fts.url MATCH ?2
874+ AND (?3 IS NULL OR h.last_visited >= ?3)
787875 ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC
788-LIMIT ?3
876+LIMIT ?4
789877 `
790878
791879 type SearchHistoryFTSUrlWithDomainBoostParams struct {
792- Term sql.NullString `json:"term"`
793- Query string `json:"query"`
794- Limit int64 `json:"limit"`
880+ Term sql.NullString `json:"term"`
881+ Query string `json:"query"`
882+ Cutoff interface{} `json:"cutoff"`
883+ Limit int64 `json:"limit"`
795884 }
796885
797886 type SearchHistoryFTSUrlWithDomainBoostRow struct {
@@ -806,7 +895,12 @@ type SearchHistoryFTSUrlWithDomainBoostRow struct {
806895 }
807896
808897 func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) {
809- rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrlWithDomainBoost, arg.Term, arg.Query, arg.Limit)
898+ rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrlWithDomainBoost,
899+ arg.Term,
900+ arg.Query,
901+ arg.Cutoff,
902+ arg.Limit,
903+ )
810904 if err != nil {
811905 return nil, err
812906 }
@@ -386,6 +386,90 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H
386 return items, nil386 return items, nil
387 }387 }
388 388
389+const GetMostVisitedHistory = `-- name: GetMostVisitedHistory :many
390+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
391+ORDER BY visit_count DESC, last_visited DESC
392+LIMIT ?1
393+`
394+
395+func (q *Queries) GetMostVisitedHistory(ctx context.Context, limit int64) ([]History, error) {
396+ rows, err := q.db.QueryContext(ctx, GetMostVisitedHistory, limit)
397+ if err != nil {
398+ return nil, err
399+ }
400+ defer rows.Close()
401+ items := []History{}
402+ for rows.Next() {
403+ var i History
404+ if err := rows.Scan(
405+ &i.ID,
406+ &i.Url,
407+ &i.Title,
408+ &i.FaviconUrl,
409+ &i.VisitCount,
410+ &i.LastVisited,
411+ &i.CreatedAt,
412+ &i.Domain,
413+ ); err != nil {
414+ return nil, err
415+ }
416+ items = append(items, i)
417+ }
418+ if err := rows.Close(); err != nil {
419+ return nil, err
420+ }
421+ if err := rows.Err(); err != nil {
422+ return nil, err
423+ }
424+ return items, nil
425+}
426+
427+const GetMostVisitedHistorySinceCutoff = `-- name: GetMostVisitedHistorySinceCutoff :many
428+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
429+WHERE last_visited >= ?1
430+ORDER BY visit_count DESC, last_visited DESC
431+LIMIT ?2
432+`
433+
434+type GetMostVisitedHistorySinceCutoffParams struct {
435+ Cutoff sql.NullTime `json:"cutoff"`
436+ Limit int64 `json:"limit"`
437+}
438+
439+// Bounded window variant: the direct comparison keeps the last_visited index
440+// usable. Rows with a NULL last_visited are intentionally excluded.
441+func (q *Queries) GetMostVisitedHistorySinceCutoff(ctx context.Context, arg GetMostVisitedHistorySinceCutoffParams) ([]History, error) {
442+ rows, err := q.db.QueryContext(ctx, GetMostVisitedHistorySinceCutoff, arg.Cutoff, arg.Limit)
443+ if err != nil {
444+ return nil, err
445+ }
446+ defer rows.Close()
447+ items := []History{}
448+ for rows.Next() {
449+ var i History
450+ if err := rows.Scan(
451+ &i.ID,
452+ &i.Url,
453+ &i.Title,
454+ &i.FaviconUrl,
455+ &i.VisitCount,
456+ &i.LastVisited,
457+ &i.CreatedAt,
458+ &i.Domain,
459+ ); err != nil {
460+ return nil, err
461+ }
462+ items = append(items, i)
463+ }
464+ if err := rows.Close(); err != nil {
465+ return nil, err
466+ }
467+ if err := rows.Err(); err != nil {
468+ return nil, err
469+ }
470+ return items, nil
471+}
472+
389 const GetRecentHistory = `-- name: GetRecentHistory :many473 const GetRecentHistory = `-- name: GetRecentHistory :many
390 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history474 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
391 ORDER BY last_visited DESC, id DESC475 ORDER BY last_visited DESC, id DESC
@@ -512,6 +596,53 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{
512 return items, nil596 return items, nil
513 }597 }
514 598
599+const GetRecentHistorySinceCutoff = `-- name: GetRecentHistorySinceCutoff :many
600+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
601+WHERE last_visited >= ?1
602+ORDER BY last_visited DESC, id DESC
603+LIMIT ?3 OFFSET ?2
604+`
605+
606+type GetRecentHistorySinceCutoffParams struct {
607+ Cutoff sql.NullTime `json:"cutoff"`
608+ Offset int64 `json:"offset"`
609+ Limit int64 `json:"limit"`
610+}
611+
612+// Bounded window variant: the direct comparison keeps the last_visited index
613+// usable. Rows with a NULL last_visited are intentionally excluded.
614+func (q *Queries) GetRecentHistorySinceCutoff(ctx context.Context, arg GetRecentHistorySinceCutoffParams) ([]History, error) {
615+ rows, err := q.db.QueryContext(ctx, GetRecentHistorySinceCutoff, arg.Cutoff, arg.Offset, arg.Limit)
616+ if err != nil {
617+ return nil, err
618+ }
619+ defer rows.Close()
620+ items := []History{}
621+ for rows.Next() {
622+ var i History
623+ if err := rows.Scan(
624+ &i.ID,
625+ &i.Url,
626+ &i.Title,
627+ &i.FaviconUrl,
628+ &i.VisitCount,
629+ &i.LastVisited,
630+ &i.CreatedAt,
631+ &i.Domain,
632+ ); err != nil {
633+ return nil, err
634+ }
635+ items = append(items, i)
636+ }
637+ if err := rows.Close(); err != nil {
638+ return nil, err
639+ }
640+ if err := rows.Err(); err != nil {
641+ return nil, err
642+ }
643+ return items, nil
644+}
645+
515 const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many646 const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many
516 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history647 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
517 WHERE last_visited < ?1 OR (last_visited = ?1 AND id < ?2)648 WHERE last_visited < ?1 OR (last_visited = ?1 AND id < ?2)
@@ -685,63 +816,19 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre
685 FROM history_fts fts816 FROM history_fts fts
686 JOIN history h ON fts.rowid = h.id817 JOIN history h ON fts.rowid = h.id
687 WHERE fts.title MATCH ?1818 WHERE fts.title MATCH ?1
819+ AND (?2 IS NULL OR h.last_visited >= ?2)
688 ORDER BY h.visit_count DESC, h.last_visited DESC820 ORDER BY h.visit_count DESC, h.last_visited DESC
689-LIMIT ?2821+LIMIT ?3
690 `822 `
691 823
692 type SearchHistoryFTSTitleParams struct {824 type SearchHistoryFTSTitleParams struct {
693- Query string `json:"query"`825+ Query string `json:"query"`
694- Limit int64 `json:"limit"`826+ Cutoff interface{} `json:"cutoff"`
827+ Limit int64 `json:"limit"`
695 }828 }
696 829
697 func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) {830 func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) {
698- rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Limit)831+ rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Cutoff, arg.Limit)
699- if err != nil {
700- return nil, err
701- }
702- defer rows.Close()
703- items := []History{}
704- for rows.Next() {
705- var i History
706- if err := rows.Scan(
707- &i.ID,
708- &i.Url,
709- &i.Title,
710- &i.FaviconUrl,
711- &i.VisitCount,
712- &i.LastVisited,
713- &i.CreatedAt,
714- &i.Domain,
715- ); err != nil {
716- return nil, err
717- }
718- items = append(items, i)
719- }
720- if err := rows.Close(); err != nil {
721- return nil, err
722- }
723- if err := rows.Err(); err != nil {
724- return nil, err
725- }
726- return items, nil
727-}
728-
729-const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many
730-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
731-FROM history_fts fts
732-JOIN history h ON fts.rowid = h.id
733-WHERE fts.url MATCH ?1
734-ORDER BY h.visit_count DESC, h.last_visited DESC
735-LIMIT ?2
736-`
737-
738-type SearchHistoryFTSUrlParams struct {
739- Query string `json:"query"`
740- Limit int64 `json:"limit"`
741-}
742-
743-func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) {
744- rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit)
745 if err != nil {832 if err != nil {
746 return nil, err833 return nil, err
747 }834 }
@@ -784,14 +871,16 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre
784 FROM history_fts fts871 FROM history_fts fts
785 JOIN history h ON fts.rowid = h.id872 JOIN history h ON fts.rowid = h.id
786 WHERE fts.url MATCH ?2873 WHERE fts.url MATCH ?2
874+ AND (?3 IS NULL OR h.last_visited >= ?3)
787 ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC875 ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC
788-LIMIT ?3876+LIMIT ?4
789 `877 `
790 878
791 type SearchHistoryFTSUrlWithDomainBoostParams struct {879 type SearchHistoryFTSUrlWithDomainBoostParams struct {
792- Term sql.NullString `json:"term"`880+ Term sql.NullString `json:"term"`
793- Query string `json:"query"`881+ Query string `json:"query"`
794- Limit int64 `json:"limit"`882+ Cutoff interface{} `json:"cutoff"`
883+ Limit int64 `json:"limit"`
795 }884 }
796 885
797 type SearchHistoryFTSUrlWithDomainBoostRow struct {886 type SearchHistoryFTSUrlWithDomainBoostRow struct {
@@ -806,7 +895,12 @@ type SearchHistoryFTSUrlWithDomainBoostRow struct {
806 }895 }
807 896
808 func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) {897 func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) {
809- rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrlWithDomainBoost, arg.Term, arg.Query, arg.Limit)898+ rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrlWithDomainBoost,
899+ arg.Term,
900+ arg.Query,
901+ arg.Cutoff,
902+ arg.Limit,
903+ )
810 if err != nil {904 if err != nil {
811 return nil, err905 return nil, err
812 }906 }
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +7 -1
@@ -50,10 +50,17 @@ type Querier interface {
5050 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
5151 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
5252 GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
53+ GetMostVisitedHistory(ctx context.Context, limit int64) ([]History, error)
54+ // Bounded window variant: the direct comparison keeps the last_visited index
55+ // usable. Rows with a NULL last_visited are intentionally excluded.
56+ GetMostVisitedHistorySinceCutoff(ctx context.Context, arg GetMostVisitedHistorySinceCutoffParams) ([]History, error)
5357 GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error)
5458 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
5559 GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error)
5660 GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
61+ // Bounded window variant: the direct comparison keeps the last_visited index
62+ // usable. Rows with a NULL last_visited are intentionally excluded.
63+ GetRecentHistorySinceCutoff(ctx context.Context, arg GetRecentHistorySinceCutoffParams) ([]History, error)
5764 GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error)
5865 GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error)
5966 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
@@ -78,7 +85,6 @@ type Querier interface {
7885 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
7986 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
8087 SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)
81- SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)
8288 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)
8389 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
8490 SetPermission(ctx context.Context, arg SetPermissionParams) error
@@ -50,10 +50,17 @@ type Querier interface {
50 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)50 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
51 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)51 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
52 GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)52 GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
53+ GetMostVisitedHistory(ctx context.Context, limit int64) ([]History, error)
54+ // Bounded window variant: the direct comparison keeps the last_visited index
55+ // usable. Rows with a NULL last_visited are intentionally excluded.
56+ GetMostVisitedHistorySinceCutoff(ctx context.Context, arg GetMostVisitedHistorySinceCutoffParams) ([]History, error)
53 GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error)57 GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error)
54 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)58 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
55 GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error)59 GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error)
56 GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)60 GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
61+ // Bounded window variant: the direct comparison keeps the last_visited index
62+ // usable. Rows with a NULL last_visited are intentionally excluded.
63+ GetRecentHistorySinceCutoff(ctx context.Context, arg GetRecentHistorySinceCutoffParams) ([]History, error)
57 GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error)64 GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error)
58 GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error)65 GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error)
59 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)66 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
@@ -78,7 +85,6 @@ type Querier interface {
78 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error85 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
79 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)86 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
80 SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)87 SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)
81- SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)
82 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)88 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)
83 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error89 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
84 SetPermission(ctx context.Context, arg SetPermissionParams) error90 SetPermission(ctx context.Context, arg SetPermissionParams) error
M internal/ui/app.go +1 -1
@@ -1318,7 +1318,7 @@ func buildOmniboxConfig(
13181318 DefaultSearch: runtimeCfg.DefaultSearchEngine,
13191319 NormalizeNavigationURL: callbacks.NormalizeNavigationURL,
13201320 InitialBehavior: runtimeCfg.Omnibox.InitialBehavior,
1321- MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays,
1321+ MaxHistoryDays: runtimeCfg.Omnibox.MaxHistoryDays,
13221322 SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,
13231323 UIScale: runtimeCfg.DefaultUIScale,
13241324 OnNavigate: callbacks.OnNavigate,
@@ -1318,7 +1318,7 @@ func buildOmniboxConfig(
1318 DefaultSearch: runtimeCfg.DefaultSearchEngine,1318 DefaultSearch: runtimeCfg.DefaultSearchEngine,
1319 NormalizeNavigationURL: callbacks.NormalizeNavigationURL,1319 NormalizeNavigationURL: callbacks.NormalizeNavigationURL,
1320 InitialBehavior: runtimeCfg.Omnibox.InitialBehavior,1320 InitialBehavior: runtimeCfg.Omnibox.InitialBehavior,
1321- MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays,1321+ MaxHistoryDays: runtimeCfg.Omnibox.MaxHistoryDays,
1322 SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,1322 SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,
1323 UIScale: runtimeCfg.DefaultUIScale,1323 UIScale: runtimeCfg.DefaultUIScale,
1324 OnNavigate: callbacks.OnNavigate,1324 OnNavigate: callbacks.OnNavigate,
M internal/ui/app_omnibox_config_test.go +3 -3
@@ -21,7 +21,7 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) {
2121 Omnibox: entity.RuntimeOmniboxConfig{
2222 Style: "command",
2323 InitialBehavior: entity.OmniboxInitialBehaviorMostVisited,
24- MostVisitedDays: 7,
24+ MaxHistoryDays: 7,
2525 },
2626 }
2727
@@ -33,8 +33,8 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) {
3333 if got.Style != "command" {
3434 t.Fatalf("Style = %q, want command", got.Style)
3535 }
36- if got.MostVisitedDays != 7 {
37- t.Fatalf("MostVisitedDays = %d, want 7", got.MostVisitedDays)
36+ if got.MaxHistoryDays != 7 {
37+ t.Fatalf("MaxHistoryDays = %d, want 7", got.MaxHistoryDays)
3838 }
3939 if got.DefaultSearch != "https://search.example/?q=%s" {
4040 t.Fatalf("DefaultSearch = %q, want runtime default search", got.DefaultSearch)
@@ -21,7 +21,7 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) {
21 Omnibox: entity.RuntimeOmniboxConfig{21 Omnibox: entity.RuntimeOmniboxConfig{
22 Style: "command",22 Style: "command",
23 InitialBehavior: entity.OmniboxInitialBehaviorMostVisited,23 InitialBehavior: entity.OmniboxInitialBehaviorMostVisited,
24- MostVisitedDays: 7,24+ MaxHistoryDays: 7,
25 },25 },
26 }26 }
27 27
@@ -33,8 +33,8 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) {
33 if got.Style != "command" {33 if got.Style != "command" {
34 t.Fatalf("Style = %q, want command", got.Style)34 t.Fatalf("Style = %q, want command", got.Style)
35 }35 }
36- if got.MostVisitedDays != 7 {36+ if got.MaxHistoryDays != 7 {
37- t.Fatalf("MostVisitedDays = %d, want 7", got.MostVisitedDays)37+ t.Fatalf("MaxHistoryDays = %d, want 7", got.MaxHistoryDays)
38 }38 }
39 if got.DefaultSearch != "https://search.example/?q=%s" {39 if got.DefaultSearch != "https://search.example/?q=%s" {
40 t.Fatalf("DefaultSearch = %q, want runtime default search", got.DefaultSearch)40 t.Fatalf("DefaultSearch = %q, want runtime default search", got.DefaultSearch)
M internal/ui/component/omnibox.go +15 -16
@@ -181,7 +181,7 @@ type Omnibox struct {
181181 defaultSearch string
182182 normalizeNavigationURL func(ctx context.Context, input string) string
183183 initialBehavior entity.OmniboxInitialBehavior
184- mostVisitedDays int
184+ maxHistoryDays int
185185 saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error
186186 ctx context.Context
187187
@@ -236,7 +236,7 @@ type OmniboxConfig struct {
236236 // It is injected so local filesystem probing stays outside the domain URL package.
237237 NormalizeNavigationURL func(ctx context.Context, input string) string
238238 InitialBehavior entity.OmniboxInitialBehavior
239- MostVisitedDays int
239+ MaxHistoryDays int
240240 SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error
241241 UIScale float64 // UI scale for favicon sizing
242242 // OnNavigate is called when the user submits a URL; returning nil closes the omnibox.
@@ -270,7 +270,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
270270 defaultSearch: cfg.DefaultSearch,
271271 normalizeNavigationURL: cfg.NormalizeNavigationURL,
272272 initialBehavior: cfg.InitialBehavior,
273- mostVisitedDays: cfg.MostVisitedDays,
273+ maxHistoryDays: cfg.MaxHistoryDays,
274274 saveInitialBehaviorFn: cfg.SaveInitialBehavior,
275275 onToast: cfg.OnToast,
276276 onAccentKeyPress: cfg.OnAccentKeyPress,
@@ -1761,6 +1761,10 @@ func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
17611761 // searchHistory runs a fuzzy history search in a background goroutine.
17621762 // query is the search text; limit caps the number of results.
17631763 func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
1764+ // Capture the age window before spawning so the goroutine never reads
1765+ // mutable widget state.
1766+ maxAgeDays := o.maxHistoryDays
1767+
17641768 go func() {
17651769 ctx := o.ctx
17661770 log := logging.FromContext(ctx)
@@ -1778,8 +1782,9 @@ func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
17781782
17791783 go func() {
17801784 searchInput := usecase.SearchInput{
1781- Query: query,
1782- Limit: limit,
1785+ Query: query,
1786+ Limit: limit,
1787+ MaxAgeDays: maxAgeDays,
17831788 }
17841789 output, err := o.historyUC.Search(ctx, searchInput)
17851790 searchCh <- searchResult{output, err}
@@ -1822,10 +1827,11 @@ func (o *Omnibox) loadInitialHistory(token uint64) {
18221827 return
18231828 }
18241829
1825- // Capture effective result limit on the GTK main thread before spawning goroutine
1830+ // Capture effective result limit and age window on the GTK main thread
1831+ // before spawning goroutine.
18261832 initialLimit := o.effectiveMaxRows()
18271833 initialBehavior := o.initialBehavior
1828- mostVisitedDays := o.mostVisitedDays
1834+ maxHistoryDays := o.maxHistoryDays
18291835
18301836 go func() {
18311837 ctx := o.ctx
@@ -1852,16 +1858,9 @@ func (o *Omnibox) loadInitialHistory(token uint64) {
18521858 err error
18531859 )
18541860 if initialBehavior == entity.OmniboxInitialBehaviorMostVisited {
1855- if mostVisitedDays == 0 {
1856- results, err = o.historyUC.GetMostVisited(ctx, 0)
1857- } else {
1858- results, err = o.historyUC.GetMostVisited(ctx, mostVisitedDays)
1859- }
1860- if err == nil && len(results) > initialLimit {
1861- results = results[:initialLimit]
1862- }
1861+ results, err = o.historyUC.GetMostVisitedWithin(ctx, initialLimit, maxHistoryDays)
18631862 } else {
1864- results, err = o.historyUC.GetRecent(ctx, initialLimit, 0)
1863+ results, err = o.historyUC.GetRecentWithin(ctx, initialLimit, maxHistoryDays)
18651864 }
18661865 historyCh <- historyResult{results, err}
18671866 }()
@@ -181,7 +181,7 @@ type Omnibox struct {
181 defaultSearch string181 defaultSearch string
182 normalizeNavigationURL func(ctx context.Context, input string) string182 normalizeNavigationURL func(ctx context.Context, input string) string
183 initialBehavior entity.OmniboxInitialBehavior183 initialBehavior entity.OmniboxInitialBehavior
184- mostVisitedDays int184+ maxHistoryDays int
185 saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error185 saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error
186 ctx context.Context186 ctx context.Context
187 187
@@ -236,7 +236,7 @@ type OmniboxConfig struct {
236 // It is injected so local filesystem probing stays outside the domain URL package.236 // It is injected so local filesystem probing stays outside the domain URL package.
237 NormalizeNavigationURL func(ctx context.Context, input string) string237 NormalizeNavigationURL func(ctx context.Context, input string) string
238 InitialBehavior entity.OmniboxInitialBehavior238 InitialBehavior entity.OmniboxInitialBehavior
239- MostVisitedDays int239+ MaxHistoryDays int
240 SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error240 SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error
241 UIScale float64 // UI scale for favicon sizing241 UIScale float64 // UI scale for favicon sizing
242 // OnNavigate is called when the user submits a URL; returning nil closes the omnibox.242 // OnNavigate is called when the user submits a URL; returning nil closes the omnibox.
@@ -270,7 +270,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
270 defaultSearch: cfg.DefaultSearch,270 defaultSearch: cfg.DefaultSearch,
271 normalizeNavigationURL: cfg.NormalizeNavigationURL,271 normalizeNavigationURL: cfg.NormalizeNavigationURL,
272 initialBehavior: cfg.InitialBehavior,272 initialBehavior: cfg.InitialBehavior,
273- mostVisitedDays: cfg.MostVisitedDays,273+ maxHistoryDays: cfg.MaxHistoryDays,
274 saveInitialBehaviorFn: cfg.SaveInitialBehavior,274 saveInitialBehaviorFn: cfg.SaveInitialBehavior,
275 onToast: cfg.OnToast,275 onToast: cfg.OnToast,
276 onAccentKeyPress: cfg.OnAccentKeyPress,276 onAccentKeyPress: cfg.OnAccentKeyPress,
@@ -1761,6 +1761,10 @@ func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
1761 // searchHistory runs a fuzzy history search in a background goroutine.1761 // searchHistory runs a fuzzy history search in a background goroutine.
1762 // query is the search text; limit caps the number of results.1762 // query is the search text; limit caps the number of results.
1763 func (o *Omnibox) searchHistory(query string, limit int, token uint64) {1763 func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
1764+ // Capture the age window before spawning so the goroutine never reads
1765+ // mutable widget state.
1766+ maxAgeDays := o.maxHistoryDays
1767+
1764 go func() {1768 go func() {
1765 ctx := o.ctx1769 ctx := o.ctx
1766 log := logging.FromContext(ctx)1770 log := logging.FromContext(ctx)
@@ -1778,8 +1782,9 @@ func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
1778 1782
1779 go func() {1783 go func() {
1780 searchInput := usecase.SearchInput{1784 searchInput := usecase.SearchInput{
1781- Query: query,1785+ Query: query,
1782- Limit: limit,1786+ Limit: limit,
1787+ MaxAgeDays: maxAgeDays,
1783 }1788 }
1784 output, err := o.historyUC.Search(ctx, searchInput)1789 output, err := o.historyUC.Search(ctx, searchInput)
1785 searchCh <- searchResult{output, err}1790 searchCh <- searchResult{output, err}
@@ -1822,10 +1827,11 @@ func (o *Omnibox) loadInitialHistory(token uint64) {
1822 return1827 return
1823 }1828 }
1824 1829
1825- // Capture effective result limit on the GTK main thread before spawning goroutine1830+ // Capture effective result limit and age window on the GTK main thread
1831+ // before spawning goroutine.
1826 initialLimit := o.effectiveMaxRows()1832 initialLimit := o.effectiveMaxRows()
1827 initialBehavior := o.initialBehavior1833 initialBehavior := o.initialBehavior
1828- mostVisitedDays := o.mostVisitedDays1834+ maxHistoryDays := o.maxHistoryDays
1829 1835
1830 go func() {1836 go func() {
1831 ctx := o.ctx1837 ctx := o.ctx
@@ -1852,16 +1858,9 @@ func (o *Omnibox) loadInitialHistory(token uint64) {
1852 err error1858 err error
1853 )1859 )
1854 if initialBehavior == entity.OmniboxInitialBehaviorMostVisited {1860 if initialBehavior == entity.OmniboxInitialBehaviorMostVisited {
1855- if mostVisitedDays == 0 {1861+ results, err = o.historyUC.GetMostVisitedWithin(ctx, initialLimit, maxHistoryDays)
1856- results, err = o.historyUC.GetMostVisited(ctx, 0)
1857- } else {
1858- results, err = o.historyUC.GetMostVisited(ctx, mostVisitedDays)
1859- }
1860- if err == nil && len(results) > initialLimit {
1861- results = results[:initialLimit]
1862- }
1863 } else {1862 } else {
1864- results, err = o.historyUC.GetRecent(ctx, initialLimit, 0)1863+ results, err = o.historyUC.GetRecentWithin(ctx, initialLimit, maxHistoryDays)
1865 }1864 }
1866 historyCh <- historyResult{results, err}1865 historyCh <- historyResult{results, err}
1867 }()1866 }()
M internal/ui/component/omnibox_initial_behavior_test.go +95 -30
@@ -8,6 +8,7 @@ import (
88
99 appusecase "github.com/bnema/dumber/internal/application/usecase"
1010 "github.com/bnema/dumber/internal/domain/entity"
11+ repository "github.com/bnema/dumber/internal/domain/repository"
1112 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
1213 "github.com/stretchr/testify/assert"
1314 "github.com/stretchr/testify/mock"
@@ -159,19 +160,40 @@ func omniboxSuggestionUpdatesInline(o *Omnibox) {
159160 o.scheduleSuggestionUpdate = func(fn func()) { fn() }
160161 }
161162
163+// scopeCutoffWithin matches a history scope whose cutoff is approximately the
164+// given age, tolerating the small delay between test setup and query execution.
165+func scopeCutoffWithin(want, tolerance time.Duration) any {
166+ return mock.MatchedBy(func(scope repository.HistoryScope) bool {
167+ if scope.Cutoff.IsZero() {
168+ return false
169+ }
170+ diff := time.Since(scope.Cutoff) - want
171+ if diff < 0 {
172+ diff = -diff
173+ }
174+ return diff <= tolerance
175+ })
176+}
177+
178+// scopeUnbounded matches a history scope with no age restriction.
179+func scopeUnbounded() any {
180+ return mock.MatchedBy(func(scope repository.HistoryScope) bool {
181+ return scope.Cutoff.IsZero()
182+ })
183+}
184+
162185 func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) {
163186 repo := repomocks.NewMockHistoryRepository(t)
164187 done := make(chan struct{})
165- repo.EXPECT().GetRecent(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
166- func(context.Context, int, int) ([]*entity.HistoryEntry, error) {
167- close(done)
168- return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil
169- },
170- )
188+ repo.EXPECT().
189+ GetRecent(mock.Anything, mock.Anything, 0, mock.Anything).
190+ Run(func(context.Context, int, int, repository.HistoryScope) { close(done) }).
191+ Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
171192
172193 o := &Omnibox{
173194 historyUC: appusecase.NewSearchHistoryUseCase(repo),
174195 initialBehavior: entity.OmniboxInitialBehaviorRecent,
196+ maxHistoryDays: 30,
175197 ctx: context.Background(),
176198 }
177199 omniboxSuggestionUpdatesInline(o)
@@ -186,20 +208,18 @@ func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) {
186208 }
187209 }
188210
189-func TestOmniboxLoadInitialHistory_MostVisitedUsesThirtyDayWindow(t *testing.T) {
211+func TestOmniboxLoadInitialHistory_RecentUsesConfiguredWindow(t *testing.T) {
190212 repo := repomocks.NewMockHistoryRepository(t)
191213 done := make(chan struct{})
192- repo.EXPECT().GetMostVisited(mock.Anything, 30).RunAndReturn(
193- func(context.Context, int) ([]*entity.HistoryEntry, error) {
194- close(done)
195- return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil
196- },
197- )
214+ repo.EXPECT().
215+ GetRecent(mock.Anything, mock.Anything, 0, scopeCutoffWithin(7*24*time.Hour, time.Minute)).
216+ Run(func(context.Context, int, int, repository.HistoryScope) { close(done) }).
217+ Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
198218
199219 o := &Omnibox{
200220 historyUC: appusecase.NewSearchHistoryUseCase(repo),
201- initialBehavior: entity.OmniboxInitialBehaviorMostVisited,
202- mostVisitedDays: 30,
221+ initialBehavior: entity.OmniboxInitialBehaviorRecent,
222+ maxHistoryDays: 7,
203223 ctx: context.Background(),
204224 }
205225 omniboxSuggestionUpdatesInline(o)
@@ -209,24 +229,22 @@ func TestOmniboxLoadInitialHistory_MostVisitedUsesThirtyDayWindow(t *testing.T)
209229 select {
210230 case <-done:
211231 case <-time.After(2 * time.Second):
212- t.Fatal("timed out waiting for most-visited load")
232+ t.Fatal("timed out waiting for recent-history load")
213233 }
214234 }
215235
216236 func TestOmniboxLoadInitialHistory_MostVisitedUsesConfiguredWindow(t *testing.T) {
217237 repo := repomocks.NewMockHistoryRepository(t)
218238 done := make(chan struct{})
219- repo.EXPECT().GetMostVisited(mock.Anything, 7).RunAndReturn(
220- func(context.Context, int) ([]*entity.HistoryEntry, error) {
221- close(done)
222- return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil
223- },
224- )
239+ repo.EXPECT().
240+ GetMostVisitedWithin(mock.Anything, mock.Anything, scopeCutoffWithin(7*24*time.Hour, time.Minute)).
241+ Run(func(context.Context, int, repository.HistoryScope) { close(done) }).
242+ Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
225243
226244 o := &Omnibox{
227245 historyUC: appusecase.NewSearchHistoryUseCase(repo),
228246 initialBehavior: entity.OmniboxInitialBehaviorMostVisited,
229- mostVisitedDays: 7,
247+ maxHistoryDays: 7,
230248 ctx: context.Background(),
231249 }
232250 omniboxSuggestionUpdatesInline(o)
@@ -243,17 +261,15 @@ func TestOmniboxLoadInitialHistory_MostVisitedUsesConfiguredWindow(t *testing.T)
243261 func TestOmniboxLoadInitialHistory_MostVisitedZeroWindowUsesAllHistory(t *testing.T) {
244262 repo := repomocks.NewMockHistoryRepository(t)
245263 done := make(chan struct{})
246- repo.EXPECT().GetAllMostVisited(mock.Anything).RunAndReturn(
247- func(context.Context) ([]*entity.HistoryEntry, error) {
248- close(done)
249- return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil
250- },
251- )
264+ repo.EXPECT().
265+ GetMostVisitedWithin(mock.Anything, mock.Anything, scopeUnbounded()).
266+ Run(func(context.Context, int, repository.HistoryScope) { close(done) }).
267+ Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
252268
253269 o := &Omnibox{
254270 historyUC: appusecase.NewSearchHistoryUseCase(repo),
255271 initialBehavior: entity.OmniboxInitialBehaviorMostVisited,
256- mostVisitedDays: 0,
272+ maxHistoryDays: 0,
257273 ctx: context.Background(),
258274 }
259275 omniboxSuggestionUpdatesInline(o)
@@ -266,3 +282,52 @@ func TestOmniboxLoadInitialHistory_MostVisitedZeroWindowUsesAllHistory(t *testin
266282 t.Fatal("timed out waiting for all-history most-visited load")
267283 }
268284 }
285+
286+func TestOmniboxLoadInitialHistory_MostVisitedBoundsSQL(t *testing.T) {
287+ repo := repomocks.NewMockHistoryRepository(t)
288+ done := make(chan struct{})
289+ repo.EXPECT().
290+ GetMostVisitedWithin(mock.Anything, OmniboxListDefaults.MaxVisibleRows, mock.Anything).
291+ Run(func(context.Context, int, repository.HistoryScope) { close(done) }).
292+ Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
293+
294+ o := &Omnibox{
295+ historyUC: appusecase.NewSearchHistoryUseCase(repo),
296+ initialBehavior: entity.OmniboxInitialBehaviorMostVisited,
297+ maxHistoryDays: 30,
298+ ctx: context.Background(),
299+ }
300+ omniboxSuggestionUpdatesInline(o)
301+
302+ o.loadInitialHistory(1)
303+
304+ select {
305+ case <-done:
306+ case <-time.After(2 * time.Second):
307+ t.Fatal("timed out waiting for bounded most-visited load")
308+ }
309+}
310+
311+func TestOmniboxSearchHistory_PassesConfiguredAgeScope(t *testing.T) {
312+ repo := repomocks.NewMockHistoryRepository(t)
313+ done := make(chan struct{})
314+ repo.EXPECT().
315+ Search(mock.Anything, "example", 5, scopeCutoffWithin(30*24*time.Hour, time.Minute)).
316+ Run(func(context.Context, string, int, repository.HistoryScope) { close(done) }).
317+ Return([]entity.HistoryMatch{}, nil)
318+
319+ o := &Omnibox{
320+ historyUC: appusecase.NewSearchHistoryUseCase(repo),
321+ maxHistoryDays: 30,
322+ ctx: context.Background(),
323+ }
324+ omniboxSuggestionUpdatesInline(o)
325+
326+ o.searchHistory("example", 5, 1)
327+
328+ select {
329+ case <-done:
330+ case <-time.After(2 * time.Second):
331+ t.Fatal("timed out waiting for typed history search")
332+ }
333+}
@@ -8,6 +8,7 @@ import (
8 8
9 appusecase "github.com/bnema/dumber/internal/application/usecase"9 appusecase "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11+ repository "github.com/bnema/dumber/internal/domain/repository"
11 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"12 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
12 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
@@ -159,19 +160,40 @@ func omniboxSuggestionUpdatesInline(o *Omnibox) {
159 o.scheduleSuggestionUpdate = func(fn func()) { fn() }160 o.scheduleSuggestionUpdate = func(fn func()) { fn() }
160 }161 }
161 162
163+// scopeCutoffWithin matches a history scope whose cutoff is approximately the
164+// given age, tolerating the small delay between test setup and query execution.
165+func scopeCutoffWithin(want, tolerance time.Duration) any {
166+ return mock.MatchedBy(func(scope repository.HistoryScope) bool {
167+ if scope.Cutoff.IsZero() {
168+ return false
169+ }
170+ diff := time.Since(scope.Cutoff) - want
171+ if diff < 0 {
172+ diff = -diff
173+ }
174+ return diff <= tolerance
175+ })
176+}
177+
178+// scopeUnbounded matches a history scope with no age restriction.
179+func scopeUnbounded() any {
180+ return mock.MatchedBy(func(scope repository.HistoryScope) bool {
181+ return scope.Cutoff.IsZero()
182+ })
183+}
184+
162 func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) {185 func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) {
163 repo := repomocks.NewMockHistoryRepository(t)186 repo := repomocks.NewMockHistoryRepository(t)
164 done := make(chan struct{})187 done := make(chan struct{})
165- repo.EXPECT().GetRecent(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(188+ repo.EXPECT().
166- func(context.Context, int, int) ([]*entity.HistoryEntry, error) {189+ GetRecent(mock.Anything, mock.Anything, 0, mock.Anything).
167- close(done)190+ Run(func(context.Context, int, int, repository.HistoryScope) { close(done) }).
168- return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil191+ Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
169- },
170- )
171 192
172 o := &Omnibox{193 o := &Omnibox{
173 historyUC: appusecase.NewSearchHistoryUseCase(repo),194 historyUC: appusecase.NewSearchHistoryUseCase(repo),
174 initialBehavior: entity.OmniboxInitialBehaviorRecent,195 initialBehavior: entity.OmniboxInitialBehaviorRecent,
196+ maxHistoryDays: 30,
175 ctx: context.Background(),197 ctx: context.Background(),
176 }198 }
177 omniboxSuggestionUpdatesInline(o)199 omniboxSuggestionUpdatesInline(o)
@@ -186,20 +208,18 @@ func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) {
186 }208 }
187 }209 }
188 210
189-func TestOmniboxLoadInitialHistory_MostVisitedUsesThirtyDayWindow(t *testing.T) {211+func TestOmniboxLoadInitialHistory_RecentUsesConfiguredWindow(t *testing.T) {
190 repo := repomocks.NewMockHistoryRepository(t)212 repo := repomocks.NewMockHistoryRepository(t)
191 done := make(chan struct{})213 done := make(chan struct{})
192- repo.EXPECT().GetMostVisited(mock.Anything, 30).RunAndReturn(214+ repo.EXPECT().
193- func(context.Context, int) ([]*entity.HistoryEntry, error) {215+ GetRecent(mock.Anything, mock.Anything, 0, scopeCutoffWithin(7*24*time.Hour, time.Minute)).
194- close(done)216+ Run(func(context.Context, int, int, repository.HistoryScope) { close(done) }).
195- return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil217+ Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
196- },
197- )
198 218
199 o := &Omnibox{219 o := &Omnibox{
200 historyUC: appusecase.NewSearchHistoryUseCase(repo),220 historyUC: appusecase.NewSearchHistoryUseCase(repo),
201- initialBehavior: entity.OmniboxInitialBehaviorMostVisited,221+ initialBehavior: entity.OmniboxInitialBehaviorRecent,
202- mostVisitedDays: 30,222+ maxHistoryDays: 7,
203 ctx: context.Background(),223 ctx: context.Background(),
204 }224 }
205 omniboxSuggestionUpdatesInline(o)225 omniboxSuggestionUpdatesInline(o)
@@ -209,24 +229,22 @@ func TestOmniboxLoadInitialHistory_MostVisitedUsesThirtyDayWindow(t *testing.T)
209 select {229 select {
210 case <-done:230 case <-done:
211 case <-time.After(2 * time.Second):231 case <-time.After(2 * time.Second):
212- t.Fatal("timed out waiting for most-visited load")232+ t.Fatal("timed out waiting for recent-history load")
213 }233 }
214 }234 }
215 235
216 func TestOmniboxLoadInitialHistory_MostVisitedUsesConfiguredWindow(t *testing.T) {236 func TestOmniboxLoadInitialHistory_MostVisitedUsesConfiguredWindow(t *testing.T) {
217 repo := repomocks.NewMockHistoryRepository(t)237 repo := repomocks.NewMockHistoryRepository(t)
218 done := make(chan struct{})238 done := make(chan struct{})
219- repo.EXPECT().GetMostVisited(mock.Anything, 7).RunAndReturn(239+ repo.EXPECT().
220- func(context.Context, int) ([]*entity.HistoryEntry, error) {240+ GetMostVisitedWithin(mock.Anything, mock.Anything, scopeCutoffWithin(7*24*time.Hour, time.Minute)).
221- close(done)241+ Run(func(context.Context, int, repository.HistoryScope) { close(done) }).
222- return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil242+ Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
223- },
224- )
225 243
226 o := &Omnibox{244 o := &Omnibox{
227 historyUC: appusecase.NewSearchHistoryUseCase(repo),245 historyUC: appusecase.NewSearchHistoryUseCase(repo),
228 initialBehavior: entity.OmniboxInitialBehaviorMostVisited,246 initialBehavior: entity.OmniboxInitialBehaviorMostVisited,
229- mostVisitedDays: 7,247+ maxHistoryDays: 7,
230 ctx: context.Background(),248 ctx: context.Background(),
231 }249 }
232 omniboxSuggestionUpdatesInline(o)250 omniboxSuggestionUpdatesInline(o)
@@ -243,17 +261,15 @@ func TestOmniboxLoadInitialHistory_MostVisitedUsesConfiguredWindow(t *testing.T)
243 func TestOmniboxLoadInitialHistory_MostVisitedZeroWindowUsesAllHistory(t *testing.T) {261 func TestOmniboxLoadInitialHistory_MostVisitedZeroWindowUsesAllHistory(t *testing.T) {
244 repo := repomocks.NewMockHistoryRepository(t)262 repo := repomocks.NewMockHistoryRepository(t)
245 done := make(chan struct{})263 done := make(chan struct{})
246- repo.EXPECT().GetAllMostVisited(mock.Anything).RunAndReturn(264+ repo.EXPECT().
247- func(context.Context) ([]*entity.HistoryEntry, error) {265+ GetMostVisitedWithin(mock.Anything, mock.Anything, scopeUnbounded()).
248- close(done)266+ Run(func(context.Context, int, repository.HistoryScope) { close(done) }).
249- return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil267+ Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
250- },
251- )
252 268
253 o := &Omnibox{269 o := &Omnibox{
254 historyUC: appusecase.NewSearchHistoryUseCase(repo),270 historyUC: appusecase.NewSearchHistoryUseCase(repo),
255 initialBehavior: entity.OmniboxInitialBehaviorMostVisited,271 initialBehavior: entity.OmniboxInitialBehaviorMostVisited,
256- mostVisitedDays: 0,272+ maxHistoryDays: 0,
257 ctx: context.Background(),273 ctx: context.Background(),
258 }274 }
259 omniboxSuggestionUpdatesInline(o)275 omniboxSuggestionUpdatesInline(o)
@@ -266,3 +282,52 @@ func TestOmniboxLoadInitialHistory_MostVisitedZeroWindowUsesAllHistory(t *testin
266 t.Fatal("timed out waiting for all-history most-visited load")282 t.Fatal("timed out waiting for all-history most-visited load")
267 }283 }
268 }284 }
285+
286+func TestOmniboxLoadInitialHistory_MostVisitedBoundsSQL(t *testing.T) {
287+ repo := repomocks.NewMockHistoryRepository(t)
288+ done := make(chan struct{})
289+ repo.EXPECT().
290+ GetMostVisitedWithin(mock.Anything, OmniboxListDefaults.MaxVisibleRows, mock.Anything).
291+ Run(func(context.Context, int, repository.HistoryScope) { close(done) }).
292+ Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
293+
294+ o := &Omnibox{
295+ historyUC: appusecase.NewSearchHistoryUseCase(repo),
296+ initialBehavior: entity.OmniboxInitialBehaviorMostVisited,
297+ maxHistoryDays: 30,
298+ ctx: context.Background(),
299+ }
300+ omniboxSuggestionUpdatesInline(o)
301+
302+ o.loadInitialHistory(1)
303+
304+ select {
305+ case <-done:
306+ case <-time.After(2 * time.Second):
307+ t.Fatal("timed out waiting for bounded most-visited load")
308+ }
309+}
310+
311+func TestOmniboxSearchHistory_PassesConfiguredAgeScope(t *testing.T) {
312+ repo := repomocks.NewMockHistoryRepository(t)
313+ done := make(chan struct{})
314+ repo.EXPECT().
315+ Search(mock.Anything, "example", 5, scopeCutoffWithin(30*24*time.Hour, time.Minute)).
316+ Run(func(context.Context, string, int, repository.HistoryScope) { close(done) }).
317+ Return([]entity.HistoryMatch{}, nil)
318+
319+ o := &Omnibox{
320+ historyUC: appusecase.NewSearchHistoryUseCase(repo),
321+ maxHistoryDays: 30,
322+ ctx: context.Background(),
323+ }
324+ omniboxSuggestionUpdatesInline(o)
325+
326+ o.searchHistory("example", 5, 1)
327+
328+ select {
329+ case <-done:
330+ case <-time.After(2 * time.Second):
331+ t.Fatal("timed out waiting for typed history search")
332+ }
333+}

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