Merge pull request #324 from bnema/perf/history-timeline-row-limit
perf(history): bound timeline pagination by rows
22 files · +554 -387
M cmd/systemviews/bootstrap_test.go +1 -1
| @@ -157,7 +157,7 @@ func (f *bridgeServiceRecorder) TimelineByDomain(context.Context, string, int, i | |||
| 157 | 157 | return f.historyEntries, nil | |
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | - | func (f *bridgeServiceRecorder) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) { | |
| 160 | + | func (f *bridgeServiceRecorder) TimelineWindow(_ context.Context, before time.Time, _ int64, _ string) (*entity.HistoryWindow, error) { | |
| 161 | 161 | f.calledHistory.Store(true) | |
| 162 | 162 | return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil | |
| 163 | 163 | } | |
| @@ -157,7 +157,7 @@ func (f *bridgeServiceRecorder) TimelineByDomain(context.Context, string, int, i | |||||
| 157 | return f.historyEntries, nil | 157 | return f.historyEntries, nil | ||
| 158 | } | 158 | } | ||
| 159 | 159 | ||||
| 160 | - | func (f *bridgeServiceRecorder) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) { | 160 | + | func (f *bridgeServiceRecorder) TimelineWindow(_ context.Context, before time.Time, _ int64, _ string) (*entity.HistoryWindow, error) { |
| 161 | f.calledHistory.Store(true) | 161 | f.calledHistory.Store(true) | ||
| 162 | return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil | 162 | return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil | ||
| 163 | } | 163 | } | ||
M internal/application/port/homepage.go +1 -1
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | type HomepageHistory interface { | |
| 13 | 13 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 14 | 14 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 15 | - | GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | |
| 15 | + | GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) | |
| 16 | 16 | Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) | |
| 17 | 17 | Delete(ctx context.Context, id int64) error | |
| 18 | 18 | ClearRange(ctx context.Context, rangeID string) error | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | type HomepageHistory interface { | 12 | type HomepageHistory interface { | ||
| 13 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | 13 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 14 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | 14 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 15 | - | GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | 15 | + | GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) |
| 16 | Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) | 16 | Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) | ||
| 17 | Delete(ctx context.Context, id int64) error | 17 | Delete(ctx context.Context, id int64) error | ||
| 18 | ClearRange(ctx context.Context, rangeID string) error | 18 | ClearRange(ctx context.Context, rangeID string) error | ||
M internal/application/port/mocks/homepage.go +20 -14
| @@ -604,8 +604,8 @@ func (_c *MockHomepageHistory_GetRecentByDomain_Call) RunAndReturn(run func(ctx | |||
| 604 | 604 | } | |
| 605 | 605 | ||
| 606 | 606 | // GetRecentWindow provides a mock function for the type MockHomepageHistory | |
| 607 | - | func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |
| 608 | - | ret := _mock.Called(ctx, before, domain) | |
| 607 | + | func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) { | |
| 608 | + | ret := _mock.Called(ctx, before, beforeID, domain) | |
| 609 | 609 | ||
| 610 | 610 | if len(ret) == 0 { | |
| 611 | 611 | panic("no return value specified for GetRecentWindow") | |
| @@ -613,18 +613,18 @@ func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before ti | |||
| 613 | 613 | ||
| 614 | 614 | var r0 *entity.HistoryWindow | |
| 615 | 615 | var r1 error | |
| 616 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) (*entity.HistoryWindow, error)); ok { | |
| 617 | - | return returnFunc(ctx, before, domain) | |
| 616 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, string) (*entity.HistoryWindow, error)); ok { | |
| 617 | + | return returnFunc(ctx, before, beforeID, domain) | |
| 618 | 618 | } | |
| 619 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) *entity.HistoryWindow); ok { | |
| 620 | - | r0 = returnFunc(ctx, before, domain) | |
| 619 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, string) *entity.HistoryWindow); ok { | |
| 620 | + | r0 = returnFunc(ctx, before, beforeID, domain) | |
| 621 | 621 | } else { | |
| 622 | 622 | if ret.Get(0) != nil { | |
| 623 | 623 | r0 = ret.Get(0).(*entity.HistoryWindow) | |
| 624 | 624 | } | |
| 625 | 625 | } | |
| 626 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, string) error); ok { | |
| 627 | - | r1 = returnFunc(ctx, before, domain) | |
| 626 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, int64, string) error); ok { | |
| 627 | + | r1 = returnFunc(ctx, before, beforeID, domain) | |
| 628 | 628 | } else { | |
| 629 | 629 | r1 = ret.Error(1) | |
| 630 | 630 | } | |
| @@ -639,12 +639,13 @@ type MockHomepageHistory_GetRecentWindow_Call struct { | |||
| 639 | 639 | // GetRecentWindow is a helper method to define mock.On call | |
| 640 | 640 | // - ctx context.Context | |
| 641 | 641 | // - before time.Time | |
| 642 | + | // - beforeID int64 | |
| 642 | 643 | // - domain string | |
| 643 | - | func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx any, before any, domain any) *MockHomepageHistory_GetRecentWindow_Call { | |
| 644 | - | return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, domain)} | |
| 644 | + | func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx any, before any, beforeID any, domain any) *MockHomepageHistory_GetRecentWindow_Call { | |
| 645 | + | return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, beforeID, domain)} | |
| 645 | 646 | } | |
| 646 | 647 | ||
| 647 | - | func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, domain string)) *MockHomepageHistory_GetRecentWindow_Call { | |
| 648 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, beforeID int64, domain string)) *MockHomepageHistory_GetRecentWindow_Call { | |
| 648 | 649 | _c.Call.Run(func(args mock.Arguments) { | |
| 649 | 650 | var arg0 context.Context | |
| 650 | 651 | if args[0] != nil { | |
| @@ -654,14 +655,19 @@ func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Con | |||
| 654 | 655 | if args[1] != nil { | |
| 655 | 656 | arg1 = args[1].(time.Time) | |
| 656 | 657 | } | |
| 657 | - | var arg2 string | |
| 658 | + | var arg2 int64 | |
| 658 | 659 | if args[2] != nil { | |
| 659 | - | arg2 = args[2].(string) | |
| 660 | + | arg2 = args[2].(int64) | |
| 661 | + | } | |
| 662 | + | var arg3 string | |
| 663 | + | if args[3] != nil { | |
| 664 | + | arg3 = args[3].(string) | |
| 660 | 665 | } | |
| 661 | 666 | run( | |
| 662 | 667 | arg0, | |
| 663 | 668 | arg1, | |
| 664 | 669 | arg2, | |
| 670 | + | arg3, | |
| 665 | 671 | ) | |
| 666 | 672 | }) | |
| 667 | 673 | return _c | |
| @@ -672,7 +678,7 @@ func (_c *MockHomepageHistory_GetRecentWindow_Call) Return(historyWindow *entity | |||
| 672 | 678 | return _c | |
| 673 | 679 | } | |
| 674 | 680 | ||
| 675 | - | func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call { | |
| 681 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call { | |
| 676 | 682 | _c.Call.Return(run) | |
| 677 | 683 | return _c | |
| 678 | 684 | } | |
| @@ -604,8 +604,8 @@ func (_c *MockHomepageHistory_GetRecentByDomain_Call) RunAndReturn(run func(ctx | |||||
| 604 | } | 604 | } | ||
| 605 | 605 | ||||
| 606 | // GetRecentWindow provides a mock function for the type MockHomepageHistory | 606 | // GetRecentWindow provides a mock function for the type MockHomepageHistory | ||
| 607 | - | func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | 607 | + | func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) { |
| 608 | - | ret := _mock.Called(ctx, before, domain) | 608 | + | ret := _mock.Called(ctx, before, beforeID, domain) |
| 609 | 609 | ||||
| 610 | if len(ret) == 0 { | 610 | if len(ret) == 0 { | ||
| 611 | panic("no return value specified for GetRecentWindow") | 611 | panic("no return value specified for GetRecentWindow") | ||
| @@ -613,18 +613,18 @@ func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before ti | |||||
| 613 | 613 | ||||
| 614 | var r0 *entity.HistoryWindow | 614 | var r0 *entity.HistoryWindow | ||
| 615 | var r1 error | 615 | var r1 error | ||
| 616 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) (*entity.HistoryWindow, error)); ok { | 616 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, string) (*entity.HistoryWindow, error)); ok { |
| 617 | - | return returnFunc(ctx, before, domain) | 617 | + | return returnFunc(ctx, before, beforeID, domain) |
| 618 | } | 618 | } | ||
| 619 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) *entity.HistoryWindow); ok { | 619 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, string) *entity.HistoryWindow); ok { |
| 620 | - | r0 = returnFunc(ctx, before, domain) | 620 | + | r0 = returnFunc(ctx, before, beforeID, domain) |
| 621 | } else { | 621 | } else { | ||
| 622 | if ret.Get(0) != nil { | 622 | if ret.Get(0) != nil { | ||
| 623 | r0 = ret.Get(0).(*entity.HistoryWindow) | 623 | r0 = ret.Get(0).(*entity.HistoryWindow) | ||
| 624 | } | 624 | } | ||
| 625 | } | 625 | } | ||
| 626 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, string) error); ok { | 626 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, int64, string) error); ok { |
| 627 | - | r1 = returnFunc(ctx, before, domain) | 627 | + | r1 = returnFunc(ctx, before, beforeID, domain) |
| 628 | } else { | 628 | } else { | ||
| 629 | r1 = ret.Error(1) | 629 | r1 = ret.Error(1) | ||
| 630 | } | 630 | } | ||
| @@ -639,12 +639,13 @@ type MockHomepageHistory_GetRecentWindow_Call struct { | |||||
| 639 | // GetRecentWindow is a helper method to define mock.On call | 639 | // GetRecentWindow is a helper method to define mock.On call | ||
| 640 | // - ctx context.Context | 640 | // - ctx context.Context | ||
| 641 | // - before time.Time | 641 | // - before time.Time | ||
| 642 | + | // - beforeID int64 | |||
| 642 | // - domain string | 643 | // - domain string | ||
| 643 | - | func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx any, before any, domain any) *MockHomepageHistory_GetRecentWindow_Call { | 644 | + | func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx any, before any, beforeID any, domain any) *MockHomepageHistory_GetRecentWindow_Call { |
| 644 | - | return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, domain)} | 645 | + | return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, beforeID, domain)} |
| 645 | } | 646 | } | ||
| 646 | 647 | ||||
| 647 | - | func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, domain string)) *MockHomepageHistory_GetRecentWindow_Call { | 648 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, beforeID int64, domain string)) *MockHomepageHistory_GetRecentWindow_Call { |
| 648 | _c.Call.Run(func(args mock.Arguments) { | 649 | _c.Call.Run(func(args mock.Arguments) { | ||
| 649 | var arg0 context.Context | 650 | var arg0 context.Context | ||
| 650 | if args[0] != nil { | 651 | if args[0] != nil { | ||
| @@ -654,14 +655,19 @@ func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Con | |||||
| 654 | if args[1] != nil { | 655 | if args[1] != nil { | ||
| 655 | arg1 = args[1].(time.Time) | 656 | arg1 = args[1].(time.Time) | ||
| 656 | } | 657 | } | ||
| 657 | - | var arg2 string | 658 | + | var arg2 int64 |
| 658 | if args[2] != nil { | 659 | if args[2] != nil { | ||
| 659 | - | arg2 = args[2].(string) | 660 | + | arg2 = args[2].(int64) |
| 661 | + | } | |||
| 662 | + | var arg3 string | |||
| 663 | + | if args[3] != nil { | |||
| 664 | + | arg3 = args[3].(string) | |||
| 660 | } | 665 | } | ||
| 661 | run( | 666 | run( | ||
| 662 | arg0, | 667 | arg0, | ||
| 663 | arg1, | 668 | arg1, | ||
| 664 | arg2, | 669 | arg2, | ||
| 670 | + | arg3, | |||
| 665 | ) | 671 | ) | ||
| 666 | }) | 672 | }) | ||
| 667 | return _c | 673 | return _c | ||
| @@ -672,7 +678,7 @@ func (_c *MockHomepageHistory_GetRecentWindow_Call) Return(historyWindow *entity | |||||
| 672 | return _c | 678 | return _c | ||
| 673 | } | 679 | } | ||
| 674 | 680 | ||||
| 675 | - | func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call { | 681 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call { |
| 676 | _c.Call.Return(run) | 682 | _c.Call.Return(run) | ||
| 677 | return _c | 683 | return _c | ||
| 678 | } | 684 | } | ||
M internal/application/port/systemviews.go +1 -1
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | type SystemviewHistoryService interface { | |
| 13 | 13 | Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 14 | 14 | TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 15 | - | TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | |
| 15 | + | TimelineWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) | |
| 16 | 16 | Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) | |
| 17 | 17 | DeleteEntry(ctx context.Context, id int64) error | |
| 18 | 18 | DeleteRange(ctx context.Context, rangeID string) error | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | type SystemviewHistoryService interface { | 12 | type SystemviewHistoryService interface { | ||
| 13 | Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | 13 | Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 14 | TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | 14 | TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 15 | - | TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | 15 | + | TimelineWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) |
| 16 | Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) | 16 | Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) | ||
| 17 | DeleteEntry(ctx context.Context, id int64) error | 17 | DeleteEntry(ctx context.Context, id int64) error | ||
| 18 | DeleteRange(ctx context.Context, rangeID string) error | 18 | DeleteRange(ctx context.Context, rangeID string) error | ||
M internal/application/usecase/search_history.go +40 -16
| @@ -22,6 +22,7 @@ var _ port.HistorySidebarHistory = (*SearchHistoryUseCase)(nil) | |||
| 22 | 22 | ||
| 23 | 23 | const ( | |
| 24 | 24 | historyWindowDuration = 24 * time.Hour | |
| 25 | + | historyWindowRowLimit = 100 | |
| 25 | 26 | ||
| 26 | 27 | defaultHistoryPageLimit = 50 | |
| 27 | 28 | maxHistoryPageLimit = 500 | |
| @@ -115,17 +116,30 @@ func canonicalHistoryDomain(domain string) (string, error) { | |||
| 115 | 116 | return domain, nil | |
| 116 | 117 | } | |
| 117 | 118 | ||
| 118 | - | // GetRecentWindow retrieves the 24-hour history window ending before the given cursor. | |
| 119 | - | func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |
| 120 | - | if before.IsZero() { | |
| 119 | + | // GetRecentWindow retrieves a row-bounded history window ending before the given cursor. | |
| 120 | + | func (uc *SearchHistoryUseCase) GetRecentWindow( | |
| 121 | + | ctx context.Context, | |
| 122 | + | before time.Time, | |
| 123 | + | beforeID int64, | |
| 124 | + | domain string, | |
| 125 | + | ) (*entity.HistoryWindow, error) { | |
| 126 | + | if beforeID < 0 { | |
| 127 | + | return nil, fmt.Errorf("beforeID must be non-negative") | |
| 128 | + | } | |
| 129 | + | hasBefore := !before.IsZero() | |
| 130 | + | hasBeforeID := beforeID != 0 | |
| 131 | + | if hasBefore != hasBeforeID { | |
| 132 | + | return nil, fmt.Errorf("before and beforeID must be provided together") | |
| 133 | + | } | |
| 134 | + | if !hasBefore { | |
| 121 | 135 | before = time.Now() | |
| 122 | 136 | } | |
| 123 | 137 | before = before.UTC() | |
| 124 | 138 | after := before.Add(-historyWindowDuration) | |
| 139 | + | queryLimit := historyWindowRowLimit + 1 | |
| 125 | 140 | ||
| 126 | 141 | var ( | |
| 127 | 142 | entries []*entity.HistoryEntry | |
| 128 | - | hasMore bool | |
| 129 | 143 | err error | |
| 130 | 144 | ) | |
| 131 | 145 | originalDomain := domain | |
| @@ -134,25 +148,35 @@ func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time | |||
| 134 | 148 | return nil, fmt.Errorf("domain is required") | |
| 135 | 149 | } | |
| 136 | 150 | if domain != "" { | |
| 137 | - | entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after) | |
| 138 | - | if err == nil { | |
| 139 | - | hasMore, err = uc.historyRepo.HasEntriesByDomainBefore(ctx, domain, after) | |
| 140 | - | } | |
| 151 | + | entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, beforeID, queryLimit) | |
| 141 | 152 | } else { | |
| 142 | - | entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after) | |
| 143 | - | if err == nil { | |
| 144 | - | hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after) | |
| 145 | - | } | |
| 153 | + | entries, err = uc.historyRepo.GetRecentWindow(ctx, before, beforeID, queryLimit) | |
| 146 | 154 | } | |
| 147 | 155 | if err != nil { | |
| 148 | 156 | return nil, fmt.Errorf("failed to get recent history window: %w", err) | |
| 149 | 157 | } | |
| 150 | 158 | ||
| 159 | + | hasMore := len(entries) > historyWindowRowLimit | |
| 160 | + | if hasMore { | |
| 161 | + | entries = entries[:historyWindowRowLimit] | |
| 162 | + | } | |
| 163 | + | var cursorLastVisited time.Time | |
| 164 | + | var cursorID int64 | |
| 165 | + | if len(entries) > 0 { | |
| 166 | + | last := entries[len(entries)-1] | |
| 167 | + | if last != nil { | |
| 168 | + | cursorLastVisited = last.LastVisited.UTC() | |
| 169 | + | cursorID = last.ID | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 151 | 173 | return &entity.HistoryWindow{ | |
| 152 | - | Entries: entries, | |
| 153 | - | Before: before, | |
| 154 | - | After: after, | |
| 155 | - | HasMore: hasMore, | |
| 174 | + | Entries: entries, | |
| 175 | + | Before: before, | |
| 176 | + | After: after, | |
| 177 | + | CursorLastVisited: cursorLastVisited, | |
| 178 | + | CursorID: cursorID, | |
| 179 | + | HasMore: hasMore, | |
| 156 | 180 | }, nil | |
| 157 | 181 | } | |
| 158 | 182 | ||
| @@ -22,6 +22,7 @@ var _ port.HistorySidebarHistory = (*SearchHistoryUseCase)(nil) | |||||
| 22 | 22 | ||||
| 23 | const ( | 23 | const ( | ||
| 24 | historyWindowDuration = 24 * time.Hour | 24 | historyWindowDuration = 24 * time.Hour | ||
| 25 | + | historyWindowRowLimit = 100 | |||
| 25 | 26 | ||||
| 26 | defaultHistoryPageLimit = 50 | 27 | defaultHistoryPageLimit = 50 | ||
| 27 | maxHistoryPageLimit = 500 | 28 | maxHistoryPageLimit = 500 | ||
| @@ -115,17 +116,30 @@ func canonicalHistoryDomain(domain string) (string, error) { | |||||
| 115 | return domain, nil | 116 | return domain, nil | ||
| 116 | } | 117 | } | ||
| 117 | 118 | ||||
| 118 | - | // GetRecentWindow retrieves the 24-hour history window ending before the given cursor. | 119 | + | // GetRecentWindow retrieves a row-bounded history window ending before the given cursor. |
| 119 | - | func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | 120 | + | func (uc *SearchHistoryUseCase) GetRecentWindow( |
| 120 | - | if before.IsZero() { | 121 | + | ctx context.Context, |
| 122 | + | before time.Time, | |||
| 123 | + | beforeID int64, | |||
| 124 | + | domain string, | |||
| 125 | + | ) (*entity.HistoryWindow, error) { | |||
| 126 | + | if beforeID < 0 { | |||
| 127 | + | return nil, fmt.Errorf("beforeID must be non-negative") | |||
| 128 | + | } | |||
| 129 | + | hasBefore := !before.IsZero() | |||
| 130 | + | hasBeforeID := beforeID != 0 | |||
| 131 | + | if hasBefore != hasBeforeID { | |||
| 132 | + | return nil, fmt.Errorf("before and beforeID must be provided together") | |||
| 133 | + | } | |||
| 134 | + | if !hasBefore { | |||
| 121 | before = time.Now() | 135 | before = time.Now() | ||
| 122 | } | 136 | } | ||
| 123 | before = before.UTC() | 137 | before = before.UTC() | ||
| 124 | after := before.Add(-historyWindowDuration) | 138 | after := before.Add(-historyWindowDuration) | ||
| 139 | + | queryLimit := historyWindowRowLimit + 1 | |||
| 125 | 140 | ||||
| 126 | var ( | 141 | var ( | ||
| 127 | entries []*entity.HistoryEntry | 142 | entries []*entity.HistoryEntry | ||
| 128 | - | hasMore bool | |||
| 129 | err error | 143 | err error | ||
| 130 | ) | 144 | ) | ||
| 131 | originalDomain := domain | 145 | originalDomain := domain | ||
| @@ -134,25 +148,35 @@ func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time | |||||
| 134 | return nil, fmt.Errorf("domain is required") | 148 | return nil, fmt.Errorf("domain is required") | ||
| 135 | } | 149 | } | ||
| 136 | if domain != "" { | 150 | if domain != "" { | ||
| 137 | - | entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after) | 151 | + | entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, beforeID, queryLimit) |
| 138 | - | if err == nil { | |||
| 139 | - | hasMore, err = uc.historyRepo.HasEntriesByDomainBefore(ctx, domain, after) | |||
| 140 | - | } | |||
| 141 | } else { | 152 | } else { | ||
| 142 | - | entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after) | 153 | + | entries, err = uc.historyRepo.GetRecentWindow(ctx, before, beforeID, queryLimit) |
| 143 | - | if err == nil { | |||
| 144 | - | hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after) | |||
| 145 | - | } | |||
| 146 | } | 154 | } | ||
| 147 | if err != nil { | 155 | if err != nil { | ||
| 148 | return nil, fmt.Errorf("failed to get recent history window: %w", err) | 156 | return nil, fmt.Errorf("failed to get recent history window: %w", err) | ||
| 149 | } | 157 | } | ||
| 150 | 158 | ||||
| 159 | + | hasMore := len(entries) > historyWindowRowLimit | |||
| 160 | + | if hasMore { | |||
| 161 | + | entries = entries[:historyWindowRowLimit] | |||
| 162 | + | } | |||
| 163 | + | var cursorLastVisited time.Time | |||
| 164 | + | var cursorID int64 | |||
| 165 | + | if len(entries) > 0 { | |||
| 166 | + | last := entries[len(entries)-1] | |||
| 167 | + | if last != nil { | |||
| 168 | + | cursorLastVisited = last.LastVisited.UTC() | |||
| 169 | + | cursorID = last.ID | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 151 | return &entity.HistoryWindow{ | 173 | return &entity.HistoryWindow{ | ||
| 152 | - | Entries: entries, | 174 | + | Entries: entries, |
| 153 | - | Before: before, | 175 | + | Before: before, |
| 154 | - | After: after, | 176 | + | After: after, |
| 155 | - | HasMore: hasMore, | 177 | + | CursorLastVisited: cursorLastVisited, |
| 178 | + | CursorID: cursorID, | |||
| 179 | + | HasMore: hasMore, | |||
| 156 | }, nil | 180 | }, nil | ||
| 157 | } | 181 | } | ||
| 158 | 182 | ||||
M internal/application/usecase/search_history_test.go +44 -1
| @@ -68,13 +68,56 @@ func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationErro | |||
| 68 | 68 | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 69 | 69 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 70 | 70 | ||
| 71 | - | result, err := uc.GetRecentWindow(ctx, time.Now(), "///") | |
| 71 | + | result, err := uc.GetRecentWindow(ctx, time.Time{}, 0, "///") | |
| 72 | 72 | ||
| 73 | 73 | require.Error(t, err) | |
| 74 | 74 | assert.Nil(t, result) | |
| 75 | 75 | assert.Contains(t, err.Error(), "domain is required") | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | + | func TestSearchHistoryUseCase_GetRecentWindow_RejectsInvalidCursorPairs(t *testing.T) { | |
| 79 | + | ctx := testContext() | |
| 80 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 81 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 82 | + | ||
| 83 | + | result, err := uc.GetRecentWindow(ctx, time.Time{}, 42, "") | |
| 84 | + | require.Error(t, err) | |
| 85 | + | assert.Nil(t, result) | |
| 86 | + | assert.Contains(t, err.Error(), "before and beforeID") | |
| 87 | + | ||
| 88 | + | result, err = uc.GetRecentWindow(ctx, time.Now(), 0, "") | |
| 89 | + | require.Error(t, err) | |
| 90 | + | assert.Nil(t, result) | |
| 91 | + | assert.Contains(t, err.Error(), "before and beforeID") | |
| 92 | + | ||
| 93 | + | result, err = uc.GetRecentWindow(ctx, time.Now(), -1, "") | |
| 94 | + | require.Error(t, err) | |
| 95 | + | assert.Nil(t, result) | |
| 96 | + | assert.Contains(t, err.Error(), "beforeID must be non-negative") | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestSearchHistoryUseCase_GetRecentWindow_UsesLimitPlusOneForHasMoreAndCursor(t *testing.T) { | |
| 100 | + | ctx := testContext() | |
| 101 | + | before := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 102 | + | entries := make([]*entity.HistoryEntry, 101) | |
| 103 | + | for i := range entries { | |
| 104 | + | entries[i] = &entity.HistoryEntry{ID: int64(200 - i), URL: "https://example.com", LastVisited: before.Add(-time.Duration(i) * time.Minute)} | |
| 105 | + | } | |
| 106 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 107 | + | historyRepo.EXPECT().GetRecentWindow(mock.Anything, before, int64(999), 101).Return(entries, nil).Once() | |
| 108 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 109 | + | ||
| 110 | + | result, err := uc.GetRecentWindow(ctx, before, 999, "") | |
| 111 | + | ||
| 112 | + | require.NoError(t, err) | |
| 113 | + | require.NotNil(t, result) | |
| 114 | + | assert.Len(t, result.Entries, 100) | |
| 115 | + | assert.True(t, result.HasMore) | |
| 116 | + | assert.Equal(t, entries[99].LastVisited.UTC(), result.CursorLastVisited) | |
| 117 | + | assert.Equal(t, entries[99].ID, result.CursorID) | |
| 118 | + | assert.Equal(t, before.Add(-24*time.Hour), result.After) | |
| 119 | + | } | |
| 120 | + | ||
| 78 | 121 | func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) { | |
| 79 | 122 | ctx := testContext() | |
| 80 | 123 | ||
| @@ -68,13 +68,56 @@ func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationErro | |||||
| 68 | historyRepo := repomocks.NewMockHistoryRepository(t) | 68 | historyRepo := repomocks.NewMockHistoryRepository(t) | ||
| 69 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | 69 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | ||
| 70 | 70 | ||||
| 71 | - | result, err := uc.GetRecentWindow(ctx, time.Now(), "///") | 71 | + | result, err := uc.GetRecentWindow(ctx, time.Time{}, 0, "///") |
| 72 | 72 | ||||
| 73 | require.Error(t, err) | 73 | require.Error(t, err) | ||
| 74 | assert.Nil(t, result) | 74 | assert.Nil(t, result) | ||
| 75 | assert.Contains(t, err.Error(), "domain is required") | 75 | assert.Contains(t, err.Error(), "domain is required") | ||
| 76 | } | 76 | } | ||
| 77 | 77 | ||||
| 78 | + | func TestSearchHistoryUseCase_GetRecentWindow_RejectsInvalidCursorPairs(t *testing.T) { | |||
| 79 | + | ctx := testContext() | |||
| 80 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 81 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 82 | + | ||||
| 83 | + | result, err := uc.GetRecentWindow(ctx, time.Time{}, 42, "") | |||
| 84 | + | require.Error(t, err) | |||
| 85 | + | assert.Nil(t, result) | |||
| 86 | + | assert.Contains(t, err.Error(), "before and beforeID") | |||
| 87 | + | ||||
| 88 | + | result, err = uc.GetRecentWindow(ctx, time.Now(), 0, "") | |||
| 89 | + | require.Error(t, err) | |||
| 90 | + | assert.Nil(t, result) | |||
| 91 | + | assert.Contains(t, err.Error(), "before and beforeID") | |||
| 92 | + | ||||
| 93 | + | result, err = uc.GetRecentWindow(ctx, time.Now(), -1, "") | |||
| 94 | + | require.Error(t, err) | |||
| 95 | + | assert.Nil(t, result) | |||
| 96 | + | assert.Contains(t, err.Error(), "beforeID must be non-negative") | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestSearchHistoryUseCase_GetRecentWindow_UsesLimitPlusOneForHasMoreAndCursor(t *testing.T) { | |||
| 100 | + | ctx := testContext() | |||
| 101 | + | before := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 102 | + | entries := make([]*entity.HistoryEntry, 101) | |||
| 103 | + | for i := range entries { | |||
| 104 | + | entries[i] = &entity.HistoryEntry{ID: int64(200 - i), URL: "https://example.com", LastVisited: before.Add(-time.Duration(i) * time.Minute)} | |||
| 105 | + | } | |||
| 106 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 107 | + | historyRepo.EXPECT().GetRecentWindow(mock.Anything, before, int64(999), 101).Return(entries, nil).Once() | |||
| 108 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 109 | + | ||||
| 110 | + | result, err := uc.GetRecentWindow(ctx, before, 999, "") | |||
| 111 | + | ||||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | require.NotNil(t, result) | |||
| 114 | + | assert.Len(t, result.Entries, 100) | |||
| 115 | + | assert.True(t, result.HasMore) | |||
| 116 | + | assert.Equal(t, entries[99].LastVisited.UTC(), result.CursorLastVisited) | |||
| 117 | + | assert.Equal(t, entries[99].ID, result.CursorID) | |||
| 118 | + | assert.Equal(t, before.Add(-24*time.Hour), result.After) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 78 | func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) { | 121 | func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) { | ||
| 79 | ctx := testContext() | 122 | ctx := testContext() | ||
| 80 | 123 | ||||
M internal/domain/entity/history_window.go +7 -5
| @@ -2,10 +2,12 @@ package entity | |||
| 2 | 2 | ||
| 3 | 3 | import "time" | |
| 4 | 4 | ||
| 5 | - | // HistoryWindow contains one fixed-duration slice of browsing history. | |
| 5 | + | // HistoryWindow contains one bounded slice of browsing history plus display window metadata. | |
| 6 | 6 | type HistoryWindow struct { | |
| 7 | - | Entries []*HistoryEntry `json:"entries"` | |
| 8 | - | Before time.Time `json:"before"` | |
| 9 | - | After time.Time `json:"after"` | |
| 10 | - | HasMore bool `json:"has_more"` | |
| 7 | + | Entries []*HistoryEntry `json:"entries"` | |
| 8 | + | Before time.Time `json:"before"` | |
| 9 | + | After time.Time `json:"after"` | |
| 10 | + | CursorLastVisited time.Time `json:"cursor_last_visited"` | |
| 11 | + | CursorID int64 `json:"cursor_id,omitempty"` | |
| 12 | + | HasMore bool `json:"has_more"` | |
| 11 | 13 | } | |
| @@ -2,10 +2,12 @@ package entity | |||||
| 2 | 2 | ||||
| 3 | import "time" | 3 | import "time" | ||
| 4 | 4 | ||||
| 5 | - | // HistoryWindow contains one fixed-duration slice of browsing history. | 5 | + | // HistoryWindow contains one bounded slice of browsing history plus display window metadata. |
| 6 | type HistoryWindow struct { | 6 | type HistoryWindow struct { | ||
| 7 | - | Entries []*HistoryEntry `json:"entries"` | 7 | + | Entries []*HistoryEntry `json:"entries"` |
| 8 | - | Before time.Time `json:"before"` | 8 | + | Before time.Time `json:"before"` |
| 9 | - | After time.Time `json:"after"` | 9 | + | After time.Time `json:"after"` |
| 10 | - | HasMore bool `json:"has_more"` | 10 | + | CursorLastVisited time.Time `json:"cursor_last_visited"` |
| 11 | + | CursorID int64 `json:"cursor_id,omitempty"` | |||
| 12 | + | HasMore bool `json:"has_more"` | |||
| 11 | } | 13 | } | ||
M internal/domain/repository/history.go +4 -10
| @@ -24,17 +24,11 @@ type HistoryRepository interface { | |||
| 24 | 24 | // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries. | |
| 25 | 25 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 26 | 26 | ||
| 27 | - | // GetRecentWindow retrieves entries visited in [after, before), sorted by recency. | |
| 28 | - | GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) | |
| 27 | + | // GetRecentWindow retrieves entries older than the stable (last_visited, id) cursor, sorted by recency. | |
| 28 | + | GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) | |
| 29 | 29 | ||
| 30 | - | // GetRecentWindowByDomain retrieves domain entries visited in [after, before), sorted by recency. | |
| 31 | - | GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) | |
| 32 | - | ||
| 33 | - | // HasEntriesBefore reports whether any history entry was visited before the given time. | |
| 34 | - | HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) | |
| 35 | - | ||
| 36 | - | // HasEntriesByDomainBefore reports whether any domain entry was visited before the given time. | |
| 37 | - | HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) | |
| 30 | + | // GetRecentWindowByDomain retrieves domain entries older than the stable (last_visited, id) cursor, sorted by recency. | |
| 31 | + | GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) | |
| 38 | 32 | ||
| 39 | 33 | // GetRecentSince retrieves history entries visited within the last N days. | |
| 40 | 34 | // days must be > 0. | |
| @@ -24,17 +24,11 @@ type HistoryRepository interface { | |||||
| 24 | // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries. | 24 | // 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) | 25 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 26 | 26 | ||||
| 27 | - | // GetRecentWindow retrieves entries visited in [after, before), sorted by recency. | 27 | + | // GetRecentWindow retrieves entries older than the stable (last_visited, id) cursor, sorted by recency. |
| 28 | - | GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) | 28 | + | GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) |
| 29 | 29 | ||||
| 30 | - | // GetRecentWindowByDomain retrieves domain entries visited in [after, before), sorted by recency. | 30 | + | // GetRecentWindowByDomain retrieves domain entries older than the stable (last_visited, id) cursor, sorted by recency. |
| 31 | - | GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) | 31 | + | GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) |
| 32 | - | ||||
| 33 | - | // HasEntriesBefore reports whether any history entry was visited before the given time. | |||
| 34 | - | HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) | |||
| 35 | - | ||||
| 36 | - | // HasEntriesByDomainBefore reports whether any domain entry was visited before the given time. | |||
| 37 | - | HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) | |||
| 38 | 32 | ||||
| 39 | // GetRecentSince retrieves history entries visited within the last N days. | 33 | // GetRecentSince retrieves history entries visited within the last N days. | ||
| 40 | // days must be > 0. | 34 | // days must be > 0. | ||
M internal/domain/repository/mocks/history.go +42 -168
| @@ -999,8 +999,8 @@ func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx c | |||
| 999 | 999 | } | |
| 1000 | 1000 | ||
| 1001 | 1001 | // GetRecentWindow provides a mock function for the type MockHistoryRepository | |
| 1002 | - | func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) { | |
| 1003 | - | ret := _mock.Called(ctx, before, after) | |
| 1002 | + | func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) { | |
| 1003 | + | ret := _mock.Called(ctx, before, beforeID, limit) | |
| 1004 | 1004 | ||
| 1005 | 1005 | if len(ret) == 0 { | |
| 1006 | 1006 | panic("no return value specified for GetRecentWindow") | |
| @@ -1008,18 +1008,18 @@ func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before | |||
| 1008 | 1008 | ||
| 1009 | 1009 | var r0 []*entity.HistoryEntry | |
| 1010 | 1010 | var r1 error | |
| 1011 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok { | |
| 1012 | - | return returnFunc(ctx, before, after) | |
| 1011 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, int) ([]*entity.HistoryEntry, error)); ok { | |
| 1012 | + | return returnFunc(ctx, before, beforeID, limit) | |
| 1013 | 1013 | } | |
| 1014 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) []*entity.HistoryEntry); ok { | |
| 1015 | - | r0 = returnFunc(ctx, before, after) | |
| 1014 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, int) []*entity.HistoryEntry); ok { | |
| 1015 | + | r0 = returnFunc(ctx, before, beforeID, limit) | |
| 1016 | 1016 | } else { | |
| 1017 | 1017 | if ret.Get(0) != nil { | |
| 1018 | 1018 | r0 = ret.Get(0).([]*entity.HistoryEntry) | |
| 1019 | 1019 | } | |
| 1020 | 1020 | } | |
| 1021 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, time.Time) error); ok { | |
| 1022 | - | r1 = returnFunc(ctx, before, after) | |
| 1021 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, int64, int) error); ok { | |
| 1022 | + | r1 = returnFunc(ctx, before, beforeID, limit) | |
| 1023 | 1023 | } else { | |
| 1024 | 1024 | r1 = ret.Error(1) | |
| 1025 | 1025 | } | |
| @@ -1034,12 +1034,13 @@ type MockHistoryRepository_GetRecentWindow_Call struct { | |||
| 1034 | 1034 | // GetRecentWindow is a helper method to define mock.On call | |
| 1035 | 1035 | // - ctx context.Context | |
| 1036 | 1036 | // - before time.Time | |
| 1037 | - | // - after time.Time | |
| 1038 | - | func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx any, before any, after any) *MockHistoryRepository_GetRecentWindow_Call { | |
| 1039 | - | return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, after)} | |
| 1037 | + | // - beforeID int64 | |
| 1038 | + | // - limit int | |
| 1039 | + | func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx any, before any, beforeID any, limit any) *MockHistoryRepository_GetRecentWindow_Call { | |
| 1040 | + | return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, beforeID, limit)} | |
| 1040 | 1041 | } | |
| 1041 | 1042 | ||
| 1042 | - | func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindow_Call { | |
| 1043 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, beforeID int64, limit int)) *MockHistoryRepository_GetRecentWindow_Call { | |
| 1043 | 1044 | _c.Call.Run(func(args mock.Arguments) { | |
| 1044 | 1045 | var arg0 context.Context | |
| 1045 | 1046 | if args[0] != nil { | |
| @@ -1049,14 +1050,19 @@ func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.C | |||
| 1049 | 1050 | if args[1] != nil { | |
| 1050 | 1051 | arg1 = args[1].(time.Time) | |
| 1051 | 1052 | } | |
| 1052 | - | var arg2 time.Time | |
| 1053 | + | var arg2 int64 | |
| 1053 | 1054 | if args[2] != nil { | |
| 1054 | - | arg2 = args[2].(time.Time) | |
| 1055 | + | arg2 = args[2].(int64) | |
| 1056 | + | } | |
| 1057 | + | var arg3 int | |
| 1058 | + | if args[3] != nil { | |
| 1059 | + | arg3 = args[3].(int) | |
| 1055 | 1060 | } | |
| 1056 | 1061 | run( | |
| 1057 | 1062 | arg0, | |
| 1058 | 1063 | arg1, | |
| 1059 | 1064 | arg2, | |
| 1065 | + | arg3, | |
| 1060 | 1066 | ) | |
| 1061 | 1067 | }) | |
| 1062 | 1068 | return _c | |
| @@ -1067,14 +1073,14 @@ func (_c *MockHistoryRepository_GetRecentWindow_Call) Return(historyEntrys []*en | |||
| 1067 | 1073 | return _c | |
| 1068 | 1074 | } | |
| 1069 | 1075 | ||
| 1070 | - | func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call { | |
| 1076 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call { | |
| 1071 | 1077 | _c.Call.Return(run) | |
| 1072 | 1078 | return _c | |
| 1073 | 1079 | } | |
| 1074 | 1080 | ||
| 1075 | 1081 | // GetRecentWindowByDomain provides a mock function for the type MockHistoryRepository | |
| 1076 | - | func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) { | |
| 1077 | - | ret := _mock.Called(ctx, domain, before, after) | |
| 1082 | + | func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) { | |
| 1083 | + | ret := _mock.Called(ctx, domain, before, beforeID, limit) | |
| 1078 | 1084 | ||
| 1079 | 1085 | if len(ret) == 0 { | |
| 1080 | 1086 | panic("no return value specified for GetRecentWindowByDomain") | |
| @@ -1082,18 +1088,18 @@ func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, | |||
| 1082 | 1088 | ||
| 1083 | 1089 | var r0 []*entity.HistoryEntry | |
| 1084 | 1090 | var r1 error | |
| 1085 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok { | |
| 1086 | - | return returnFunc(ctx, domain, before, after) | |
| 1091 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, int64, int) ([]*entity.HistoryEntry, error)); ok { | |
| 1092 | + | return returnFunc(ctx, domain, before, beforeID, limit) | |
| 1087 | 1093 | } | |
| 1088 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) []*entity.HistoryEntry); ok { | |
| 1089 | - | r0 = returnFunc(ctx, domain, before, after) | |
| 1094 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, int64, int) []*entity.HistoryEntry); ok { | |
| 1095 | + | r0 = returnFunc(ctx, domain, before, beforeID, limit) | |
| 1090 | 1096 | } else { | |
| 1091 | 1097 | if ret.Get(0) != nil { | |
| 1092 | 1098 | r0 = ret.Get(0).([]*entity.HistoryEntry) | |
| 1093 | 1099 | } | |
| 1094 | 1100 | } | |
| 1095 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, time.Time) error); ok { | |
| 1096 | - | r1 = returnFunc(ctx, domain, before, after) | |
| 1101 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, int64, int) error); ok { | |
| 1102 | + | r1 = returnFunc(ctx, domain, before, beforeID, limit) | |
| 1097 | 1103 | } else { | |
| 1098 | 1104 | r1 = ret.Error(1) | |
| 1099 | 1105 | } | |
| @@ -1109,12 +1115,13 @@ type MockHistoryRepository_GetRecentWindowByDomain_Call struct { | |||
| 1109 | 1115 | // - ctx context.Context | |
| 1110 | 1116 | // - domain string | |
| 1111 | 1117 | // - before time.Time | |
| 1112 | - | // - after time.Time | |
| 1113 | - | func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx any, domain any, before any, after any) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |
| 1114 | - | return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, after)} | |
| 1118 | + | // - beforeID int64 | |
| 1119 | + | // - limit int | |
| 1120 | + | func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx any, domain any, before any, beforeID any, limit any) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |
| 1121 | + | return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, beforeID, limit)} | |
| 1115 | 1122 | } | |
| 1116 | 1123 | ||
| 1117 | - | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |
| 1124 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, beforeID int64, limit int)) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |
| 1118 | 1125 | _c.Call.Run(func(args mock.Arguments) { | |
| 1119 | 1126 | var arg0 context.Context | |
| 1120 | 1127 | if args[0] != nil { | |
| @@ -1128,15 +1135,20 @@ func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx c | |||
| 1128 | 1135 | if args[2] != nil { | |
| 1129 | 1136 | arg2 = args[2].(time.Time) | |
| 1130 | 1137 | } | |
| 1131 | - | var arg3 time.Time | |
| 1138 | + | var arg3 int64 | |
| 1132 | 1139 | if args[3] != nil { | |
| 1133 | - | arg3 = args[3].(time.Time) | |
| 1140 | + | arg3 = args[3].(int64) | |
| 1141 | + | } | |
| 1142 | + | var arg4 int | |
| 1143 | + | if args[4] != nil { | |
| 1144 | + | arg4 = args[4].(int) | |
| 1134 | 1145 | } | |
| 1135 | 1146 | run( | |
| 1136 | 1147 | arg0, | |
| 1137 | 1148 | arg1, | |
| 1138 | 1149 | arg2, | |
| 1139 | 1150 | arg3, | |
| 1151 | + | arg4, | |
| 1140 | 1152 | ) | |
| 1141 | 1153 | }) | |
| 1142 | 1154 | return _c | |
| @@ -1147,7 +1159,7 @@ func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Return(historyEntr | |||
| 1147 | 1159 | return _c | |
| 1148 | 1160 | } | |
| 1149 | 1161 | ||
| 1150 | - | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |
| 1162 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |
| 1151 | 1163 | _c.Call.Return(run) | |
| 1152 | 1164 | return _c | |
| 1153 | 1165 | } | |
| @@ -1214,144 +1226,6 @@ func (_c *MockHistoryRepository_GetStats_Call) RunAndReturn(run func(ctx context | |||
| 1214 | 1226 | return _c | |
| 1215 | 1227 | } | |
| 1216 | 1228 | ||
| 1217 | - | // HasEntriesBefore provides a mock function for the type MockHistoryRepository | |
| 1218 | - | func (_mock *MockHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |
| 1219 | - | ret := _mock.Called(ctx, before) | |
| 1220 | - | ||
| 1221 | - | if len(ret) == 0 { | |
| 1222 | - | panic("no return value specified for HasEntriesBefore") | |
| 1223 | - | } | |
| 1224 | - | ||
| 1225 | - | var r0 bool | |
| 1226 | - | var r1 error | |
| 1227 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (bool, error)); ok { | |
| 1228 | - | return returnFunc(ctx, before) | |
| 1229 | - | } | |
| 1230 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) bool); ok { | |
| 1231 | - | r0 = returnFunc(ctx, before) | |
| 1232 | - | } else { | |
| 1233 | - | r0 = ret.Get(0).(bool) | |
| 1234 | - | } | |
| 1235 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok { | |
| 1236 | - | r1 = returnFunc(ctx, before) | |
| 1237 | - | } else { | |
| 1238 | - | r1 = ret.Error(1) | |
| 1239 | - | } | |
| 1240 | - | return r0, r1 | |
| 1241 | - | } | |
| 1242 | - | ||
| 1243 | - | // MockHistoryRepository_HasEntriesBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesBefore' | |
| 1244 | - | type MockHistoryRepository_HasEntriesBefore_Call struct { | |
| 1245 | - | *mock.Call | |
| 1246 | - | } | |
| 1247 | - | ||
| 1248 | - | // HasEntriesBefore is a helper method to define mock.On call | |
| 1249 | - | // - ctx context.Context | |
| 1250 | - | // - before time.Time | |
| 1251 | - | func (_e *MockHistoryRepository_Expecter) HasEntriesBefore(ctx any, before any) *MockHistoryRepository_HasEntriesBefore_Call { | |
| 1252 | - | return &MockHistoryRepository_HasEntriesBefore_Call{Call: _e.mock.On("HasEntriesBefore", ctx, before)} | |
| 1253 | - | } | |
| 1254 | - | ||
| 1255 | - | func (_c *MockHistoryRepository_HasEntriesBefore_Call) Run(run func(ctx context.Context, before time.Time)) *MockHistoryRepository_HasEntriesBefore_Call { | |
| 1256 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 1257 | - | var arg0 context.Context | |
| 1258 | - | if args[0] != nil { | |
| 1259 | - | arg0 = args[0].(context.Context) | |
| 1260 | - | } | |
| 1261 | - | var arg1 time.Time | |
| 1262 | - | if args[1] != nil { | |
| 1263 | - | arg1 = args[1].(time.Time) | |
| 1264 | - | } | |
| 1265 | - | run( | |
| 1266 | - | arg0, | |
| 1267 | - | arg1, | |
| 1268 | - | ) | |
| 1269 | - | }) | |
| 1270 | - | return _c | |
| 1271 | - | } | |
| 1272 | - | ||
| 1273 | - | func (_c *MockHistoryRepository_HasEntriesBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesBefore_Call { | |
| 1274 | - | _c.Call.Return(b, err) | |
| 1275 | - | return _c | |
| 1276 | - | } | |
| 1277 | - | ||
| 1278 | - | func (_c *MockHistoryRepository_HasEntriesBefore_Call) RunAndReturn(run func(ctx context.Context, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesBefore_Call { | |
| 1279 | - | _c.Call.Return(run) | |
| 1280 | - | return _c | |
| 1281 | - | } | |
| 1282 | - | ||
| 1283 | - | // HasEntriesByDomainBefore provides a mock function for the type MockHistoryRepository | |
| 1284 | - | func (_mock *MockHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |
| 1285 | - | ret := _mock.Called(ctx, domain, before) | |
| 1286 | - | ||
| 1287 | - | if len(ret) == 0 { | |
| 1288 | - | panic("no return value specified for HasEntriesByDomainBefore") | |
| 1289 | - | } | |
| 1290 | - | ||
| 1291 | - | var r0 bool | |
| 1292 | - | var r1 error | |
| 1293 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (bool, error)); ok { | |
| 1294 | - | return returnFunc(ctx, domain, before) | |
| 1295 | - | } | |
| 1296 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) bool); ok { | |
| 1297 | - | r0 = returnFunc(ctx, domain, before) | |
| 1298 | - | } else { | |
| 1299 | - | r0 = ret.Get(0).(bool) | |
| 1300 | - | } | |
| 1301 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok { | |
| 1302 | - | r1 = returnFunc(ctx, domain, before) | |
| 1303 | - | } else { | |
| 1304 | - | r1 = ret.Error(1) | |
| 1305 | - | } | |
| 1306 | - | return r0, r1 | |
| 1307 | - | } | |
| 1308 | - | ||
| 1309 | - | // MockHistoryRepository_HasEntriesByDomainBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesByDomainBefore' | |
| 1310 | - | type MockHistoryRepository_HasEntriesByDomainBefore_Call struct { | |
| 1311 | - | *mock.Call | |
| 1312 | - | } | |
| 1313 | - | ||
| 1314 | - | // HasEntriesByDomainBefore is a helper method to define mock.On call | |
| 1315 | - | // - ctx context.Context | |
| 1316 | - | // - domain string | |
| 1317 | - | // - before time.Time | |
| 1318 | - | func (_e *MockHistoryRepository_Expecter) HasEntriesByDomainBefore(ctx any, domain any, before any) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |
| 1319 | - | return &MockHistoryRepository_HasEntriesByDomainBefore_Call{Call: _e.mock.On("HasEntriesByDomainBefore", ctx, domain, before)} | |
| 1320 | - | } | |
| 1321 | - | ||
| 1322 | - | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Run(run func(ctx context.Context, domain string, before time.Time)) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |
| 1323 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 1324 | - | var arg0 context.Context | |
| 1325 | - | if args[0] != nil { | |
| 1326 | - | arg0 = args[0].(context.Context) | |
| 1327 | - | } | |
| 1328 | - | var arg1 string | |
| 1329 | - | if args[1] != nil { | |
| 1330 | - | arg1 = args[1].(string) | |
| 1331 | - | } | |
| 1332 | - | var arg2 time.Time | |
| 1333 | - | if args[2] != nil { | |
| 1334 | - | arg2 = args[2].(time.Time) | |
| 1335 | - | } | |
| 1336 | - | run( | |
| 1337 | - | arg0, | |
| 1338 | - | arg1, | |
| 1339 | - | arg2, | |
| 1340 | - | ) | |
| 1341 | - | }) | |
| 1342 | - | return _c | |
| 1343 | - | } | |
| 1344 | - | ||
| 1345 | - | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |
| 1346 | - | _c.Call.Return(b, err) | |
| 1347 | - | return _c | |
| 1348 | - | } | |
| 1349 | - | ||
| 1350 | - | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |
| 1351 | - | _c.Call.Return(run) | |
| 1352 | - | return _c | |
| 1353 | - | } | |
| 1354 | - | ||
| 1355 | 1229 | // IncrementVisitCount provides a mock function for the type MockHistoryRepository | |
| 1356 | 1230 | func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error { | |
| 1357 | 1231 | ret := _mock.Called(ctx, url) | |
| @@ -999,8 +999,8 @@ func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx c | |||||
| 999 | } | 999 | } | ||
| 1000 | 1000 | ||||
| 1001 | // GetRecentWindow provides a mock function for the type MockHistoryRepository | 1001 | // GetRecentWindow provides a mock function for the type MockHistoryRepository | ||
| 1002 | - | func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) { | 1002 | + | func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) { |
| 1003 | - | ret := _mock.Called(ctx, before, after) | 1003 | + | ret := _mock.Called(ctx, before, beforeID, limit) |
| 1004 | 1004 | ||||
| 1005 | if len(ret) == 0 { | 1005 | if len(ret) == 0 { | ||
| 1006 | panic("no return value specified for GetRecentWindow") | 1006 | panic("no return value specified for GetRecentWindow") | ||
| @@ -1008,18 +1008,18 @@ func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before | |||||
| 1008 | 1008 | ||||
| 1009 | var r0 []*entity.HistoryEntry | 1009 | var r0 []*entity.HistoryEntry | ||
| 1010 | var r1 error | 1010 | var r1 error | ||
| 1011 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok { | 1011 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, int) ([]*entity.HistoryEntry, error)); ok { |
| 1012 | - | return returnFunc(ctx, before, after) | 1012 | + | return returnFunc(ctx, before, beforeID, limit) |
| 1013 | } | 1013 | } | ||
| 1014 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) []*entity.HistoryEntry); ok { | 1014 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, int64, int) []*entity.HistoryEntry); ok { |
| 1015 | - | r0 = returnFunc(ctx, before, after) | 1015 | + | r0 = returnFunc(ctx, before, beforeID, limit) |
| 1016 | } else { | 1016 | } else { | ||
| 1017 | if ret.Get(0) != nil { | 1017 | if ret.Get(0) != nil { | ||
| 1018 | r0 = ret.Get(0).([]*entity.HistoryEntry) | 1018 | r0 = ret.Get(0).([]*entity.HistoryEntry) | ||
| 1019 | } | 1019 | } | ||
| 1020 | } | 1020 | } | ||
| 1021 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, time.Time) error); ok { | 1021 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, int64, int) error); ok { |
| 1022 | - | r1 = returnFunc(ctx, before, after) | 1022 | + | r1 = returnFunc(ctx, before, beforeID, limit) |
| 1023 | } else { | 1023 | } else { | ||
| 1024 | r1 = ret.Error(1) | 1024 | r1 = ret.Error(1) | ||
| 1025 | } | 1025 | } | ||
| @@ -1034,12 +1034,13 @@ type MockHistoryRepository_GetRecentWindow_Call struct { | |||||
| 1034 | // GetRecentWindow is a helper method to define mock.On call | 1034 | // GetRecentWindow is a helper method to define mock.On call | ||
| 1035 | // - ctx context.Context | 1035 | // - ctx context.Context | ||
| 1036 | // - before time.Time | 1036 | // - before time.Time | ||
| 1037 | - | // - after time.Time | 1037 | + | // - beforeID int64 |
| 1038 | - | func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx any, before any, after any) *MockHistoryRepository_GetRecentWindow_Call { | 1038 | + | // - limit int |
| 1039 | - | return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, after)} | 1039 | + | func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx any, before any, beforeID any, limit any) *MockHistoryRepository_GetRecentWindow_Call { |
| 1040 | + | return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, beforeID, limit)} | |||
| 1040 | } | 1041 | } | ||
| 1041 | 1042 | ||||
| 1042 | - | func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindow_Call { | 1043 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, beforeID int64, limit int)) *MockHistoryRepository_GetRecentWindow_Call { |
| 1043 | _c.Call.Run(func(args mock.Arguments) { | 1044 | _c.Call.Run(func(args mock.Arguments) { | ||
| 1044 | var arg0 context.Context | 1045 | var arg0 context.Context | ||
| 1045 | if args[0] != nil { | 1046 | if args[0] != nil { | ||
| @@ -1049,14 +1050,19 @@ func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.C | |||||
| 1049 | if args[1] != nil { | 1050 | if args[1] != nil { | ||
| 1050 | arg1 = args[1].(time.Time) | 1051 | arg1 = args[1].(time.Time) | ||
| 1051 | } | 1052 | } | ||
| 1052 | - | var arg2 time.Time | 1053 | + | var arg2 int64 |
| 1053 | if args[2] != nil { | 1054 | if args[2] != nil { | ||
| 1054 | - | arg2 = args[2].(time.Time) | 1055 | + | arg2 = args[2].(int64) |
| 1056 | + | } | |||
| 1057 | + | var arg3 int | |||
| 1058 | + | if args[3] != nil { | |||
| 1059 | + | arg3 = args[3].(int) | |||
| 1055 | } | 1060 | } | ||
| 1056 | run( | 1061 | run( | ||
| 1057 | arg0, | 1062 | arg0, | ||
| 1058 | arg1, | 1063 | arg1, | ||
| 1059 | arg2, | 1064 | arg2, | ||
| 1065 | + | arg3, | |||
| 1060 | ) | 1066 | ) | ||
| 1061 | }) | 1067 | }) | ||
| 1062 | return _c | 1068 | return _c | ||
| @@ -1067,14 +1073,14 @@ func (_c *MockHistoryRepository_GetRecentWindow_Call) Return(historyEntrys []*en | |||||
| 1067 | return _c | 1073 | return _c | ||
| 1068 | } | 1074 | } | ||
| 1069 | 1075 | ||||
| 1070 | - | func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call { | 1076 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call { |
| 1071 | _c.Call.Return(run) | 1077 | _c.Call.Return(run) | ||
| 1072 | return _c | 1078 | return _c | ||
| 1073 | } | 1079 | } | ||
| 1074 | 1080 | ||||
| 1075 | // GetRecentWindowByDomain provides a mock function for the type MockHistoryRepository | 1081 | // GetRecentWindowByDomain provides a mock function for the type MockHistoryRepository | ||
| 1076 | - | func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) { | 1082 | + | func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) { |
| 1077 | - | ret := _mock.Called(ctx, domain, before, after) | 1083 | + | ret := _mock.Called(ctx, domain, before, beforeID, limit) |
| 1078 | 1084 | ||||
| 1079 | if len(ret) == 0 { | 1085 | if len(ret) == 0 { | ||
| 1080 | panic("no return value specified for GetRecentWindowByDomain") | 1086 | panic("no return value specified for GetRecentWindowByDomain") | ||
| @@ -1082,18 +1088,18 @@ func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, | |||||
| 1082 | 1088 | ||||
| 1083 | var r0 []*entity.HistoryEntry | 1089 | var r0 []*entity.HistoryEntry | ||
| 1084 | var r1 error | 1090 | var r1 error | ||
| 1085 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok { | 1091 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, int64, int) ([]*entity.HistoryEntry, error)); ok { |
| 1086 | - | return returnFunc(ctx, domain, before, after) | 1092 | + | return returnFunc(ctx, domain, before, beforeID, limit) |
| 1087 | } | 1093 | } | ||
| 1088 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) []*entity.HistoryEntry); ok { | 1094 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, int64, int) []*entity.HistoryEntry); ok { |
| 1089 | - | r0 = returnFunc(ctx, domain, before, after) | 1095 | + | r0 = returnFunc(ctx, domain, before, beforeID, limit) |
| 1090 | } else { | 1096 | } else { | ||
| 1091 | if ret.Get(0) != nil { | 1097 | if ret.Get(0) != nil { | ||
| 1092 | r0 = ret.Get(0).([]*entity.HistoryEntry) | 1098 | r0 = ret.Get(0).([]*entity.HistoryEntry) | ||
| 1093 | } | 1099 | } | ||
| 1094 | } | 1100 | } | ||
| 1095 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, time.Time) error); ok { | 1101 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, int64, int) error); ok { |
| 1096 | - | r1 = returnFunc(ctx, domain, before, after) | 1102 | + | r1 = returnFunc(ctx, domain, before, beforeID, limit) |
| 1097 | } else { | 1103 | } else { | ||
| 1098 | r1 = ret.Error(1) | 1104 | r1 = ret.Error(1) | ||
| 1099 | } | 1105 | } | ||
| @@ -1109,12 +1115,13 @@ type MockHistoryRepository_GetRecentWindowByDomain_Call struct { | |||||
| 1109 | // - ctx context.Context | 1115 | // - ctx context.Context | ||
| 1110 | // - domain string | 1116 | // - domain string | ||
| 1111 | // - before time.Time | 1117 | // - before time.Time | ||
| 1112 | - | // - after time.Time | 1118 | + | // - beforeID int64 |
| 1113 | - | func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx any, domain any, before any, after any) *MockHistoryRepository_GetRecentWindowByDomain_Call { | 1119 | + | // - limit int |
| 1114 | - | return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, after)} | 1120 | + | func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx any, domain any, before any, beforeID any, limit any) *MockHistoryRepository_GetRecentWindowByDomain_Call { |
| 1121 | + | return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, beforeID, limit)} | |||
| 1115 | } | 1122 | } | ||
| 1116 | 1123 | ||||
| 1117 | - | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindowByDomain_Call { | 1124 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, beforeID int64, limit int)) *MockHistoryRepository_GetRecentWindowByDomain_Call { |
| 1118 | _c.Call.Run(func(args mock.Arguments) { | 1125 | _c.Call.Run(func(args mock.Arguments) { | ||
| 1119 | var arg0 context.Context | 1126 | var arg0 context.Context | ||
| 1120 | if args[0] != nil { | 1127 | if args[0] != nil { | ||
| @@ -1128,15 +1135,20 @@ func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx c | |||||
| 1128 | if args[2] != nil { | 1135 | if args[2] != nil { | ||
| 1129 | arg2 = args[2].(time.Time) | 1136 | arg2 = args[2].(time.Time) | ||
| 1130 | } | 1137 | } | ||
| 1131 | - | var arg3 time.Time | 1138 | + | var arg3 int64 |
| 1132 | if args[3] != nil { | 1139 | if args[3] != nil { | ||
| 1133 | - | arg3 = args[3].(time.Time) | 1140 | + | arg3 = args[3].(int64) |
| 1141 | + | } | |||
| 1142 | + | var arg4 int | |||
| 1143 | + | if args[4] != nil { | |||
| 1144 | + | arg4 = args[4].(int) | |||
| 1134 | } | 1145 | } | ||
| 1135 | run( | 1146 | run( | ||
| 1136 | arg0, | 1147 | arg0, | ||
| 1137 | arg1, | 1148 | arg1, | ||
| 1138 | arg2, | 1149 | arg2, | ||
| 1139 | arg3, | 1150 | arg3, | ||
| 1151 | + | arg4, | |||
| 1140 | ) | 1152 | ) | ||
| 1141 | }) | 1153 | }) | ||
| 1142 | return _c | 1154 | return _c | ||
| @@ -1147,7 +1159,7 @@ func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Return(historyEntr | |||||
| 1147 | return _c | 1159 | return _c | ||
| 1148 | } | 1160 | } | ||
| 1149 | 1161 | ||||
| 1150 | - | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call { | 1162 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call { |
| 1151 | _c.Call.Return(run) | 1163 | _c.Call.Return(run) | ||
| 1152 | return _c | 1164 | return _c | ||
| 1153 | } | 1165 | } | ||
| @@ -1214,144 +1226,6 @@ func (_c *MockHistoryRepository_GetStats_Call) RunAndReturn(run func(ctx context | |||||
| 1214 | return _c | 1226 | return _c | ||
| 1215 | } | 1227 | } | ||
| 1216 | 1228 | ||||
| 1217 | - | // HasEntriesBefore provides a mock function for the type MockHistoryRepository | |||
| 1218 | - | func (_mock *MockHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |||
| 1219 | - | ret := _mock.Called(ctx, before) | |||
| 1220 | - | ||||
| 1221 | - | if len(ret) == 0 { | |||
| 1222 | - | panic("no return value specified for HasEntriesBefore") | |||
| 1223 | - | } | |||
| 1224 | - | ||||
| 1225 | - | var r0 bool | |||
| 1226 | - | var r1 error | |||
| 1227 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (bool, error)); ok { | |||
| 1228 | - | return returnFunc(ctx, before) | |||
| 1229 | - | } | |||
| 1230 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) bool); ok { | |||
| 1231 | - | r0 = returnFunc(ctx, before) | |||
| 1232 | - | } else { | |||
| 1233 | - | r0 = ret.Get(0).(bool) | |||
| 1234 | - | } | |||
| 1235 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok { | |||
| 1236 | - | r1 = returnFunc(ctx, before) | |||
| 1237 | - | } else { | |||
| 1238 | - | r1 = ret.Error(1) | |||
| 1239 | - | } | |||
| 1240 | - | return r0, r1 | |||
| 1241 | - | } | |||
| 1242 | - | ||||
| 1243 | - | // MockHistoryRepository_HasEntriesBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesBefore' | |||
| 1244 | - | type MockHistoryRepository_HasEntriesBefore_Call struct { | |||
| 1245 | - | *mock.Call | |||
| 1246 | - | } | |||
| 1247 | - | ||||
| 1248 | - | // HasEntriesBefore is a helper method to define mock.On call | |||
| 1249 | - | // - ctx context.Context | |||
| 1250 | - | // - before time.Time | |||
| 1251 | - | func (_e *MockHistoryRepository_Expecter) HasEntriesBefore(ctx any, before any) *MockHistoryRepository_HasEntriesBefore_Call { | |||
| 1252 | - | return &MockHistoryRepository_HasEntriesBefore_Call{Call: _e.mock.On("HasEntriesBefore", ctx, before)} | |||
| 1253 | - | } | |||
| 1254 | - | ||||
| 1255 | - | func (_c *MockHistoryRepository_HasEntriesBefore_Call) Run(run func(ctx context.Context, before time.Time)) *MockHistoryRepository_HasEntriesBefore_Call { | |||
| 1256 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 1257 | - | var arg0 context.Context | |||
| 1258 | - | if args[0] != nil { | |||
| 1259 | - | arg0 = args[0].(context.Context) | |||
| 1260 | - | } | |||
| 1261 | - | var arg1 time.Time | |||
| 1262 | - | if args[1] != nil { | |||
| 1263 | - | arg1 = args[1].(time.Time) | |||
| 1264 | - | } | |||
| 1265 | - | run( | |||
| 1266 | - | arg0, | |||
| 1267 | - | arg1, | |||
| 1268 | - | ) | |||
| 1269 | - | }) | |||
| 1270 | - | return _c | |||
| 1271 | - | } | |||
| 1272 | - | ||||
| 1273 | - | func (_c *MockHistoryRepository_HasEntriesBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesBefore_Call { | |||
| 1274 | - | _c.Call.Return(b, err) | |||
| 1275 | - | return _c | |||
| 1276 | - | } | |||
| 1277 | - | ||||
| 1278 | - | func (_c *MockHistoryRepository_HasEntriesBefore_Call) RunAndReturn(run func(ctx context.Context, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesBefore_Call { | |||
| 1279 | - | _c.Call.Return(run) | |||
| 1280 | - | return _c | |||
| 1281 | - | } | |||
| 1282 | - | ||||
| 1283 | - | // HasEntriesByDomainBefore provides a mock function for the type MockHistoryRepository | |||
| 1284 | - | func (_mock *MockHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |||
| 1285 | - | ret := _mock.Called(ctx, domain, before) | |||
| 1286 | - | ||||
| 1287 | - | if len(ret) == 0 { | |||
| 1288 | - | panic("no return value specified for HasEntriesByDomainBefore") | |||
| 1289 | - | } | |||
| 1290 | - | ||||
| 1291 | - | var r0 bool | |||
| 1292 | - | var r1 error | |||
| 1293 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (bool, error)); ok { | |||
| 1294 | - | return returnFunc(ctx, domain, before) | |||
| 1295 | - | } | |||
| 1296 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) bool); ok { | |||
| 1297 | - | r0 = returnFunc(ctx, domain, before) | |||
| 1298 | - | } else { | |||
| 1299 | - | r0 = ret.Get(0).(bool) | |||
| 1300 | - | } | |||
| 1301 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok { | |||
| 1302 | - | r1 = returnFunc(ctx, domain, before) | |||
| 1303 | - | } else { | |||
| 1304 | - | r1 = ret.Error(1) | |||
| 1305 | - | } | |||
| 1306 | - | return r0, r1 | |||
| 1307 | - | } | |||
| 1308 | - | ||||
| 1309 | - | // MockHistoryRepository_HasEntriesByDomainBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesByDomainBefore' | |||
| 1310 | - | type MockHistoryRepository_HasEntriesByDomainBefore_Call struct { | |||
| 1311 | - | *mock.Call | |||
| 1312 | - | } | |||
| 1313 | - | ||||
| 1314 | - | // HasEntriesByDomainBefore is a helper method to define mock.On call | |||
| 1315 | - | // - ctx context.Context | |||
| 1316 | - | // - domain string | |||
| 1317 | - | // - before time.Time | |||
| 1318 | - | func (_e *MockHistoryRepository_Expecter) HasEntriesByDomainBefore(ctx any, domain any, before any) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |||
| 1319 | - | return &MockHistoryRepository_HasEntriesByDomainBefore_Call{Call: _e.mock.On("HasEntriesByDomainBefore", ctx, domain, before)} | |||
| 1320 | - | } | |||
| 1321 | - | ||||
| 1322 | - | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Run(run func(ctx context.Context, domain string, before time.Time)) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |||
| 1323 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 1324 | - | var arg0 context.Context | |||
| 1325 | - | if args[0] != nil { | |||
| 1326 | - | arg0 = args[0].(context.Context) | |||
| 1327 | - | } | |||
| 1328 | - | var arg1 string | |||
| 1329 | - | if args[1] != nil { | |||
| 1330 | - | arg1 = args[1].(string) | |||
| 1331 | - | } | |||
| 1332 | - | var arg2 time.Time | |||
| 1333 | - | if args[2] != nil { | |||
| 1334 | - | arg2 = args[2].(time.Time) | |||
| 1335 | - | } | |||
| 1336 | - | run( | |||
| 1337 | - | arg0, | |||
| 1338 | - | arg1, | |||
| 1339 | - | arg2, | |||
| 1340 | - | ) | |||
| 1341 | - | }) | |||
| 1342 | - | return _c | |||
| 1343 | - | } | |||
| 1344 | - | ||||
| 1345 | - | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |||
| 1346 | - | _c.Call.Return(b, err) | |||
| 1347 | - | return _c | |||
| 1348 | - | } | |||
| 1349 | - | ||||
| 1350 | - | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |||
| 1351 | - | _c.Call.Return(run) | |||
| 1352 | - | return _c | |||
| 1353 | - | } | |||
| 1354 | - | ||||
| 1355 | // IncrementVisitCount provides a mock function for the type MockHistoryRepository | 1229 | // IncrementVisitCount provides a mock function for the type MockHistoryRepository | ||
| 1356 | func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error { | 1230 | func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error { | ||
| 1357 | ret := _mock.Called(ctx, url) | 1231 | ret := _mock.Called(ctx, url) | ||
M internal/infrastructure/handlers/homepage/history.go +11 -2
| @@ -40,6 +40,7 @@ type timelineByDomainRequest struct { | |||
| 40 | 40 | type timelineWindowRequest struct { | |
| 41 | 41 | RequestID string `json:"requestId"` | |
| 42 | 42 | Before string `json:"before"` | |
| 43 | + | BeforeID int64 `json:"beforeId"` | |
| 43 | 44 | Domain string `json:"domain"` | |
| 44 | 45 | } | |
| 45 | 46 | ||
| @@ -124,7 +125,15 @@ func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler { | |||
| 124 | 125 | domain := strings.TrimSpace(req.Domain) | |
| 125 | 126 | ||
| 126 | 127 | var before time.Time | |
| 127 | - | if strings.TrimSpace(req.Before) != "" { | |
| 128 | + | hasBefore := strings.TrimSpace(req.Before) != "" | |
| 129 | + | if req.BeforeID < 0 { | |
| 130 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("history window cursor id must be non-negative")), nil | |
| 131 | + | } | |
| 132 | + | hasBeforeID := req.BeforeID > 0 | |
| 133 | + | if hasBefore != hasBeforeID { | |
| 134 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("history window cursor requires before and beforeId")), nil | |
| 135 | + | } | |
| 136 | + | if hasBefore { | |
| 128 | 137 | parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before)) | |
| 129 | 138 | if err != nil { | |
| 130 | 139 | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil | |
| @@ -138,7 +147,7 @@ func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler { | |||
| 138 | 147 | Time("before", before). | |
| 139 | 148 | Msg("handling history_timeline_window") | |
| 140 | 149 | ||
| 141 | - | window, err := h.historyUC.GetRecentWindow(ctx, before, domain) | |
| 150 | + | window, err := h.historyUC.GetRecentWindow(ctx, before, req.BeforeID, domain) | |
| 142 | 151 | if err != nil { | |
| 143 | 152 | return NewErrorResponse(req.RequestID, err), nil | |
| 144 | 153 | } | |
| @@ -40,6 +40,7 @@ type timelineByDomainRequest struct { | |||||
| 40 | type timelineWindowRequest struct { | 40 | type timelineWindowRequest struct { | ||
| 41 | RequestID string `json:"requestId"` | 41 | RequestID string `json:"requestId"` | ||
| 42 | Before string `json:"before"` | 42 | Before string `json:"before"` | ||
| 43 | + | BeforeID int64 `json:"beforeId"` | |||
| 43 | Domain string `json:"domain"` | 44 | Domain string `json:"domain"` | ||
| 44 | } | 45 | } | ||
| 45 | 46 | ||||
| @@ -124,7 +125,15 @@ func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler { | |||||
| 124 | domain := strings.TrimSpace(req.Domain) | 125 | domain := strings.TrimSpace(req.Domain) | ||
| 125 | 126 | ||||
| 126 | var before time.Time | 127 | var before time.Time | ||
| 127 | - | if strings.TrimSpace(req.Before) != "" { | 128 | + | hasBefore := strings.TrimSpace(req.Before) != "" |
| 129 | + | if req.BeforeID < 0 { | |||
| 130 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("history window cursor id must be non-negative")), nil | |||
| 131 | + | } | |||
| 132 | + | hasBeforeID := req.BeforeID > 0 | |||
| 133 | + | if hasBefore != hasBeforeID { | |||
| 134 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("history window cursor requires before and beforeId")), nil | |||
| 135 | + | } | |||
| 136 | + | if hasBefore { | |||
| 128 | parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before)) | 137 | parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before)) | ||
| 129 | if err != nil { | 138 | if err != nil { | ||
| 130 | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil | 139 | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil | ||
| @@ -138,7 +147,7 @@ func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler { | |||||
| 138 | Time("before", before). | 147 | Time("before", before). | ||
| 139 | Msg("handling history_timeline_window") | 148 | Msg("handling history_timeline_window") | ||
| 140 | 149 | ||||
| 141 | - | window, err := h.historyUC.GetRecentWindow(ctx, before, domain) | 150 | + | window, err := h.historyUC.GetRecentWindow(ctx, before, req.BeforeID, domain) |
| 142 | if err != nil { | 151 | if err != nil { | ||
| 143 | return NewErrorResponse(req.RequestID, err), nil | 152 | return NewErrorResponse(req.RequestID, err), nil | ||
| 144 | } | 153 | } | ||
A internal/infrastructure/handlers/homepage/history_test.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | package homepage | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestHandleTimelineWindowRejectsIncompleteCursor(t *testing.T) { | |
| 17 | + | t.Parallel() | |
| 18 | + | ||
| 19 | + | tests := map[string]json.RawMessage{ | |
| 20 | + | "before without beforeId": json.RawMessage(`{"requestId":"req-1","before":"2026-04-25T09:00:00Z"}`), | |
| 21 | + | "beforeId without before": json.RawMessage(`{"requestId":"req-1","beforeId":42}`), | |
| 22 | + | "negative beforeId without before": json.RawMessage(`{"requestId":"req-1","beforeId":-1}`), | |
| 23 | + | } | |
| 24 | + | for name, payload := range tests { | |
| 25 | + | t.Run(name, func(t *testing.T) { | |
| 26 | + | t.Parallel() | |
| 27 | + | ||
| 28 | + | history := portmocks.NewMockHomepageHistory(t) | |
| 29 | + | handler := NewHistoryHandlers(history).HandleTimelineWindow() | |
| 30 | + | ||
| 31 | + | got, err := handler.Handle(context.Background(), port.WebViewID(0), payload) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | ||
| 34 | + | resp, ok := got.(Response) | |
| 35 | + | require.True(t, ok) | |
| 36 | + | require.False(t, resp.Success) | |
| 37 | + | require.Contains(t, resp.Error, "cursor") | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestHandleTimelineWindowPassesCompleteCursor(t *testing.T) { | |
| 43 | + | t.Parallel() | |
| 44 | + | ||
| 45 | + | before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |
| 46 | + | history := portmocks.NewMockHomepageHistory(t) | |
| 47 | + | history.EXPECT(). | |
| 48 | + | GetRecentWindow(mock.Anything, before, int64(42), "example.com"). | |
| 49 | + | Return(&entity.HistoryWindow{Before: before, CursorID: 42}, nil). | |
| 50 | + | Once() | |
| 51 | + | ||
| 52 | + | handler := NewHistoryHandlers(history).HandleTimelineWindow() | |
| 53 | + | payload := json.RawMessage(`{"requestId":"req-2","before":"2026-04-25T09:00:00Z","beforeId":42,"domain":"example.com"}`) | |
| 54 | + | ||
| 55 | + | got, err := handler.Handle(context.Background(), port.WebViewID(0), payload) | |
| 56 | + | require.NoError(t, err) | |
| 57 | + | ||
| 58 | + | resp, ok := got.(Response) | |
| 59 | + | require.True(t, ok) | |
| 60 | + | require.True(t, resp.Success) | |
| 61 | + | } | |
| @@ -0,0 +1,61 @@ | |||||
| 1 | + | package homepage | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestHandleTimelineWindowRejectsIncompleteCursor(t *testing.T) { | |||
| 17 | + | t.Parallel() | |||
| 18 | + | ||||
| 19 | + | tests := map[string]json.RawMessage{ | |||
| 20 | + | "before without beforeId": json.RawMessage(`{"requestId":"req-1","before":"2026-04-25T09:00:00Z"}`), | |||
| 21 | + | "beforeId without before": json.RawMessage(`{"requestId":"req-1","beforeId":42}`), | |||
| 22 | + | "negative beforeId without before": json.RawMessage(`{"requestId":"req-1","beforeId":-1}`), | |||
| 23 | + | } | |||
| 24 | + | for name, payload := range tests { | |||
| 25 | + | t.Run(name, func(t *testing.T) { | |||
| 26 | + | t.Parallel() | |||
| 27 | + | ||||
| 28 | + | history := portmocks.NewMockHomepageHistory(t) | |||
| 29 | + | handler := NewHistoryHandlers(history).HandleTimelineWindow() | |||
| 30 | + | ||||
| 31 | + | got, err := handler.Handle(context.Background(), port.WebViewID(0), payload) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | ||||
| 34 | + | resp, ok := got.(Response) | |||
| 35 | + | require.True(t, ok) | |||
| 36 | + | require.False(t, resp.Success) | |||
| 37 | + | require.Contains(t, resp.Error, "cursor") | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestHandleTimelineWindowPassesCompleteCursor(t *testing.T) { | |||
| 43 | + | t.Parallel() | |||
| 44 | + | ||||
| 45 | + | before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |||
| 46 | + | history := portmocks.NewMockHomepageHistory(t) | |||
| 47 | + | history.EXPECT(). | |||
| 48 | + | GetRecentWindow(mock.Anything, before, int64(42), "example.com"). | |||
| 49 | + | Return(&entity.HistoryWindow{Before: before, CursorID: 42}, nil). | |||
| 50 | + | Once() | |||
| 51 | + | ||||
| 52 | + | handler := NewHistoryHandlers(history).HandleTimelineWindow() | |||
| 53 | + | payload := json.RawMessage(`{"requestId":"req-2","before":"2026-04-25T09:00:00Z","beforeId":42,"domain":"example.com"}`) | |||
| 54 | + | ||||
| 55 | + | got, err := handler.Handle(context.Background(), port.WebViewID(0), payload) | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | ||||
| 58 | + | resp, ok := got.(Response) | |||
| 59 | + | require.True(t, ok) | |||
| 60 | + | require.True(t, resp.Success) | |||
| 61 | + | } | |||
M internal/infrastructure/persistence/sqlite/history_repo.go +21 -31
| @@ -524,10 +524,14 @@ func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limi | |||
| 524 | 524 | return historyEntriesFromRows(rows), nil | |
| 525 | 525 | } | |
| 526 | 526 | ||
| 527 | - | func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) { | |
| 527 | + | func (r *historyRepo) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) { | |
| 528 | + | if limit <= 0 { | |
| 529 | + | return []*entity.HistoryEntry{}, nil | |
| 530 | + | } | |
| 528 | 531 | rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{ | |
| 529 | - | Before: sql.NullTime{Time: before, Valid: true}, | |
| 530 | - | After: sql.NullTime{Time: after, Valid: true}, | |
| 532 | + | Before: sql.NullTime{Time: before, Valid: true}, | |
| 533 | + | BeforeID: beforeID, | |
| 534 | + | Limit: int64(limit), | |
| 531 | 535 | }) | |
| 532 | 536 | if err != nil { | |
| 533 | 537 | return nil, err | |
| @@ -535,16 +539,26 @@ func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Ti | |||
| 535 | 539 | return historyEntriesFromRows(rows), nil | |
| 536 | 540 | } | |
| 537 | 541 | ||
| 538 | - | func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) { | |
| 542 | + | func (r *historyRepo) GetRecentWindowByDomain( | |
| 543 | + | ctx context.Context, | |
| 544 | + | domain string, | |
| 545 | + | before time.Time, | |
| 546 | + | beforeID int64, | |
| 547 | + | limit int, | |
| 548 | + | ) ([]*entity.HistoryEntry, error) { | |
| 539 | 549 | rawDomain := domain | |
| 540 | 550 | domain = domainurl.CanonicalDomain(domain) | |
| 541 | 551 | if domain == "" { | |
| 542 | 552 | return nil, fmt.Errorf("history domain is required: %q", rawDomain) | |
| 543 | 553 | } | |
| 554 | + | if limit <= 0 { | |
| 555 | + | return []*entity.HistoryEntry{}, nil | |
| 556 | + | } | |
| 544 | 557 | rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{ | |
| 545 | - | Domain: sql.NullString{String: domain, Valid: true}, | |
| 546 | - | Before: sql.NullTime{Time: before, Valid: true}, | |
| 547 | - | After: sql.NullTime{Time: after, Valid: true}, | |
| 558 | + | Domain: sql.NullString{String: domain, Valid: true}, | |
| 559 | + | Before: sql.NullTime{Time: before, Valid: true}, | |
| 560 | + | BeforeID: beforeID, | |
| 561 | + | Limit: int64(limit), | |
| 548 | 562 | }) | |
| 549 | 563 | if err != nil { | |
| 550 | 564 | return nil, err | |
| @@ -552,30 +566,6 @@ func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string | |||
| 552 | 566 | return historyEntriesFromRows(rows), nil | |
| 553 | 567 | } | |
| 554 | 568 | ||
| 555 | - | func (r *historyRepo) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |
| 556 | - | value, err := r.queries.HasHistoryBefore(ctx, sql.NullTime{Time: before, Valid: true}) | |
| 557 | - | if err != nil { | |
| 558 | - | return false, err | |
| 559 | - | } | |
| 560 | - | return value != 0, nil | |
| 561 | - | } | |
| 562 | - | ||
| 563 | - | func (r *historyRepo) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |
| 564 | - | rawDomain := domain | |
| 565 | - | domain = domainurl.CanonicalDomain(domain) | |
| 566 | - | if domain == "" { | |
| 567 | - | return false, fmt.Errorf("history domain is required: %q", rawDomain) | |
| 568 | - | } | |
| 569 | - | value, err := r.queries.HasHistoryByDomainBefore(ctx, sqlc.HasHistoryByDomainBeforeParams{ | |
| 570 | - | Domain: sql.NullString{String: domain, Valid: true}, | |
| 571 | - | Before: sql.NullTime{Time: before, Valid: true}, | |
| 572 | - | }) | |
| 573 | - | if err != nil { | |
| 574 | - | return false, err | |
| 575 | - | } | |
| 576 | - | return value != 0, nil | |
| 577 | - | } | |
| 578 | - | ||
| 579 | 569 | func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 580 | 570 | if days <= 0 { | |
| 581 | 571 | return nil, fmt.Errorf("days must be positive, got %d", days) | |
| @@ -524,10 +524,14 @@ func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limi | |||||
| 524 | return historyEntriesFromRows(rows), nil | 524 | return historyEntriesFromRows(rows), nil | ||
| 525 | } | 525 | } | ||
| 526 | 526 | ||||
| 527 | - | func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) { | 527 | + | func (r *historyRepo) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) { |
| 528 | + | if limit <= 0 { | |||
| 529 | + | return []*entity.HistoryEntry{}, nil | |||
| 530 | + | } | |||
| 528 | rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{ | 531 | rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{ | ||
| 529 | - | Before: sql.NullTime{Time: before, Valid: true}, | 532 | + | Before: sql.NullTime{Time: before, Valid: true}, |
| 530 | - | After: sql.NullTime{Time: after, Valid: true}, | 533 | + | BeforeID: beforeID, |
| 534 | + | Limit: int64(limit), | |||
| 531 | }) | 535 | }) | ||
| 532 | if err != nil { | 536 | if err != nil { | ||
| 533 | return nil, err | 537 | return nil, err | ||
| @@ -535,16 +539,26 @@ func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Ti | |||||
| 535 | return historyEntriesFromRows(rows), nil | 539 | return historyEntriesFromRows(rows), nil | ||
| 536 | } | 540 | } | ||
| 537 | 541 | ||||
| 538 | - | func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) { | 542 | + | func (r *historyRepo) GetRecentWindowByDomain( |
| 543 | + | ctx context.Context, | |||
| 544 | + | domain string, | |||
| 545 | + | before time.Time, | |||
| 546 | + | beforeID int64, | |||
| 547 | + | limit int, | |||
| 548 | + | ) ([]*entity.HistoryEntry, error) { | |||
| 539 | rawDomain := domain | 549 | rawDomain := domain | ||
| 540 | domain = domainurl.CanonicalDomain(domain) | 550 | domain = domainurl.CanonicalDomain(domain) | ||
| 541 | if domain == "" { | 551 | if domain == "" { | ||
| 542 | return nil, fmt.Errorf("history domain is required: %q", rawDomain) | 552 | return nil, fmt.Errorf("history domain is required: %q", rawDomain) | ||
| 543 | } | 553 | } | ||
| 554 | + | if limit <= 0 { | |||
| 555 | + | return []*entity.HistoryEntry{}, nil | |||
| 556 | + | } | |||
| 544 | rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{ | 557 | rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{ | ||
| 545 | - | Domain: sql.NullString{String: domain, Valid: true}, | 558 | + | Domain: sql.NullString{String: domain, Valid: true}, |
| 546 | - | Before: sql.NullTime{Time: before, Valid: true}, | 559 | + | Before: sql.NullTime{Time: before, Valid: true}, |
| 547 | - | After: sql.NullTime{Time: after, Valid: true}, | 560 | + | BeforeID: beforeID, |
| 561 | + | Limit: int64(limit), | |||
| 548 | }) | 562 | }) | ||
| 549 | if err != nil { | 563 | if err != nil { | ||
| 550 | return nil, err | 564 | return nil, err | ||
| @@ -552,30 +566,6 @@ func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string | |||||
| 552 | return historyEntriesFromRows(rows), nil | 566 | return historyEntriesFromRows(rows), nil | ||
| 553 | } | 567 | } | ||
| 554 | 568 | ||||
| 555 | - | func (r *historyRepo) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |||
| 556 | - | value, err := r.queries.HasHistoryBefore(ctx, sql.NullTime{Time: before, Valid: true}) | |||
| 557 | - | if err != nil { | |||
| 558 | - | return false, err | |||
| 559 | - | } | |||
| 560 | - | return value != 0, nil | |||
| 561 | - | } | |||
| 562 | - | ||||
| 563 | - | func (r *historyRepo) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |||
| 564 | - | rawDomain := domain | |||
| 565 | - | domain = domainurl.CanonicalDomain(domain) | |||
| 566 | - | if domain == "" { | |||
| 567 | - | return false, fmt.Errorf("history domain is required: %q", rawDomain) | |||
| 568 | - | } | |||
| 569 | - | value, err := r.queries.HasHistoryByDomainBefore(ctx, sqlc.HasHistoryByDomainBeforeParams{ | |||
| 570 | - | Domain: sql.NullString{String: domain, Valid: true}, | |||
| 571 | - | Before: sql.NullTime{Time: before, Valid: true}, | |||
| 572 | - | }) | |||
| 573 | - | if err != nil { | |||
| 574 | - | return false, err | |||
| 575 | - | } | |||
| 576 | - | return value != 0, nil | |||
| 577 | - | } | |||
| 578 | - | ||||
| 579 | func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | 569 | func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | ||
| 580 | if days <= 0 { | 570 | if days <= 0 { | ||
| 581 | return nil, fmt.Errorf("days must be positive, got %d", days) | 571 | return nil, fmt.Errorf("days must be positive, got %d", days) | ||
M internal/infrastructure/persistence/sqlite/history_repo_test.go +47 -0
| @@ -2,6 +2,7 @@ package sqlite_test | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| @@ -890,3 +891,49 @@ func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing. | |||
| 890 | 891 | require.Len(t, remaining, 1) | |
| 891 | 892 | assert.Equal(t, "https://other.test", remaining[0].URL) | |
| 892 | 893 | } | |
| 894 | + | func TestHistoryRepository_GetRecentWindow_StableCursorWithTimestampTies(t *testing.T) { | |
| 895 | + | ctx := historyTestCtx() | |
| 896 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 897 | + | ||
| 898 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 899 | + | require.NoError(t, err) | |
| 900 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 901 | + | ||
| 902 | + | repo := sqlite.NewHistoryRepository(db) | |
| 903 | + | tied := time.Date(2026, 6, 24, 10, 0, 0, 0, time.UTC) | |
| 904 | + | older := tied.Add(-time.Hour) | |
| 905 | + | for i := 1; i <= 5; i++ { | |
| 906 | + | _, err = db.ExecContext(ctx, `INSERT INTO history (url, title, domain, last_visited, created_at) VALUES (?, ?, ?, ?, ?)`, | |
| 907 | + | fmt.Sprintf("https://example.com/%d", i), fmt.Sprintf("Entry %d", i), "example.com", tied, tied) | |
| 908 | + | require.NoError(t, err) | |
| 909 | + | } | |
| 910 | + | _, err = db.ExecContext(ctx, `INSERT INTO history (url, title, domain, last_visited, created_at) VALUES (?, ?, ?, ?, ?)`, | |
| 911 | + | "https://example.com/older", "Older", "example.com", older, older) | |
| 912 | + | require.NoError(t, err) | |
| 913 | + | ||
| 914 | + | page1, err := repo.GetRecentWindow(ctx, tied.Add(time.Second), 0, 3) | |
| 915 | + | require.NoError(t, err) | |
| 916 | + | require.Len(t, page1, 3) | |
| 917 | + | assert.Equal(t, []int64{5, 4, 3}, historyIDs(page1)) | |
| 918 | + | ||
| 919 | + | page2, err := repo.GetRecentWindow(ctx, page1[len(page1)-1].LastVisited, page1[len(page1)-1].ID, 3) | |
| 920 | + | require.NoError(t, err) | |
| 921 | + | require.Len(t, page2, 3) | |
| 922 | + | assert.Equal(t, []int64{2, 1, 6}, historyIDs(page2)) | |
| 923 | + | ||
| 924 | + | seen := map[int64]bool{} | |
| 925 | + | for _, entry := range append(page1, page2...) { | |
| 926 | + | if seen[entry.ID] { | |
| 927 | + | t.Fatalf("duplicate history id %d", entry.ID) | |
| 928 | + | } | |
| 929 | + | seen[entry.ID] = true | |
| 930 | + | } | |
| 931 | + | } | |
| 932 | + | ||
| 933 | + | func historyIDs(entries []*entity.HistoryEntry) []int64 { | |
| 934 | + | ids := make([]int64, len(entries)) | |
| 935 | + | for i, entry := range entries { | |
| 936 | + | ids[i] = entry.ID | |
| 937 | + | } | |
| 938 | + | return ids | |
| 939 | + | } | |
| @@ -2,6 +2,7 @@ package sqlite_test | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | "path/filepath" | 6 | "path/filepath" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| @@ -890,3 +891,49 @@ func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing. | |||||
| 890 | require.Len(t, remaining, 1) | 891 | require.Len(t, remaining, 1) | ||
| 891 | assert.Equal(t, "https://other.test", remaining[0].URL) | 892 | assert.Equal(t, "https://other.test", remaining[0].URL) | ||
| 892 | } | 893 | } | ||
| 894 | + | func TestHistoryRepository_GetRecentWindow_StableCursorWithTimestampTies(t *testing.T) { | |||
| 895 | + | ctx := historyTestCtx() | |||
| 896 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 897 | + | ||||
| 898 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 899 | + | require.NoError(t, err) | |||
| 900 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 901 | + | ||||
| 902 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 903 | + | tied := time.Date(2026, 6, 24, 10, 0, 0, 0, time.UTC) | |||
| 904 | + | older := tied.Add(-time.Hour) | |||
| 905 | + | for i := 1; i <= 5; i++ { | |||
| 906 | + | _, err = db.ExecContext(ctx, `INSERT INTO history (url, title, domain, last_visited, created_at) VALUES (?, ?, ?, ?, ?)`, | |||
| 907 | + | fmt.Sprintf("https://example.com/%d", i), fmt.Sprintf("Entry %d", i), "example.com", tied, tied) | |||
| 908 | + | require.NoError(t, err) | |||
| 909 | + | } | |||
| 910 | + | _, err = db.ExecContext(ctx, `INSERT INTO history (url, title, domain, last_visited, created_at) VALUES (?, ?, ?, ?, ?)`, | |||
| 911 | + | "https://example.com/older", "Older", "example.com", older, older) | |||
| 912 | + | require.NoError(t, err) | |||
| 913 | + | ||||
| 914 | + | page1, err := repo.GetRecentWindow(ctx, tied.Add(time.Second), 0, 3) | |||
| 915 | + | require.NoError(t, err) | |||
| 916 | + | require.Len(t, page1, 3) | |||
| 917 | + | assert.Equal(t, []int64{5, 4, 3}, historyIDs(page1)) | |||
| 918 | + | ||||
| 919 | + | page2, err := repo.GetRecentWindow(ctx, page1[len(page1)-1].LastVisited, page1[len(page1)-1].ID, 3) | |||
| 920 | + | require.NoError(t, err) | |||
| 921 | + | require.Len(t, page2, 3) | |||
| 922 | + | assert.Equal(t, []int64{2, 1, 6}, historyIDs(page2)) | |||
| 923 | + | ||||
| 924 | + | seen := map[int64]bool{} | |||
| 925 | + | for _, entry := range append(page1, page2...) { | |||
| 926 | + | if seen[entry.ID] { | |||
| 927 | + | t.Fatalf("duplicate history id %d", entry.ID) | |||
| 928 | + | } | |||
| 929 | + | seen[entry.ID] = true | |||
| 930 | + | } | |||
| 931 | + | } | |||
| 932 | + | ||||
| 933 | + | func historyIDs(entries []*entity.HistoryEntry) []int64 { | |||
| 934 | + | ids := make([]int64, len(entries)) | |||
| 935 | + | for i, entry := range entries { | |||
| 936 | + | ids[i] = entry.ID | |||
| 937 | + | } | |||
| 938 | + | return ids | |||
| 939 | + | } | |||
M internal/infrastructure/persistence/sqlite/lazy_repos.go +10 -18
| @@ -156,37 +156,29 @@ func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain st | |||
| 156 | 156 | return r.repo.GetRecentByDomain(ctx, domain, limit, offset) | |
| 157 | 157 | } | |
| 158 | 158 | ||
| 159 | - | func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) { | |
| 159 | + | func (r *LazyHistoryRepository) GetRecentWindow( | |
| 160 | + | ctx context.Context, | |
| 161 | + | before time.Time, | |
| 162 | + | beforeID int64, | |
| 163 | + | limit int, | |
| 164 | + | ) ([]*entity.HistoryEntry, error) { | |
| 160 | 165 | if err := r.init(ctx); err != nil { | |
| 161 | 166 | return nil, err | |
| 162 | 167 | } | |
| 163 | - | return r.repo.GetRecentWindow(ctx, before, after) | |
| 168 | + | return r.repo.GetRecentWindow(ctx, before, beforeID, limit) | |
| 164 | 169 | } | |
| 165 | 170 | ||
| 166 | 171 | func (r *LazyHistoryRepository) GetRecentWindowByDomain( | |
| 167 | 172 | ctx context.Context, | |
| 168 | 173 | domain string, | |
| 169 | 174 | before time.Time, | |
| 170 | - | after time.Time, | |
| 175 | + | beforeID int64, | |
| 176 | + | limit int, | |
| 171 | 177 | ) ([]*entity.HistoryEntry, error) { | |
| 172 | 178 | if err := r.init(ctx); err != nil { | |
| 173 | 179 | return nil, err | |
| 174 | 180 | } | |
| 175 | - | return r.repo.GetRecentWindowByDomain(ctx, domain, before, after) | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | func (r *LazyHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |
| 179 | - | if err := r.init(ctx); err != nil { | |
| 180 | - | return false, err | |
| 181 | - | } | |
| 182 | - | return r.repo.HasEntriesBefore(ctx, before) | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | func (r *LazyHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |
| 186 | - | if err := r.init(ctx); err != nil { | |
| 187 | - | return false, err | |
| 188 | - | } | |
| 189 | - | return r.repo.HasEntriesByDomainBefore(ctx, domain, before) | |
| 181 | + | return r.repo.GetRecentWindowByDomain(ctx, domain, before, beforeID, limit) | |
| 190 | 182 | } | |
| 191 | 183 | ||
| 192 | 184 | func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| @@ -156,37 +156,29 @@ func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain st | |||||
| 156 | return r.repo.GetRecentByDomain(ctx, domain, limit, offset) | 156 | return r.repo.GetRecentByDomain(ctx, domain, limit, offset) | ||
| 157 | } | 157 | } | ||
| 158 | 158 | ||||
| 159 | - | func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) { | 159 | + | func (r *LazyHistoryRepository) GetRecentWindow( |
| 160 | + | ctx context.Context, | |||
| 161 | + | before time.Time, | |||
| 162 | + | beforeID int64, | |||
| 163 | + | limit int, | |||
| 164 | + | ) ([]*entity.HistoryEntry, error) { | |||
| 160 | if err := r.init(ctx); err != nil { | 165 | if err := r.init(ctx); err != nil { | ||
| 161 | return nil, err | 166 | return nil, err | ||
| 162 | } | 167 | } | ||
| 163 | - | return r.repo.GetRecentWindow(ctx, before, after) | 168 | + | return r.repo.GetRecentWindow(ctx, before, beforeID, limit) |
| 164 | } | 169 | } | ||
| 165 | 170 | ||||
| 166 | func (r *LazyHistoryRepository) GetRecentWindowByDomain( | 171 | func (r *LazyHistoryRepository) GetRecentWindowByDomain( | ||
| 167 | ctx context.Context, | 172 | ctx context.Context, | ||
| 168 | domain string, | 173 | domain string, | ||
| 169 | before time.Time, | 174 | before time.Time, | ||
| 170 | - | after time.Time, | 175 | + | beforeID int64, |
| 176 | + | limit int, | |||
| 171 | ) ([]*entity.HistoryEntry, error) { | 177 | ) ([]*entity.HistoryEntry, error) { | ||
| 172 | if err := r.init(ctx); err != nil { | 178 | if err := r.init(ctx); err != nil { | ||
| 173 | return nil, err | 179 | return nil, err | ||
| 174 | } | 180 | } | ||
| 175 | - | return r.repo.GetRecentWindowByDomain(ctx, domain, before, after) | 181 | + | return r.repo.GetRecentWindowByDomain(ctx, domain, before, beforeID, limit) |
| 176 | - | } | |||
| 177 | - | ||||
| 178 | - | func (r *LazyHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |||
| 179 | - | if err := r.init(ctx); err != nil { | |||
| 180 | - | return false, err | |||
| 181 | - | } | |||
| 182 | - | return r.repo.HasEntriesBefore(ctx, before) | |||
| 183 | - | } | |||
| 184 | - | ||||
| 185 | - | func (r *LazyHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |||
| 186 | - | if err := r.init(ctx); err != nil { | |||
| 187 | - | return false, err | |||
| 188 | - | } | |||
| 189 | - | return r.repo.HasEntriesByDomainBefore(ctx, domain, before) | |||
| 190 | } | 182 | } | ||
| 191 | 183 | ||||
| 192 | func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | 184 | func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | ||
M internal/infrastructure/persistence/sqlite/queries/history.sql +12 -14
| @@ -1,32 +1,30 @@ | |||
| 1 | 1 | -- name: GetRecentHistory :many | |
| 2 | - | SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?; | |
| 2 | + | SELECT * FROM history | |
| 3 | + | ORDER BY last_visited DESC, id DESC | |
| 4 | + | LIMIT ? OFFSET ?; | |
| 3 | 5 | ||
| 4 | 6 | -- name: GetRecentHistoryByDomain :many | |
| 5 | 7 | SELECT * FROM history | |
| 6 | 8 | WHERE domain = @domain | |
| 7 | - | ORDER BY last_visited DESC | |
| 9 | + | ORDER BY last_visited DESC, id DESC | |
| 8 | 10 | LIMIT @limit OFFSET @offset; | |
| 9 | 11 | ||
| 10 | 12 | -- name: GetAllRecentHistoryByDomain :many | |
| 11 | 13 | SELECT * FROM history | |
| 12 | 14 | WHERE domain = @domain | |
| 13 | - | ORDER BY last_visited DESC; | |
| 15 | + | ORDER BY last_visited DESC, id DESC; | |
| 14 | 16 | ||
| 15 | 17 | -- name: GetRecentHistoryWindow :many | |
| 16 | 18 | SELECT * FROM history | |
| 17 | - | WHERE last_visited < @before AND last_visited >= @after | |
| 18 | - | ORDER BY last_visited DESC; | |
| 19 | + | WHERE last_visited < @before OR (last_visited = @before AND id < @before_id) | |
| 20 | + | ORDER BY last_visited DESC, id DESC | |
| 21 | + | LIMIT @limit; | |
| 19 | 22 | ||
| 20 | 23 | -- name: GetRecentHistoryWindowByDomain :many | |
| 21 | 24 | SELECT * FROM history | |
| 22 | - | WHERE domain = @domain AND last_visited < @before AND last_visited >= @after | |
| 23 | - | ORDER BY last_visited DESC; | |
| 24 | - | ||
| 25 | - | -- name: HasHistoryBefore :one | |
| 26 | - | SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < @before LIMIT 1); | |
| 27 | - | ||
| 28 | - | -- name: HasHistoryByDomainBefore :one | |
| 29 | - | SELECT EXISTS(SELECT 1 FROM history WHERE domain = @domain AND last_visited < @before LIMIT 1); | |
| 25 | + | WHERE domain = @domain AND (last_visited < @before OR (last_visited = @before AND id < @before_id)) | |
| 26 | + | ORDER BY last_visited DESC, id DESC | |
| 27 | + | LIMIT @limit; | |
| 30 | 28 | ||
| 31 | 29 | -- name: GetHistoryByURL :one | |
| 32 | 30 | SELECT * FROM history WHERE url = ? LIMIT 1; | |
| @@ -153,7 +151,7 @@ ORDER BY visit_count DESC, last_visited DESC; | |||
| 153 | 151 | ||
| 154 | 152 | -- name: GetAllRecentHistory :many | |
| 155 | 153 | SELECT * FROM history | |
| 156 | - | ORDER BY last_visited DESC; | |
| 154 | + | ORDER BY last_visited DESC, id DESC; | |
| 157 | 155 | ||
| 158 | 156 | -- name: GetAllMostVisited :many | |
| 159 | 157 | SELECT * FROM history | |
| @@ -1,32 +1,30 @@ | |||||
| 1 | -- name: GetRecentHistory :many | 1 | -- name: GetRecentHistory :many | ||
| 2 | - | SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?; | 2 | + | SELECT * FROM history |
| 3 | + | ORDER BY last_visited DESC, id DESC | |||
| 4 | + | LIMIT ? OFFSET ?; | |||
| 3 | 5 | ||||
| 4 | -- name: GetRecentHistoryByDomain :many | 6 | -- name: GetRecentHistoryByDomain :many | ||
| 5 | SELECT * FROM history | 7 | SELECT * FROM history | ||
| 6 | WHERE domain = @domain | 8 | WHERE domain = @domain | ||
| 7 | - | ORDER BY last_visited DESC | 9 | + | ORDER BY last_visited DESC, id DESC |
| 8 | LIMIT @limit OFFSET @offset; | 10 | LIMIT @limit OFFSET @offset; | ||
| 9 | 11 | ||||
| 10 | -- name: GetAllRecentHistoryByDomain :many | 12 | -- name: GetAllRecentHistoryByDomain :many | ||
| 11 | SELECT * FROM history | 13 | SELECT * FROM history | ||
| 12 | WHERE domain = @domain | 14 | WHERE domain = @domain | ||
| 13 | - | ORDER BY last_visited DESC; | 15 | + | ORDER BY last_visited DESC, id DESC; |
| 14 | 16 | ||||
| 15 | -- name: GetRecentHistoryWindow :many | 17 | -- name: GetRecentHistoryWindow :many | ||
| 16 | SELECT * FROM history | 18 | SELECT * FROM history | ||
| 17 | - | WHERE last_visited < @before AND last_visited >= @after | 19 | + | WHERE last_visited < @before OR (last_visited = @before AND id < @before_id) |
| 18 | - | ORDER BY last_visited DESC; | 20 | + | ORDER BY last_visited DESC, id DESC |
| 21 | + | LIMIT @limit; | |||
| 19 | 22 | ||||
| 20 | -- name: GetRecentHistoryWindowByDomain :many | 23 | -- name: GetRecentHistoryWindowByDomain :many | ||
| 21 | SELECT * FROM history | 24 | SELECT * FROM history | ||
| 22 | - | WHERE domain = @domain AND last_visited < @before AND last_visited >= @after | 25 | + | WHERE domain = @domain AND (last_visited < @before OR (last_visited = @before AND id < @before_id)) |
| 23 | - | ORDER BY last_visited DESC; | 26 | + | ORDER BY last_visited DESC, id DESC |
| 24 | - | 27 | + | LIMIT @limit; | |
| 25 | - | -- name: HasHistoryBefore :one | |||
| 26 | - | SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < @before LIMIT 1); | |||
| 27 | - | ||||
| 28 | - | -- name: HasHistoryByDomainBefore :one | |||
| 29 | - | SELECT EXISTS(SELECT 1 FROM history WHERE domain = @domain AND last_visited < @before LIMIT 1); | |||
| 30 | 28 | ||||
| 31 | -- name: GetHistoryByURL :one | 29 | -- name: GetHistoryByURL :one | ||
| 32 | SELECT * FROM history WHERE url = ? LIMIT 1; | 30 | SELECT * FROM history WHERE url = ? LIMIT 1; | ||
| @@ -153,7 +151,7 @@ ORDER BY visit_count DESC, last_visited DESC; | |||||
| 153 | 151 | ||||
| 154 | -- name: GetAllRecentHistory :many | 152 | -- name: GetAllRecentHistory :many | ||
| 155 | SELECT * FROM history | 153 | SELECT * FROM history | ||
| 156 | - | ORDER BY last_visited DESC; | 154 | + | ORDER BY last_visited DESC, id DESC; |
| 157 | 155 | ||||
| 158 | -- name: GetAllMostVisited :many | 156 | -- name: GetAllMostVisited :many | ||
| 159 | SELECT * FROM history | 157 | SELECT * FROM history | ||
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +26 -42
| @@ -109,7 +109,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) { | |||
| 109 | 109 | ||
| 110 | 110 | const GetAllRecentHistory = `-- name: GetAllRecentHistory :many | |
| 111 | 111 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 112 | - | ORDER BY last_visited DESC | |
| 112 | + | ORDER BY last_visited DESC, id DESC | |
| 113 | 113 | ` | |
| 114 | 114 | ||
| 115 | 115 | func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) { | |
| @@ -147,7 +147,7 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) { | |||
| 147 | 147 | const GetAllRecentHistoryByDomain = `-- name: GetAllRecentHistoryByDomain :many | |
| 148 | 148 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 149 | 149 | WHERE domain = ?1 | |
| 150 | - | ORDER BY last_visited DESC | |
| 150 | + | ORDER BY last_visited DESC, id DESC | |
| 151 | 151 | ` | |
| 152 | 152 | ||
| 153 | 153 | func (q *Queries) GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) { | |
| @@ -387,7 +387,9 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H | |||
| 387 | 387 | } | |
| 388 | 388 | ||
| 389 | 389 | const GetRecentHistory = `-- name: GetRecentHistory :many | |
| 390 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ? | |
| 390 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 391 | + | ORDER BY last_visited DESC, id DESC | |
| 392 | + | LIMIT ? OFFSET ? | |
| 391 | 393 | ` | |
| 392 | 394 | ||
| 393 | 395 | type GetRecentHistoryParams struct { | |
| @@ -430,7 +432,7 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara | |||
| 430 | 432 | const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many | |
| 431 | 433 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 432 | 434 | WHERE domain = ?1 | |
| 433 | - | ORDER BY last_visited DESC | |
| 435 | + | ORDER BY last_visited DESC, id DESC | |
| 434 | 436 | LIMIT ?3 OFFSET ?2 | |
| 435 | 437 | ` | |
| 436 | 438 | ||
| @@ -512,17 +514,19 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{ | |||
| 512 | 514 | ||
| 513 | 515 | const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many | |
| 514 | 516 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 515 | - | WHERE last_visited < ?1 AND last_visited >= ?2 | |
| 516 | - | ORDER BY last_visited DESC | |
| 517 | + | WHERE last_visited < ?1 OR (last_visited = ?1 AND id < ?2) | |
| 518 | + | ORDER BY last_visited DESC, id DESC | |
| 519 | + | LIMIT ?3 | |
| 517 | 520 | ` | |
| 518 | 521 | ||
| 519 | 522 | type GetRecentHistoryWindowParams struct { | |
| 520 | - | Before sql.NullTime `json:"before"` | |
| 521 | - | After sql.NullTime `json:"after"` | |
| 523 | + | Before sql.NullTime `json:"before"` | |
| 524 | + | BeforeID int64 `json:"before_id"` | |
| 525 | + | Limit int64 `json:"limit"` | |
| 522 | 526 | } | |
| 523 | 527 | ||
| 524 | 528 | func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) { | |
| 525 | - | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.After) | |
| 529 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.BeforeID, arg.Limit) | |
| 526 | 530 | if err != nil { | |
| 527 | 531 | return nil, err | |
| 528 | 532 | } | |
| @@ -555,18 +559,25 @@ func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHisto | |||
| 555 | 559 | ||
| 556 | 560 | const GetRecentHistoryWindowByDomain = `-- name: GetRecentHistoryWindowByDomain :many | |
| 557 | 561 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 558 | - | WHERE domain = ?1 AND last_visited < ?2 AND last_visited >= ?3 | |
| 559 | - | ORDER BY last_visited DESC | |
| 562 | + | WHERE domain = ?1 AND (last_visited < ?2 OR (last_visited = ?2 AND id < ?3)) | |
| 563 | + | ORDER BY last_visited DESC, id DESC | |
| 564 | + | LIMIT ?4 | |
| 560 | 565 | ` | |
| 561 | 566 | ||
| 562 | 567 | type GetRecentHistoryWindowByDomainParams struct { | |
| 563 | - | Domain sql.NullString `json:"domain"` | |
| 564 | - | Before sql.NullTime `json:"before"` | |
| 565 | - | After sql.NullTime `json:"after"` | |
| 568 | + | Domain sql.NullString `json:"domain"` | |
| 569 | + | Before sql.NullTime `json:"before"` | |
| 570 | + | BeforeID int64 `json:"before_id"` | |
| 571 | + | Limit int64 `json:"limit"` | |
| 566 | 572 | } | |
| 567 | 573 | ||
| 568 | 574 | func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) { | |
| 569 | - | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain, arg.Domain, arg.Before, arg.After) | |
| 575 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain, | |
| 576 | + | arg.Domain, | |
| 577 | + | arg.Before, | |
| 578 | + | arg.BeforeID, | |
| 579 | + | arg.Limit, | |
| 580 | + | ) | |
| 570 | 581 | if err != nil { | |
| 571 | 582 | return nil, err | |
| 572 | 583 | } | |
| @@ -597,33 +608,6 @@ func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRec | |||
| 597 | 608 | return items, nil | |
| 598 | 609 | } | |
| 599 | 610 | ||
| 600 | - | const HasHistoryBefore = `-- name: HasHistoryBefore :one | |
| 601 | - | SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < ?1 LIMIT 1) | |
| 602 | - | ` | |
| 603 | - | ||
| 604 | - | func (q *Queries) HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) { | |
| 605 | - | row := q.db.QueryRowContext(ctx, HasHistoryBefore, before) | |
| 606 | - | var column_1 int64 | |
| 607 | - | err := row.Scan(&column_1) | |
| 608 | - | return column_1, err | |
| 609 | - | } | |
| 610 | - | ||
| 611 | - | const HasHistoryByDomainBefore = `-- name: HasHistoryByDomainBefore :one | |
| 612 | - | SELECT EXISTS(SELECT 1 FROM history WHERE domain = ?1 AND last_visited < ?2 LIMIT 1) | |
| 613 | - | ` | |
| 614 | - | ||
| 615 | - | type HasHistoryByDomainBeforeParams struct { | |
| 616 | - | Domain sql.NullString `json:"domain"` | |
| 617 | - | Before sql.NullTime `json:"before"` | |
| 618 | - | } | |
| 619 | - | ||
| 620 | - | func (q *Queries) HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) { | |
| 621 | - | row := q.db.QueryRowContext(ctx, HasHistoryByDomainBefore, arg.Domain, arg.Before) | |
| 622 | - | var column_1 int64 | |
| 623 | - | err := row.Scan(&column_1) | |
| 624 | - | return column_1, err | |
| 625 | - | } | |
| 626 | - | ||
| 627 | 611 | const IncrementVisitCount = `-- name: IncrementVisitCount :exec | |
| 628 | 612 | UPDATE history | |
| 629 | 613 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | |
| @@ -109,7 +109,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) { | |||||
| 109 | 109 | ||||
| 110 | const GetAllRecentHistory = `-- name: GetAllRecentHistory :many | 110 | const GetAllRecentHistory = `-- name: GetAllRecentHistory :many | ||
| 111 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | 111 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | ||
| 112 | - | ORDER BY last_visited DESC | 112 | + | ORDER BY last_visited DESC, id DESC |
| 113 | ` | 113 | ` | ||
| 114 | 114 | ||||
| 115 | func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) { | 115 | func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) { | ||
| @@ -147,7 +147,7 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) { | |||||
| 147 | const GetAllRecentHistoryByDomain = `-- name: GetAllRecentHistoryByDomain :many | 147 | const GetAllRecentHistoryByDomain = `-- name: GetAllRecentHistoryByDomain :many | ||
| 148 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | 148 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | ||
| 149 | WHERE domain = ?1 | 149 | WHERE domain = ?1 | ||
| 150 | - | ORDER BY last_visited DESC | 150 | + | ORDER BY last_visited DESC, id DESC |
| 151 | ` | 151 | ` | ||
| 152 | 152 | ||||
| 153 | func (q *Queries) GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) { | 153 | func (q *Queries) GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) { | ||
| @@ -387,7 +387,9 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H | |||||
| 387 | } | 387 | } | ||
| 388 | 388 | ||||
| 389 | const GetRecentHistory = `-- name: GetRecentHistory :many | 389 | const GetRecentHistory = `-- name: GetRecentHistory :many | ||
| 390 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ? | 390 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history |
| 391 | + | ORDER BY last_visited DESC, id DESC | |||
| 392 | + | LIMIT ? OFFSET ? | |||
| 391 | ` | 393 | ` | ||
| 392 | 394 | ||||
| 393 | type GetRecentHistoryParams struct { | 395 | type GetRecentHistoryParams struct { | ||
| @@ -430,7 +432,7 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara | |||||
| 430 | const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many | 432 | const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many | ||
| 431 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | 433 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | ||
| 432 | WHERE domain = ?1 | 434 | WHERE domain = ?1 | ||
| 433 | - | ORDER BY last_visited DESC | 435 | + | ORDER BY last_visited DESC, id DESC |
| 434 | LIMIT ?3 OFFSET ?2 | 436 | LIMIT ?3 OFFSET ?2 | ||
| 435 | ` | 437 | ` | ||
| 436 | 438 | ||||
| @@ -512,17 +514,19 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{ | |||||
| 512 | 514 | ||||
| 513 | const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many | 515 | const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many | ||
| 514 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | 516 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | ||
| 515 | - | WHERE last_visited < ?1 AND last_visited >= ?2 | 517 | + | WHERE last_visited < ?1 OR (last_visited = ?1 AND id < ?2) |
| 516 | - | ORDER BY last_visited DESC | 518 | + | ORDER BY last_visited DESC, id DESC |
| 519 | + | LIMIT ?3 | |||
| 517 | ` | 520 | ` | ||
| 518 | 521 | ||||
| 519 | type GetRecentHistoryWindowParams struct { | 522 | type GetRecentHistoryWindowParams struct { | ||
| 520 | - | Before sql.NullTime `json:"before"` | 523 | + | Before sql.NullTime `json:"before"` |
| 521 | - | After sql.NullTime `json:"after"` | 524 | + | BeforeID int64 `json:"before_id"` |
| 525 | + | Limit int64 `json:"limit"` | |||
| 522 | } | 526 | } | ||
| 523 | 527 | ||||
| 524 | func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) { | 528 | func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) { | ||
| 525 | - | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.After) | 529 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.BeforeID, arg.Limit) |
| 526 | if err != nil { | 530 | if err != nil { | ||
| 527 | return nil, err | 531 | return nil, err | ||
| 528 | } | 532 | } | ||
| @@ -555,18 +559,25 @@ func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHisto | |||||
| 555 | 559 | ||||
| 556 | const GetRecentHistoryWindowByDomain = `-- name: GetRecentHistoryWindowByDomain :many | 560 | const GetRecentHistoryWindowByDomain = `-- name: GetRecentHistoryWindowByDomain :many | ||
| 557 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | 561 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | ||
| 558 | - | WHERE domain = ?1 AND last_visited < ?2 AND last_visited >= ?3 | 562 | + | WHERE domain = ?1 AND (last_visited < ?2 OR (last_visited = ?2 AND id < ?3)) |
| 559 | - | ORDER BY last_visited DESC | 563 | + | ORDER BY last_visited DESC, id DESC |
| 564 | + | LIMIT ?4 | |||
| 560 | ` | 565 | ` | ||
| 561 | 566 | ||||
| 562 | type GetRecentHistoryWindowByDomainParams struct { | 567 | type GetRecentHistoryWindowByDomainParams struct { | ||
| 563 | - | Domain sql.NullString `json:"domain"` | 568 | + | Domain sql.NullString `json:"domain"` |
| 564 | - | Before sql.NullTime `json:"before"` | 569 | + | Before sql.NullTime `json:"before"` |
| 565 | - | After sql.NullTime `json:"after"` | 570 | + | BeforeID int64 `json:"before_id"` |
| 571 | + | Limit int64 `json:"limit"` | |||
| 566 | } | 572 | } | ||
| 567 | 573 | ||||
| 568 | func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) { | 574 | func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) { | ||
| 569 | - | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain, arg.Domain, arg.Before, arg.After) | 575 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain, |
| 576 | + | arg.Domain, | |||
| 577 | + | arg.Before, | |||
| 578 | + | arg.BeforeID, | |||
| 579 | + | arg.Limit, | |||
| 580 | + | ) | |||
| 570 | if err != nil { | 581 | if err != nil { | ||
| 571 | return nil, err | 582 | return nil, err | ||
| 572 | } | 583 | } | ||
| @@ -597,33 +608,6 @@ func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRec | |||||
| 597 | return items, nil | 608 | return items, nil | ||
| 598 | } | 609 | } | ||
| 599 | 610 | ||||
| 600 | - | const HasHistoryBefore = `-- name: HasHistoryBefore :one | |||
| 601 | - | SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < ?1 LIMIT 1) | |||
| 602 | - | ` | |||
| 603 | - | ||||
| 604 | - | func (q *Queries) HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) { | |||
| 605 | - | row := q.db.QueryRowContext(ctx, HasHistoryBefore, before) | |||
| 606 | - | var column_1 int64 | |||
| 607 | - | err := row.Scan(&column_1) | |||
| 608 | - | return column_1, err | |||
| 609 | - | } | |||
| 610 | - | ||||
| 611 | - | const HasHistoryByDomainBefore = `-- name: HasHistoryByDomainBefore :one | |||
| 612 | - | SELECT EXISTS(SELECT 1 FROM history WHERE domain = ?1 AND last_visited < ?2 LIMIT 1) | |||
| 613 | - | ` | |||
| 614 | - | ||||
| 615 | - | type HasHistoryByDomainBeforeParams struct { | |||
| 616 | - | Domain sql.NullString `json:"domain"` | |||
| 617 | - | Before sql.NullTime `json:"before"` | |||
| 618 | - | } | |||
| 619 | - | ||||
| 620 | - | func (q *Queries) HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) { | |||
| 621 | - | row := q.db.QueryRowContext(ctx, HasHistoryByDomainBefore, arg.Domain, arg.Before) | |||
| 622 | - | var column_1 int64 | |||
| 623 | - | err := row.Scan(&column_1) | |||
| 624 | - | return column_1, err | |||
| 625 | - | } | |||
| 626 | - | ||||
| 627 | const IncrementVisitCount = `-- name: IncrementVisitCount :exec | 611 | const IncrementVisitCount = `-- name: IncrementVisitCount :exec | ||
| 628 | UPDATE history | 612 | UPDATE history | ||
| 629 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | 613 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | ||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +0 -2
| @@ -72,8 +72,6 @@ type Querier interface { | |||
| 72 | 72 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | |
| 73 | 73 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | |
| 74 | 74 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | |
| 75 | - | HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) | |
| 76 | - | HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) | |
| 77 | 75 | IncrementVisitCount(ctx context.Context, url string) error | |
| 78 | 76 | IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error | |
| 79 | 77 | InsertSession(ctx context.Context, arg InsertSessionParams) error | |
| @@ -72,8 +72,6 @@ type Querier interface { | |||||
| 72 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | 72 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | ||
| 73 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | 73 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | ||
| 74 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | 74 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | ||
| 75 | - | HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) | |||
| 76 | - | HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) | |||
| 77 | IncrementVisitCount(ctx context.Context, url string) error | 75 | IncrementVisitCount(ctx context.Context, url string) error | ||
| 78 | IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error | 76 | IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error | ||
| 79 | InsertSession(ctx context.Context, arg InsertSessionParams) error | 77 | InsertSession(ctx context.Context, arg InsertSessionParams) error | ||
M internal/infrastructure/systemviewsbridge/client.go +9 -2
| @@ -108,7 +108,13 @@ func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, off | |||
| 108 | 108 | }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset}) | |
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | - | func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |
| 111 | + | func (c *Client) TimelineWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) { | |
| 112 | + | if beforeID < 0 { | |
| 113 | + | return nil, fmt.Errorf("history window cursor beforeID must be non-negative") | |
| 114 | + | } | |
| 115 | + | if before.IsZero() != (beforeID == 0) { | |
| 116 | + | return nil, fmt.Errorf("history window cursor requires before and beforeID") | |
| 117 | + | } | |
| 112 | 118 | beforeCursor := "" | |
| 113 | 119 | if !before.IsZero() { | |
| 114 | 120 | beforeCursor = before.Format(time.RFC3339Nano) | |
| @@ -116,8 +122,9 @@ func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain st | |||
| 116 | 122 | return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct { | |
| 117 | 123 | RequestID string `json:"requestId"` | |
| 118 | 124 | Before string `json:"before,omitempty"` | |
| 125 | + | BeforeID int64 `json:"beforeId,omitempty"` | |
| 119 | 126 | Domain string `json:"domain,omitempty"` | |
| 120 | - | }{RequestID: nextRequestID(), Before: beforeCursor, Domain: strings.TrimSpace(domain)}) | |
| 127 | + | }{RequestID: nextRequestID(), Before: beforeCursor, BeforeID: beforeID, Domain: strings.TrimSpace(domain)}) | |
| 121 | 128 | } | |
| 122 | 129 | ||
| 123 | 130 | func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | |
| @@ -108,7 +108,13 @@ func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, off | |||||
| 108 | }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset}) | 108 | }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset}) | ||
| 109 | } | 109 | } | ||
| 110 | 110 | ||||
| 111 | - | func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | 111 | + | func (c *Client) TimelineWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) { |
| 112 | + | if beforeID < 0 { | |||
| 113 | + | return nil, fmt.Errorf("history window cursor beforeID must be non-negative") | |||
| 114 | + | } | |||
| 115 | + | if before.IsZero() != (beforeID == 0) { | |||
| 116 | + | return nil, fmt.Errorf("history window cursor requires before and beforeID") | |||
| 117 | + | } | |||
| 112 | beforeCursor := "" | 118 | beforeCursor := "" | ||
| 113 | if !before.IsZero() { | 119 | if !before.IsZero() { | ||
| 114 | beforeCursor = before.Format(time.RFC3339Nano) | 120 | beforeCursor = before.Format(time.RFC3339Nano) | ||
| @@ -116,8 +122,9 @@ func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain st | |||||
| 116 | return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct { | 122 | return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct { | ||
| 117 | RequestID string `json:"requestId"` | 123 | RequestID string `json:"requestId"` | ||
| 118 | Before string `json:"before,omitempty"` | 124 | Before string `json:"before,omitempty"` | ||
| 125 | + | BeforeID int64 `json:"beforeId,omitempty"` | |||
| 119 | Domain string `json:"domain,omitempty"` | 126 | Domain string `json:"domain,omitempty"` | ||
| 120 | - | }{RequestID: nextRequestID(), Before: beforeCursor, Domain: strings.TrimSpace(domain)}) | 127 | + | }{RequestID: nextRequestID(), Before: beforeCursor, BeforeID: beforeID, Domain: strings.TrimSpace(domain)}) |
| 121 | } | 128 | } | ||
| 122 | 129 | ||||
| 123 | func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | 130 | func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | ||
M internal/infrastructure/systemviewsbridge/client_test.go +54 -2
| @@ -135,7 +135,7 @@ func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) { | |||
| 135 | 135 | native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`)) | |
| 136 | 136 | client := NewClient(native, nil) | |
| 137 | 137 | ||
| 138 | - | _, err := client.TimelineWindow(context.Background(), time.Time{}, "") | |
| 138 | + | _, err := client.TimelineWindow(context.Background(), time.Time{}, 0, "") | |
| 139 | 139 | if err != nil { | |
| 140 | 140 | t.Fatalf("TimelineWindow() error = %v", err) | |
| 141 | 141 | } | |
| @@ -152,7 +152,59 @@ func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) { | |||
| 152 | 152 | t.Fatalf("unmarshal payload: %v", err) | |
| 153 | 153 | } | |
| 154 | 154 | if _, ok := payload["before"]; ok { | |
| 155 | - | t.Fatalf("zero TimelineWindow cursor should be omitted, payload = %s", msg.Payload) | |
| 155 | + | t.Fatalf("zero TimelineWindow cursor should be omit before, payload = %s", msg.Payload) | |
| 156 | + | } | |
| 157 | + | if _, ok := payload["beforeId"]; ok { | |
| 158 | + | t.Fatalf("zero TimelineWindow cursor should omit beforeId, payload = %s", msg.Payload) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestClientTimelineWindowSendsCompleteCursor(t *testing.T) { | |
| 163 | + | t.Parallel() | |
| 164 | + | ||
| 165 | + | native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`)) | |
| 166 | + | client := NewClient(native, nil) | |
| 167 | + | before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |
| 168 | + | ||
| 169 | + | _, err := client.TimelineWindow(context.Background(), before, 42, "example.com") | |
| 170 | + | if err != nil { | |
| 171 | + | t.Fatalf("TimelineWindow() error = %v", err) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | var msg port.WebUIMessage | |
| 175 | + | if err := json.Unmarshal(native.last, &msg); err != nil { | |
| 176 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |
| 177 | + | } | |
| 178 | + | var payload map[string]any | |
| 179 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |
| 180 | + | t.Fatalf("unmarshal payload: %v", err) | |
| 181 | + | } | |
| 182 | + | if payload["before"] != before.Format(time.RFC3339Nano) { | |
| 183 | + | t.Fatalf("before = %v, want %s", payload["before"], before.Format(time.RFC3339Nano)) | |
| 184 | + | } | |
| 185 | + | if payload["beforeId"] != float64(42) { | |
| 186 | + | t.Fatalf("beforeId = %v, want 42", payload["beforeId"]) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestClientTimelineWindowRejectsPartialCursor(t *testing.T) { | |
| 191 | + | t.Parallel() | |
| 192 | + | ||
| 193 | + | native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`)) | |
| 194 | + | client := NewClient(native, nil) | |
| 195 | + | before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |
| 196 | + | ||
| 197 | + | if _, err := client.TimelineWindow(context.Background(), before, 0, ""); err == nil { | |
| 198 | + | t.Fatal("TimelineWindow(before without beforeID) error = nil") | |
| 199 | + | } | |
| 200 | + | if _, err := client.TimelineWindow(context.Background(), time.Time{}, 42, ""); err == nil { | |
| 201 | + | t.Fatal("TimelineWindow(beforeID without before) error = nil") | |
| 202 | + | } | |
| 203 | + | if _, err := client.TimelineWindow(context.Background(), time.Time{}, -1, ""); err == nil { | |
| 204 | + | t.Fatal("TimelineWindow(negative beforeID without before) error = nil") | |
| 205 | + | } | |
| 206 | + | if native.called { | |
| 207 | + | t.Fatal("TimelineWindow sent request for invalid partial cursor") | |
| 156 | 208 | } | |
| 157 | 209 | } | |
| 158 | 210 | ||
| @@ -135,7 +135,7 @@ func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) { | |||||
| 135 | native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`)) | 135 | native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`)) | ||
| 136 | client := NewClient(native, nil) | 136 | client := NewClient(native, nil) | ||
| 137 | 137 | ||||
| 138 | - | _, err := client.TimelineWindow(context.Background(), time.Time{}, "") | 138 | + | _, err := client.TimelineWindow(context.Background(), time.Time{}, 0, "") |
| 139 | if err != nil { | 139 | if err != nil { | ||
| 140 | t.Fatalf("TimelineWindow() error = %v", err) | 140 | t.Fatalf("TimelineWindow() error = %v", err) | ||
| 141 | } | 141 | } | ||
| @@ -152,7 +152,59 @@ func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) { | |||||
| 152 | t.Fatalf("unmarshal payload: %v", err) | 152 | t.Fatalf("unmarshal payload: %v", err) | ||
| 153 | } | 153 | } | ||
| 154 | if _, ok := payload["before"]; ok { | 154 | if _, ok := payload["before"]; ok { | ||
| 155 | - | t.Fatalf("zero TimelineWindow cursor should be omitted, payload = %s", msg.Payload) | 155 | + | t.Fatalf("zero TimelineWindow cursor should be omit before, payload = %s", msg.Payload) |
| 156 | + | } | |||
| 157 | + | if _, ok := payload["beforeId"]; ok { | |||
| 158 | + | t.Fatalf("zero TimelineWindow cursor should omit beforeId, payload = %s", msg.Payload) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestClientTimelineWindowSendsCompleteCursor(t *testing.T) { | |||
| 163 | + | t.Parallel() | |||
| 164 | + | ||||
| 165 | + | native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`)) | |||
| 166 | + | client := NewClient(native, nil) | |||
| 167 | + | before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |||
| 168 | + | ||||
| 169 | + | _, err := client.TimelineWindow(context.Background(), before, 42, "example.com") | |||
| 170 | + | if err != nil { | |||
| 171 | + | t.Fatalf("TimelineWindow() error = %v", err) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | var msg port.WebUIMessage | |||
| 175 | + | if err := json.Unmarshal(native.last, &msg); err != nil { | |||
| 176 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 177 | + | } | |||
| 178 | + | var payload map[string]any | |||
| 179 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 180 | + | t.Fatalf("unmarshal payload: %v", err) | |||
| 181 | + | } | |||
| 182 | + | if payload["before"] != before.Format(time.RFC3339Nano) { | |||
| 183 | + | t.Fatalf("before = %v, want %s", payload["before"], before.Format(time.RFC3339Nano)) | |||
| 184 | + | } | |||
| 185 | + | if payload["beforeId"] != float64(42) { | |||
| 186 | + | t.Fatalf("beforeId = %v, want 42", payload["beforeId"]) | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func TestClientTimelineWindowRejectsPartialCursor(t *testing.T) { | |||
| 191 | + | t.Parallel() | |||
| 192 | + | ||||
| 193 | + | native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`)) | |||
| 194 | + | client := NewClient(native, nil) | |||
| 195 | + | before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |||
| 196 | + | ||||
| 197 | + | if _, err := client.TimelineWindow(context.Background(), before, 0, ""); err == nil { | |||
| 198 | + | t.Fatal("TimelineWindow(before without beforeID) error = nil") | |||
| 199 | + | } | |||
| 200 | + | if _, err := client.TimelineWindow(context.Background(), time.Time{}, 42, ""); err == nil { | |||
| 201 | + | t.Fatal("TimelineWindow(beforeID without before) error = nil") | |||
| 202 | + | } | |||
| 203 | + | if _, err := client.TimelineWindow(context.Background(), time.Time{}, -1, ""); err == nil { | |||
| 204 | + | t.Fatal("TimelineWindow(negative beforeID without before) error = nil") | |||
| 205 | + | } | |||
| 206 | + | if native.called { | |||
| 207 | + | t.Fatal("TimelineWindow sent request for invalid partial cursor") | |||
| 156 | } | 208 | } | ||
| 157 | } | 209 | } | ||
| 158 | 210 | ||||
M internal/ui/systemviews/app.go +22 -3
| @@ -38,6 +38,7 @@ type App struct { | |||
| 38 | 38 | historyOffset int | |
| 39 | 39 | historyWindowBefore time.Time | |
| 40 | 40 | historyWindowAfter time.Time | |
| 41 | + | historyCursorID int64 | |
| 41 | 42 | historyHasMore bool | |
| 42 | 43 | historyNotice string | |
| 43 | 44 | historyError string | |
| @@ -571,6 +572,7 @@ type historyRouteSnapshot struct { | |||
| 571 | 572 | offset int | |
| 572 | 573 | windowBefore time.Time | |
| 573 | 574 | windowAfter time.Time | |
| 575 | + | cursorID int64 | |
| 574 | 576 | notice string | |
| 575 | 577 | error string | |
| 576 | 578 | } | |
| @@ -581,6 +583,7 @@ type historyRouteResult struct { | |||
| 581 | 583 | domains []*entity.DomainStat | |
| 582 | 584 | windowBefore time.Time | |
| 583 | 585 | windowAfter time.Time | |
| 586 | + | cursorID int64 | |
| 584 | 587 | hasMore bool | |
| 585 | 588 | html string | |
| 586 | 589 | } | |
| @@ -595,6 +598,7 @@ func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot { | |||
| 595 | 598 | offset: a.historyOffset, | |
| 596 | 599 | windowBefore: a.historyWindowBefore, | |
| 597 | 600 | windowAfter: a.historyWindowAfter, | |
| 601 | + | cursorID: a.historyCursorID, | |
| 598 | 602 | notice: a.historyNotice, | |
| 599 | 603 | error: a.historyError, | |
| 600 | 604 | } | |
| @@ -633,8 +637,7 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo | |||
| 633 | 637 | } | |
| 634 | 638 | if window != nil { | |
| 635 | 639 | result.windowBefore = window.Before | |
| 636 | - | result.windowAfter = window.After | |
| 637 | - | result.hasMore = window.HasMore | |
| 640 | + | result.windowAfter, result.cursorID, result.hasMore = historyWindowCursor(window) | |
| 638 | 641 | } | |
| 639 | 642 | data := historyRenderData{ | |
| 640 | 643 | Entries: entries, | |
| @@ -662,6 +665,20 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo | |||
| 662 | 665 | return result, nil | |
| 663 | 666 | } | |
| 664 | 667 | ||
| 668 | + | func historyWindowCursor(window *entity.HistoryWindow) (time.Time, int64, bool) { | |
| 669 | + | if window == nil || !window.HasMore { | |
| 670 | + | return time.Time{}, 0, false | |
| 671 | + | } | |
| 672 | + | cursorTime := window.CursorLastVisited | |
| 673 | + | if cursorTime.IsZero() { | |
| 674 | + | cursorTime = window.After | |
| 675 | + | } | |
| 676 | + | if cursorTime.IsZero() || window.CursorID <= 0 { | |
| 677 | + | return time.Time{}, 0, false | |
| 678 | + | } | |
| 679 | + | return cursorTime, window.CursorID, true | |
| 680 | + | } | |
| 681 | + | ||
| 665 | 682 | func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) { | |
| 666 | 683 | query := strings.TrimSpace(snapshot.query) | |
| 667 | 684 | domain := strings.TrimSpace(snapshot.domainFilter) | |
| @@ -669,7 +686,7 @@ func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapsho | |||
| 669 | 686 | entries, err := snapshot.history.Search(ctx, query, historySearchLimit) | |
| 670 | 687 | return nil, entries, err | |
| 671 | 688 | } | |
| 672 | - | window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowBefore, domain) | |
| 689 | + | window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowAfter, snapshot.cursorID, domain) | |
| 673 | 690 | if err != nil { | |
| 674 | 691 | return nil, nil, err | |
| 675 | 692 | } | |
| @@ -689,6 +706,7 @@ func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) { | |||
| 689 | 706 | a.historyDomainStats = result.domains | |
| 690 | 707 | a.historyWindowBefore = result.windowBefore | |
| 691 | 708 | a.historyWindowAfter = result.windowAfter | |
| 709 | + | a.historyCursorID = result.cursorID | |
| 692 | 710 | a.historyHasMore = result.hasMore | |
| 693 | 711 | a.renderedHTML = result.html | |
| 694 | 712 | } | |
| @@ -812,6 +830,7 @@ func (a *App) resetRouteState() { | |||
| 812 | 830 | a.historyOffset = 0 | |
| 813 | 831 | a.historyWindowBefore = time.Time{} | |
| 814 | 832 | a.historyWindowAfter = time.Time{} | |
| 833 | + | a.historyCursorID = 0 | |
| 815 | 834 | a.historyHasMore = false | |
| 816 | 835 | a.historyNotice = "" | |
| 817 | 836 | a.historyError = "" | |
| @@ -38,6 +38,7 @@ type App struct { | |||||
| 38 | historyOffset int | 38 | historyOffset int | ||
| 39 | historyWindowBefore time.Time | 39 | historyWindowBefore time.Time | ||
| 40 | historyWindowAfter time.Time | 40 | historyWindowAfter time.Time | ||
| 41 | + | historyCursorID int64 | |||
| 41 | historyHasMore bool | 42 | historyHasMore bool | ||
| 42 | historyNotice string | 43 | historyNotice string | ||
| 43 | historyError string | 44 | historyError string | ||
| @@ -571,6 +572,7 @@ type historyRouteSnapshot struct { | |||||
| 571 | offset int | 572 | offset int | ||
| 572 | windowBefore time.Time | 573 | windowBefore time.Time | ||
| 573 | windowAfter time.Time | 574 | windowAfter time.Time | ||
| 575 | + | cursorID int64 | |||
| 574 | notice string | 576 | notice string | ||
| 575 | error string | 577 | error string | ||
| 576 | } | 578 | } | ||
| @@ -581,6 +583,7 @@ type historyRouteResult struct { | |||||
| 581 | domains []*entity.DomainStat | 583 | domains []*entity.DomainStat | ||
| 582 | windowBefore time.Time | 584 | windowBefore time.Time | ||
| 583 | windowAfter time.Time | 585 | windowAfter time.Time | ||
| 586 | + | cursorID int64 | |||
| 584 | hasMore bool | 587 | hasMore bool | ||
| 585 | html string | 588 | html string | ||
| 586 | } | 589 | } | ||
| @@ -595,6 +598,7 @@ func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot { | |||||
| 595 | offset: a.historyOffset, | 598 | offset: a.historyOffset, | ||
| 596 | windowBefore: a.historyWindowBefore, | 599 | windowBefore: a.historyWindowBefore, | ||
| 597 | windowAfter: a.historyWindowAfter, | 600 | windowAfter: a.historyWindowAfter, | ||
| 601 | + | cursorID: a.historyCursorID, | |||
| 598 | notice: a.historyNotice, | 602 | notice: a.historyNotice, | ||
| 599 | error: a.historyError, | 603 | error: a.historyError, | ||
| 600 | } | 604 | } | ||
| @@ -633,8 +637,7 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo | |||||
| 633 | } | 637 | } | ||
| 634 | if window != nil { | 638 | if window != nil { | ||
| 635 | result.windowBefore = window.Before | 639 | result.windowBefore = window.Before | ||
| 636 | - | result.windowAfter = window.After | 640 | + | result.windowAfter, result.cursorID, result.hasMore = historyWindowCursor(window) |
| 637 | - | result.hasMore = window.HasMore | |||
| 638 | } | 641 | } | ||
| 639 | data := historyRenderData{ | 642 | data := historyRenderData{ | ||
| 640 | Entries: entries, | 643 | Entries: entries, | ||
| @@ -662,6 +665,20 @@ func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRo | |||||
| 662 | return result, nil | 665 | return result, nil | ||
| 663 | } | 666 | } | ||
| 664 | 667 | ||||
| 668 | + | func historyWindowCursor(window *entity.HistoryWindow) (time.Time, int64, bool) { | |||
| 669 | + | if window == nil || !window.HasMore { | |||
| 670 | + | return time.Time{}, 0, false | |||
| 671 | + | } | |||
| 672 | + | cursorTime := window.CursorLastVisited | |||
| 673 | + | if cursorTime.IsZero() { | |||
| 674 | + | cursorTime = window.After | |||
| 675 | + | } | |||
| 676 | + | if cursorTime.IsZero() || window.CursorID <= 0 { | |||
| 677 | + | return time.Time{}, 0, false | |||
| 678 | + | } | |||
| 679 | + | return cursorTime, window.CursorID, true | |||
| 680 | + | } | |||
| 681 | + | ||||
| 665 | func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) { | 682 | func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) { | ||
| 666 | query := strings.TrimSpace(snapshot.query) | 683 | query := strings.TrimSpace(snapshot.query) | ||
| 667 | domain := strings.TrimSpace(snapshot.domainFilter) | 684 | domain := strings.TrimSpace(snapshot.domainFilter) | ||
| @@ -669,7 +686,7 @@ func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapsho | |||||
| 669 | entries, err := snapshot.history.Search(ctx, query, historySearchLimit) | 686 | entries, err := snapshot.history.Search(ctx, query, historySearchLimit) | ||
| 670 | return nil, entries, err | 687 | return nil, entries, err | ||
| 671 | } | 688 | } | ||
| 672 | - | window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowBefore, domain) | 689 | + | window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowAfter, snapshot.cursorID, domain) |
| 673 | if err != nil { | 690 | if err != nil { | ||
| 674 | return nil, nil, err | 691 | return nil, nil, err | ||
| 675 | } | 692 | } | ||
| @@ -689,6 +706,7 @@ func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) { | |||||
| 689 | a.historyDomainStats = result.domains | 706 | a.historyDomainStats = result.domains | ||
| 690 | a.historyWindowBefore = result.windowBefore | 707 | a.historyWindowBefore = result.windowBefore | ||
| 691 | a.historyWindowAfter = result.windowAfter | 708 | a.historyWindowAfter = result.windowAfter | ||
| 709 | + | a.historyCursorID = result.cursorID | |||
| 692 | a.historyHasMore = result.hasMore | 710 | a.historyHasMore = result.hasMore | ||
| 693 | a.renderedHTML = result.html | 711 | a.renderedHTML = result.html | ||
| 694 | } | 712 | } | ||
| @@ -812,6 +830,7 @@ func (a *App) resetRouteState() { | |||||
| 812 | a.historyOffset = 0 | 830 | a.historyOffset = 0 | ||
| 813 | a.historyWindowBefore = time.Time{} | 831 | a.historyWindowBefore = time.Time{} | ||
| 814 | a.historyWindowAfter = time.Time{} | 832 | a.historyWindowAfter = time.Time{} | ||
| 833 | + | a.historyCursorID = 0 | |||
| 815 | a.historyHasMore = false | 834 | a.historyHasMore = false | ||
| 816 | a.historyNotice = "" | 835 | a.historyNotice = "" | ||
| 817 | a.historyError = "" | 836 | a.historyError = "" | ||
M internal/ui/systemviews/app_test.go +47 -9
| @@ -213,16 +213,18 @@ func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) { | |||
| 213 | 213 | ||
| 214 | 214 | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |
| 215 | 215 | history := &recordingHistoryService{window: &entity.HistoryWindow{ | |
| 216 | - | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | |
| 217 | - | Before: cursor, | |
| 218 | - | After: cursor.Add(-24 * time.Hour), | |
| 219 | - | HasMore: true, | |
| 216 | + | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | |
| 217 | + | Before: cursor, | |
| 218 | + | CursorLastVisited: cursor.Add(-time.Hour), | |
| 219 | + | CursorID: 2, | |
| 220 | + | HasMore: true, | |
| 220 | 221 | }} | |
| 221 | 222 | dom := &recordingActionDOM{} | |
| 222 | 223 | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |
| 223 | 224 | app.currentRoute = RouteHistory | |
| 224 | 225 | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | |
| 225 | 226 | app.historyWindowAfter = cursor | |
| 227 | + | app.historyCursorID = 1 | |
| 226 | 228 | app.historyHasMore = true | |
| 227 | 229 | ||
| 228 | 230 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| @@ -230,6 +232,9 @@ func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) { | |||
| 230 | 232 | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | |
| 231 | 233 | })) | |
| 232 | 234 | assert.Equal(t, cursor, history.windowBefore) | |
| 235 | + | assert.Equal(t, int64(1), history.windowBeforeID) | |
| 236 | + | assert.Equal(t, int64(2), app.historyCursorID) | |
| 237 | + | assert.Equal(t, cursor.Add(-time.Hour), app.historyWindowAfter) | |
| 233 | 238 | assert.Len(t, app.historyEntries, 2) | |
| 234 | 239 | assert.Contains(t, dom.AppendedHTML(), "Older entry") | |
| 235 | 240 | assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`) | |
| @@ -241,16 +246,18 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) { | |||
| 241 | 246 | ||
| 242 | 247 | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |
| 243 | 248 | history := &recordingHistoryService{window: &entity.HistoryWindow{ | |
| 244 | - | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | |
| 245 | - | Before: cursor, | |
| 246 | - | After: cursor.Add(-24 * time.Hour), | |
| 247 | - | HasMore: true, | |
| 249 | + | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | |
| 250 | + | Before: cursor, | |
| 251 | + | CursorLastVisited: cursor.Add(-time.Hour), | |
| 252 | + | CursorID: 2, | |
| 253 | + | HasMore: true, | |
| 248 | 254 | }} | |
| 249 | 255 | dom := &recordingDOM{} | |
| 250 | 256 | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |
| 251 | 257 | app.currentRoute = RouteHistory | |
| 252 | 258 | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | |
| 253 | 259 | app.historyWindowAfter = cursor | |
| 260 | + | app.historyCursorID = 1 | |
| 254 | 261 | app.historyHasMore = true | |
| 255 | 262 | ||
| 256 | 263 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| @@ -258,11 +265,40 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) { | |||
| 258 | 265 | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | |
| 259 | 266 | })) | |
| 260 | 267 | assert.Equal(t, cursor, history.windowBefore) | |
| 268 | + | assert.Equal(t, int64(1), history.windowBeforeID) | |
| 261 | 269 | assert.Len(t, app.historyEntries, 2) | |
| 262 | 270 | assert.Contains(t, dom.HTML(), "Older entry") | |
| 263 | 271 | assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`) | |
| 264 | 272 | } | |
| 265 | 273 | ||
| 274 | + | func TestAppHandleHistoryLoadMoreDisablesPartialNextCursor(t *testing.T) { | |
| 275 | + | t.Parallel() | |
| 276 | + | ||
| 277 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |
| 278 | + | history := &recordingHistoryService{window: &entity.HistoryWindow{ | |
| 279 | + | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | |
| 280 | + | Before: cursor, | |
| 281 | + | After: cursor.Add(-24 * time.Hour), | |
| 282 | + | HasMore: true, | |
| 283 | + | }} | |
| 284 | + | dom := &recordingActionDOM{} | |
| 285 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |
| 286 | + | app.currentRoute = RouteHistory | |
| 287 | + | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | |
| 288 | + | app.historyWindowAfter = cursor | |
| 289 | + | app.historyCursorID = 1 | |
| 290 | + | app.historyHasMore = true | |
| 291 | + | ||
| 292 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 293 | + | Action: historyActionLoadMore, | |
| 294 | + | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | |
| 295 | + | })) | |
| 296 | + | assert.False(t, app.historyHasMore) | |
| 297 | + | assert.Zero(t, app.historyCursorID) | |
| 298 | + | assert.True(t, app.historyWindowAfter.IsZero()) | |
| 299 | + | assert.NotContains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`) | |
| 300 | + | } | |
| 301 | + | ||
| 266 | 302 | func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) { | |
| 267 | 303 | t.Parallel() | |
| 268 | 304 | ||
| @@ -1072,6 +1108,7 @@ type recordingHistoryService struct { | |||
| 1072 | 1108 | statsCalled bool | |
| 1073 | 1109 | window *entity.HistoryWindow | |
| 1074 | 1110 | windowBefore time.Time | |
| 1111 | + | windowBeforeID int64 | |
| 1075 | 1112 | windowDomain string | |
| 1076 | 1113 | analyticsCalled bool | |
| 1077 | 1114 | ||
| @@ -1101,9 +1138,10 @@ func (s *recordingHistoryService) TimelineByDomain(_ context.Context, domain str | |||
| 1101 | 1138 | return s.entries, s.err | |
| 1102 | 1139 | } | |
| 1103 | 1140 | ||
| 1104 | - | func (s *recordingHistoryService) TimelineWindow(_ context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |
| 1141 | + | func (s *recordingHistoryService) TimelineWindow(_ context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) { | |
| 1105 | 1142 | s.called = true | |
| 1106 | 1143 | s.windowBefore = before | |
| 1144 | + | s.windowBeforeID = beforeID | |
| 1107 | 1145 | s.windowDomain = domain | |
| 1108 | 1146 | if s.timelineStarted != nil { | |
| 1109 | 1147 | s.startOnce.Do(func() { close(s.timelineStarted) }) | |
| @@ -213,16 +213,18 @@ func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) { | |||||
| 213 | 213 | ||||
| 214 | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | 214 | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | ||
| 215 | history := &recordingHistoryService{window: &entity.HistoryWindow{ | 215 | history := &recordingHistoryService{window: &entity.HistoryWindow{ | ||
| 216 | - | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | 216 | + | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, |
| 217 | - | Before: cursor, | 217 | + | Before: cursor, |
| 218 | - | After: cursor.Add(-24 * time.Hour), | 218 | + | CursorLastVisited: cursor.Add(-time.Hour), |
| 219 | - | HasMore: true, | 219 | + | CursorID: 2, |
| 220 | + | HasMore: true, | |||
| 220 | }} | 221 | }} | ||
| 221 | dom := &recordingActionDOM{} | 222 | dom := &recordingActionDOM{} | ||
| 222 | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | 223 | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | ||
| 223 | app.currentRoute = RouteHistory | 224 | app.currentRoute = RouteHistory | ||
| 224 | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | 225 | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | ||
| 225 | app.historyWindowAfter = cursor | 226 | app.historyWindowAfter = cursor | ||
| 227 | + | app.historyCursorID = 1 | |||
| 226 | app.historyHasMore = true | 228 | app.historyHasMore = true | ||
| 227 | 229 | ||||
| 228 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 230 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| @@ -230,6 +232,9 @@ func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) { | |||||
| 230 | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | 232 | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | ||
| 231 | })) | 233 | })) | ||
| 232 | assert.Equal(t, cursor, history.windowBefore) | 234 | assert.Equal(t, cursor, history.windowBefore) | ||
| 235 | + | assert.Equal(t, int64(1), history.windowBeforeID) | |||
| 236 | + | assert.Equal(t, int64(2), app.historyCursorID) | |||
| 237 | + | assert.Equal(t, cursor.Add(-time.Hour), app.historyWindowAfter) | |||
| 233 | assert.Len(t, app.historyEntries, 2) | 238 | assert.Len(t, app.historyEntries, 2) | ||
| 234 | assert.Contains(t, dom.AppendedHTML(), "Older entry") | 239 | assert.Contains(t, dom.AppendedHTML(), "Older entry") | ||
| 235 | assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`) | 240 | assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`) | ||
| @@ -241,16 +246,18 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) { | |||||
| 241 | 246 | ||||
| 242 | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | 247 | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | ||
| 243 | history := &recordingHistoryService{window: &entity.HistoryWindow{ | 248 | history := &recordingHistoryService{window: &entity.HistoryWindow{ | ||
| 244 | - | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | 249 | + | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, |
| 245 | - | Before: cursor, | 250 | + | Before: cursor, |
| 246 | - | After: cursor.Add(-24 * time.Hour), | 251 | + | CursorLastVisited: cursor.Add(-time.Hour), |
| 247 | - | HasMore: true, | 252 | + | CursorID: 2, |
| 253 | + | HasMore: true, | |||
| 248 | }} | 254 | }} | ||
| 249 | dom := &recordingDOM{} | 255 | dom := &recordingDOM{} | ||
| 250 | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | 256 | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | ||
| 251 | app.currentRoute = RouteHistory | 257 | app.currentRoute = RouteHistory | ||
| 252 | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | 258 | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | ||
| 253 | app.historyWindowAfter = cursor | 259 | app.historyWindowAfter = cursor | ||
| 260 | + | app.historyCursorID = 1 | |||
| 254 | app.historyHasMore = true | 261 | app.historyHasMore = true | ||
| 255 | 262 | ||||
| 256 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 263 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| @@ -258,11 +265,40 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) { | |||||
| 258 | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | 265 | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | ||
| 259 | })) | 266 | })) | ||
| 260 | assert.Equal(t, cursor, history.windowBefore) | 267 | assert.Equal(t, cursor, history.windowBefore) | ||
| 268 | + | assert.Equal(t, int64(1), history.windowBeforeID) | |||
| 261 | assert.Len(t, app.historyEntries, 2) | 269 | assert.Len(t, app.historyEntries, 2) | ||
| 262 | assert.Contains(t, dom.HTML(), "Older entry") | 270 | assert.Contains(t, dom.HTML(), "Older entry") | ||
| 263 | assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`) | 271 | assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`) | ||
| 264 | } | 272 | } | ||
| 265 | 273 | ||||
| 274 | + | func TestAppHandleHistoryLoadMoreDisablesPartialNextCursor(t *testing.T) { | |||
| 275 | + | t.Parallel() | |||
| 276 | + | ||||
| 277 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |||
| 278 | + | history := &recordingHistoryService{window: &entity.HistoryWindow{ | |||
| 279 | + | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | |||
| 280 | + | Before: cursor, | |||
| 281 | + | After: cursor.Add(-24 * time.Hour), | |||
| 282 | + | HasMore: true, | |||
| 283 | + | }} | |||
| 284 | + | dom := &recordingActionDOM{} | |||
| 285 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 286 | + | app.currentRoute = RouteHistory | |||
| 287 | + | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | |||
| 288 | + | app.historyWindowAfter = cursor | |||
| 289 | + | app.historyCursorID = 1 | |||
| 290 | + | app.historyHasMore = true | |||
| 291 | + | ||||
| 292 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 293 | + | Action: historyActionLoadMore, | |||
| 294 | + | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | |||
| 295 | + | })) | |||
| 296 | + | assert.False(t, app.historyHasMore) | |||
| 297 | + | assert.Zero(t, app.historyCursorID) | |||
| 298 | + | assert.True(t, app.historyWindowAfter.IsZero()) | |||
| 299 | + | assert.NotContains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`) | |||
| 300 | + | } | |||
| 301 | + | ||||
| 266 | func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) { | 302 | func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) { | ||
| 267 | t.Parallel() | 303 | t.Parallel() | ||
| 268 | 304 | ||||
| @@ -1072,6 +1108,7 @@ type recordingHistoryService struct { | |||||
| 1072 | statsCalled bool | 1108 | statsCalled bool | ||
| 1073 | window *entity.HistoryWindow | 1109 | window *entity.HistoryWindow | ||
| 1074 | windowBefore time.Time | 1110 | windowBefore time.Time | ||
| 1111 | + | windowBeforeID int64 | |||
| 1075 | windowDomain string | 1112 | windowDomain string | ||
| 1076 | analyticsCalled bool | 1113 | analyticsCalled bool | ||
| 1077 | 1114 | ||||
| @@ -1101,9 +1138,10 @@ func (s *recordingHistoryService) TimelineByDomain(_ context.Context, domain str | |||||
| 1101 | return s.entries, s.err | 1138 | return s.entries, s.err | ||
| 1102 | } | 1139 | } | ||
| 1103 | 1140 | ||||
| 1104 | - | func (s *recordingHistoryService) TimelineWindow(_ context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | 1141 | + | func (s *recordingHistoryService) TimelineWindow(_ context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) { |
| 1105 | s.called = true | 1142 | s.called = true | ||
| 1106 | s.windowBefore = before | 1143 | s.windowBefore = before | ||
| 1144 | + | s.windowBeforeID = beforeID | |||
| 1107 | s.windowDomain = domain | 1145 | s.windowDomain = domain | ||
| 1108 | if s.timelineStarted != nil { | 1146 | if s.timelineStarted != nil { | ||
| 1109 | s.startOnce.Do(func() { close(s.timelineStarted) }) | 1147 | s.startOnce.Do(func() { close(s.timelineStarted) }) | ||
M internal/ui/systemviews/history_actions.go +74 -45
| @@ -6,6 +6,9 @@ import ( | |||
| 6 | 6 | "strconv" | |
| 7 | 7 | "strings" | |
| 8 | 8 | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 12 | ) | |
| 10 | 13 | ||
| 11 | 14 | const ( | |
| @@ -167,54 +170,84 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 167 | 170 | return nil | |
| 168 | 171 | } | |
| 169 | 172 | ||
| 173 | + | type historyLoadMoreRequest struct { | |
| 174 | + | before time.Time | |
| 175 | + | beforeID int64 | |
| 176 | + | domain string | |
| 177 | + | history port.SystemviewHistoryService | |
| 178 | + | dom DOM | |
| 179 | + | } | |
| 180 | + | ||
| 170 | 181 | func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error { | |
| 171 | 182 | a.lockState() | |
| 172 | - | if a.deps.History == nil { | |
| 173 | - | a.unlockState() | |
| 174 | - | return fmt.Errorf("history service not configured") | |
| 183 | + | req, ok, err := a.historyLoadMoreRequestLocked(event) | |
| 184 | + | a.unlockState() | |
| 185 | + | if err != nil || !ok { | |
| 186 | + | return err | |
| 175 | 187 | } | |
| 176 | - | if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore { | |
| 188 | + | ||
| 189 | + | window, err := req.history.TimelineWindow(ctx, req.before, req.beforeID, req.domain) | |
| 190 | + | if err != nil { | |
| 191 | + | return err | |
| 192 | + | } | |
| 193 | + | if window == nil { | |
| 194 | + | a.lockState() | |
| 195 | + | a.historyHasMore = false | |
| 177 | 196 | a.unlockState() | |
| 178 | 197 | return nil | |
| 179 | 198 | } | |
| 199 | + | ||
| 200 | + | a.lockState() | |
| 201 | + | fragment, fallbackHTML := a.appendHistoryWindowLocked(window, req.dom) | |
| 202 | + | a.unlockState() | |
| 203 | + | ||
| 204 | + | if appender, ok := req.dom.(DOMHistoryTimelineAppender); ok { | |
| 205 | + | return appender.AppendHistoryTimeline(fragment) | |
| 206 | + | } | |
| 207 | + | return a.mountHTML(fallbackHTML) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (a *App) historyLoadMoreRequestLocked(event DOMAction) (historyLoadMoreRequest, bool, error) { | |
| 211 | + | if a.deps.History == nil { | |
| 212 | + | return historyLoadMoreRequest{}, false, fmt.Errorf("history service not configured") | |
| 213 | + | } | |
| 214 | + | if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore { | |
| 215 | + | return historyLoadMoreRequest{}, false, nil | |
| 216 | + | } | |
| 217 | + | ||
| 180 | 218 | before := a.historyWindowAfter | |
| 219 | + | beforeID := a.historyCursorID | |
| 181 | 220 | if requested := strings.TrimSpace(event.Data["before"]); requested != "" { | |
| 182 | 221 | parsed, err := time.Parse(time.RFC3339Nano, requested) | |
| 183 | 222 | if err != nil { | |
| 184 | - | a.unlockState() | |
| 185 | - | return fmt.Errorf("invalid history cursor") | |
| 223 | + | return historyLoadMoreRequest{}, false, fmt.Errorf("invalid history cursor") | |
| 186 | 224 | } | |
| 187 | 225 | if !before.IsZero() && !parsed.Equal(before) { | |
| 188 | - | a.unlockState() | |
| 189 | - | return nil | |
| 226 | + | return historyLoadMoreRequest{}, false, nil | |
| 190 | 227 | } | |
| 191 | 228 | before = parsed | |
| 192 | 229 | } | |
| 193 | - | if before.IsZero() { | |
| 194 | - | before = time.Now() | |
| 195 | - | } | |
| 196 | - | domain := a.historyDomainFilter | |
| 197 | - | history := a.deps.History | |
| 198 | - | dom := a.deps.DOM | |
| 199 | - | a.unlockState() | |
| 200 | - | ||
| 201 | - | window, err := history.TimelineWindow(ctx, before, domain) | |
| 202 | - | if err != nil { | |
| 203 | - | return err | |
| 204 | - | } | |
| 205 | - | if window == nil { | |
| 206 | - | a.lockState() | |
| 230 | + | if before.IsZero() || beforeID <= 0 { | |
| 231 | + | a.historyWindowAfter = time.Time{} | |
| 232 | + | a.historyCursorID = 0 | |
| 207 | 233 | a.historyHasMore = false | |
| 208 | - | a.unlockState() | |
| 209 | - | return nil | |
| 234 | + | return historyLoadMoreRequest{}, false, nil | |
| 210 | 235 | } | |
| 211 | 236 | ||
| 212 | - | a.lockState() | |
| 237 | + | return historyLoadMoreRequest{ | |
| 238 | + | before: before, | |
| 239 | + | beforeID: beforeID, | |
| 240 | + | domain: a.historyDomainFilter, | |
| 241 | + | history: a.deps.History, | |
| 242 | + | dom: a.deps.DOM, | |
| 243 | + | }, true, nil | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func (a *App) appendHistoryWindowLocked(window *entity.HistoryWindow, dom DOM) (string, string) { | |
| 213 | 247 | appendSkipFirstDate := lastHistoryDateKey(a.historyEntries) | |
| 214 | 248 | a.historyEntries = append(a.historyEntries, window.Entries...) | |
| 215 | 249 | a.historyWindowBefore = window.Before | |
| 216 | - | a.historyWindowAfter = window.After | |
| 217 | - | a.historyHasMore = window.HasMore | |
| 250 | + | a.historyWindowAfter, a.historyCursorID, a.historyHasMore = historyWindowCursor(window) | |
| 218 | 251 | data := historyRenderData{ | |
| 219 | 252 | Entries: window.Entries, | |
| 220 | 253 | Stats: a.historyStats, | |
| @@ -231,32 +264,28 @@ func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error | |||
| 231 | 264 | Error: a.historyError, | |
| 232 | 265 | } | |
| 233 | 266 | fragment := historyTimelineAppendHTML(data) | |
| 234 | - | var fallbackHTML string | |
| 235 | - | if _, ok := dom.(DOMHistoryTimelineAppender); !ok { | |
| 236 | - | fullData := data | |
| 237 | - | fullData.Entries = a.historyEntries | |
| 238 | - | title := historyDocumentTitle(fullData) | |
| 239 | - | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 240 | - | route: RouteHistory, | |
| 241 | - | title: title, | |
| 242 | - | subtitle: "History", | |
| 243 | - | subtitleDetail: historyTitleDetail(fullData), | |
| 244 | - | body: historyHTML(fullData), | |
| 245 | - | }, a.shellTheme) | |
| 246 | - | fallbackHTML = a.renderedHTML | |
| 267 | + | if _, ok := dom.(DOMHistoryTimelineAppender); ok { | |
| 268 | + | return fragment, "" | |
| 247 | 269 | } | |
| 248 | - | a.unlockState() | |
| 249 | 270 | ||
| 250 | - | if appender, ok := dom.(DOMHistoryTimelineAppender); ok { | |
| 251 | - | return appender.AppendHistoryTimeline(fragment) | |
| 252 | - | } | |
| 253 | - | return a.mountHTML(fallbackHTML) | |
| 271 | + | fullData := data | |
| 272 | + | fullData.Entries = a.historyEntries | |
| 273 | + | title := historyDocumentTitle(fullData) | |
| 274 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 275 | + | route: RouteHistory, | |
| 276 | + | title: title, | |
| 277 | + | subtitle: "History", | |
| 278 | + | subtitleDetail: historyTitleDetail(fullData), | |
| 279 | + | body: historyHTML(fullData), | |
| 280 | + | }, a.shellTheme) | |
| 281 | + | return fragment, a.renderedHTML | |
| 254 | 282 | } | |
| 255 | 283 | ||
| 256 | 284 | func (a *App) resetHistoryWindowState() { | |
| 257 | 285 | a.historyOffset = 0 | |
| 258 | 286 | a.historyWindowBefore = time.Time{} | |
| 259 | 287 | a.historyWindowAfter = time.Time{} | |
| 288 | + | a.historyCursorID = 0 | |
| 260 | 289 | a.historyHasMore = false | |
| 261 | 290 | } | |
| 262 | 291 | ||
| @@ -6,6 +6,9 @@ import ( | |||||
| 6 | "strconv" | 6 | "strconv" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | ) | 12 | ) | ||
| 10 | 13 | ||||
| 11 | const ( | 14 | const ( | ||
| @@ -167,54 +170,84 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||||
| 167 | return nil | 170 | return nil | ||
| 168 | } | 171 | } | ||
| 169 | 172 | ||||
| 173 | + | type historyLoadMoreRequest struct { | |||
| 174 | + | before time.Time | |||
| 175 | + | beforeID int64 | |||
| 176 | + | domain string | |||
| 177 | + | history port.SystemviewHistoryService | |||
| 178 | + | dom DOM | |||
| 179 | + | } | |||
| 180 | + | ||||
| 170 | func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error { | 181 | func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error { | ||
| 171 | a.lockState() | 182 | a.lockState() | ||
| 172 | - | if a.deps.History == nil { | 183 | + | req, ok, err := a.historyLoadMoreRequestLocked(event) |
| 173 | - | a.unlockState() | 184 | + | a.unlockState() |
| 174 | - | return fmt.Errorf("history service not configured") | 185 | + | if err != nil || !ok { |
| 186 | + | return err | |||
| 175 | } | 187 | } | ||
| 176 | - | if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore { | 188 | + | |
| 189 | + | window, err := req.history.TimelineWindow(ctx, req.before, req.beforeID, req.domain) | |||
| 190 | + | if err != nil { | |||
| 191 | + | return err | |||
| 192 | + | } | |||
| 193 | + | if window == nil { | |||
| 194 | + | a.lockState() | |||
| 195 | + | a.historyHasMore = false | |||
| 177 | a.unlockState() | 196 | a.unlockState() | ||
| 178 | return nil | 197 | return nil | ||
| 179 | } | 198 | } | ||
| 199 | + | ||||
| 200 | + | a.lockState() | |||
| 201 | + | fragment, fallbackHTML := a.appendHistoryWindowLocked(window, req.dom) | |||
| 202 | + | a.unlockState() | |||
| 203 | + | ||||
| 204 | + | if appender, ok := req.dom.(DOMHistoryTimelineAppender); ok { | |||
| 205 | + | return appender.AppendHistoryTimeline(fragment) | |||
| 206 | + | } | |||
| 207 | + | return a.mountHTML(fallbackHTML) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func (a *App) historyLoadMoreRequestLocked(event DOMAction) (historyLoadMoreRequest, bool, error) { | |||
| 211 | + | if a.deps.History == nil { | |||
| 212 | + | return historyLoadMoreRequest{}, false, fmt.Errorf("history service not configured") | |||
| 213 | + | } | |||
| 214 | + | if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore { | |||
| 215 | + | return historyLoadMoreRequest{}, false, nil | |||
| 216 | + | } | |||
| 217 | + | ||||
| 180 | before := a.historyWindowAfter | 218 | before := a.historyWindowAfter | ||
| 219 | + | beforeID := a.historyCursorID | |||
| 181 | if requested := strings.TrimSpace(event.Data["before"]); requested != "" { | 220 | if requested := strings.TrimSpace(event.Data["before"]); requested != "" { | ||
| 182 | parsed, err := time.Parse(time.RFC3339Nano, requested) | 221 | parsed, err := time.Parse(time.RFC3339Nano, requested) | ||
| 183 | if err != nil { | 222 | if err != nil { | ||
| 184 | - | a.unlockState() | 223 | + | return historyLoadMoreRequest{}, false, fmt.Errorf("invalid history cursor") |
| 185 | - | return fmt.Errorf("invalid history cursor") | |||
| 186 | } | 224 | } | ||
| 187 | if !before.IsZero() && !parsed.Equal(before) { | 225 | if !before.IsZero() && !parsed.Equal(before) { | ||
| 188 | - | a.unlockState() | 226 | + | return historyLoadMoreRequest{}, false, nil |
| 189 | - | return nil | |||
| 190 | } | 227 | } | ||
| 191 | before = parsed | 228 | before = parsed | ||
| 192 | } | 229 | } | ||
| 193 | - | if before.IsZero() { | 230 | + | if before.IsZero() || beforeID <= 0 { |
| 194 | - | before = time.Now() | 231 | + | a.historyWindowAfter = time.Time{} |
| 195 | - | } | 232 | + | a.historyCursorID = 0 |
| 196 | - | domain := a.historyDomainFilter | |||
| 197 | - | history := a.deps.History | |||
| 198 | - | dom := a.deps.DOM | |||
| 199 | - | a.unlockState() | |||
| 200 | - | ||||
| 201 | - | window, err := history.TimelineWindow(ctx, before, domain) | |||
| 202 | - | if err != nil { | |||
| 203 | - | return err | |||
| 204 | - | } | |||
| 205 | - | if window == nil { | |||
| 206 | - | a.lockState() | |||
| 207 | a.historyHasMore = false | 233 | a.historyHasMore = false | ||
| 208 | - | a.unlockState() | 234 | + | return historyLoadMoreRequest{}, false, nil |
| 209 | - | return nil | |||
| 210 | } | 235 | } | ||
| 211 | 236 | ||||
| 212 | - | a.lockState() | 237 | + | return historyLoadMoreRequest{ |
| 238 | + | before: before, | |||
| 239 | + | beforeID: beforeID, | |||
| 240 | + | domain: a.historyDomainFilter, | |||
| 241 | + | history: a.deps.History, | |||
| 242 | + | dom: a.deps.DOM, | |||
| 243 | + | }, true, nil | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func (a *App) appendHistoryWindowLocked(window *entity.HistoryWindow, dom DOM) (string, string) { | |||
| 213 | appendSkipFirstDate := lastHistoryDateKey(a.historyEntries) | 247 | appendSkipFirstDate := lastHistoryDateKey(a.historyEntries) | ||
| 214 | a.historyEntries = append(a.historyEntries, window.Entries...) | 248 | a.historyEntries = append(a.historyEntries, window.Entries...) | ||
| 215 | a.historyWindowBefore = window.Before | 249 | a.historyWindowBefore = window.Before | ||
| 216 | - | a.historyWindowAfter = window.After | 250 | + | a.historyWindowAfter, a.historyCursorID, a.historyHasMore = historyWindowCursor(window) |
| 217 | - | a.historyHasMore = window.HasMore | |||
| 218 | data := historyRenderData{ | 251 | data := historyRenderData{ | ||
| 219 | Entries: window.Entries, | 252 | Entries: window.Entries, | ||
| 220 | Stats: a.historyStats, | 253 | Stats: a.historyStats, | ||
| @@ -231,32 +264,28 @@ func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error | |||||
| 231 | Error: a.historyError, | 264 | Error: a.historyError, | ||
| 232 | } | 265 | } | ||
| 233 | fragment := historyTimelineAppendHTML(data) | 266 | fragment := historyTimelineAppendHTML(data) | ||
| 234 | - | var fallbackHTML string | 267 | + | if _, ok := dom.(DOMHistoryTimelineAppender); ok { |
| 235 | - | if _, ok := dom.(DOMHistoryTimelineAppender); !ok { | 268 | + | return fragment, "" |
| 236 | - | fullData := data | |||
| 237 | - | fullData.Entries = a.historyEntries | |||
| 238 | - | title := historyDocumentTitle(fullData) | |||
| 239 | - | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 240 | - | route: RouteHistory, | |||
| 241 | - | title: title, | |||
| 242 | - | subtitle: "History", | |||
| 243 | - | subtitleDetail: historyTitleDetail(fullData), | |||
| 244 | - | body: historyHTML(fullData), | |||
| 245 | - | }, a.shellTheme) | |||
| 246 | - | fallbackHTML = a.renderedHTML | |||
| 247 | } | 269 | } | ||
| 248 | - | a.unlockState() | |||
| 249 | 270 | ||||
| 250 | - | if appender, ok := dom.(DOMHistoryTimelineAppender); ok { | 271 | + | fullData := data |
| 251 | - | return appender.AppendHistoryTimeline(fragment) | 272 | + | fullData.Entries = a.historyEntries |
| 252 | - | } | 273 | + | title := historyDocumentTitle(fullData) |
| 253 | - | return a.mountHTML(fallbackHTML) | 274 | + | a.renderedHTML = renderAppFrame(renderedPage{ |
| 275 | + | route: RouteHistory, | |||
| 276 | + | title: title, | |||
| 277 | + | subtitle: "History", | |||
| 278 | + | subtitleDetail: historyTitleDetail(fullData), | |||
| 279 | + | body: historyHTML(fullData), | |||
| 280 | + | }, a.shellTheme) | |||
| 281 | + | return fragment, a.renderedHTML | |||
| 254 | } | 282 | } | ||
| 255 | 283 | ||||
| 256 | func (a *App) resetHistoryWindowState() { | 284 | func (a *App) resetHistoryWindowState() { | ||
| 257 | a.historyOffset = 0 | 285 | a.historyOffset = 0 | ||
| 258 | a.historyWindowBefore = time.Time{} | 286 | a.historyWindowBefore = time.Time{} | ||
| 259 | a.historyWindowAfter = time.Time{} | 287 | a.historyWindowAfter = time.Time{} | ||
| 288 | + | a.historyCursorID = 0 | |||
| 260 | a.historyHasMore = false | 289 | a.historyHasMore = false | ||
| 261 | } | 290 | } | ||
| 262 | 291 | ||||