detached 553c7b8

fix(error-handling): add context and error handling for CSS provider, cache ops, and shutdown

Adds timeout context to cache flush and history queue operations, proper error handling for CSS provider, and improved error reporting across db, cache, config, and service layers for graceful failure modes.
commit
553c7b8935af42716240b0cee81120dc168d15a7
author
bnema · · 2025-10-23 19:35 UTC
committer
bnema · · 2025-10-24 17:33 UTC
parent
1583194
signature
unsigned
43 files · +1890 -445
A internal/cache/cert_cache.go +78 -0
@@ -0,0 +1,78 @@
1+package cache
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/cache/generic"
7+ "github.com/bnema/dumber/internal/db"
8+)
9+
10+// CertDBOperations implements DatabaseOperations for certificate validations cache.
11+// Handles loading, persisting, and deleting certificate validations from the database.
12+type CertDBOperations struct {
13+ queries db.DatabaseQuerier
14+}
15+
16+// NewCertDBOperations creates a new CertDBOperations instance.
17+func NewCertDBOperations(queries db.DatabaseQuerier) *CertDBOperations {
18+ return &CertDBOperations{
19+ queries: queries,
20+ }
21+}
22+
23+// LoadAll loads all certificate validations from the database.
24+// Returns a map of hostname -> CertificateValidation.
25+// Only loads non-expired validations.
26+func (c *CertDBOperations) LoadAll(ctx context.Context) (map[string]db.CertificateValidation, error) {
27+ validations, err := c.queries.ListCertificateValidations(ctx)
28+ if err != nil {
29+ return nil, err
30+ }
31+
32+ result := make(map[string]db.CertificateValidation, len(validations))
33+ for _, validation := range validations {
34+ // Use hostname as key - most recent validation per hostname
35+ // (query already orders by created_at DESC in GetCertificateValidationByHostname)
36+ result[validation.Hostname] = validation
37+ }
38+
39+ return result, nil
40+}
41+
42+// Persist saves a certificate validation to the database.
43+// Uses INSERT OR REPLACE to handle both new and existing entries.
44+func (c *CertDBOperations) Persist(ctx context.Context, hostname string, validation db.CertificateValidation) error {
45+ return c.queries.StoreCertificateValidation(
46+ ctx,
47+ hostname,
48+ validation.CertificateHash,
49+ validation.UserDecision,
50+ validation.ExpiresAt,
51+ )
52+}
53+
54+// Delete removes a certificate validation from the database.
55+func (c *CertDBOperations) Delete(ctx context.Context, hostname string) error {
56+ // Get the validation to find the certificate hash
57+ validation, err := c.queries.GetCertificateValidationByHostname(ctx, hostname)
58+ if err != nil {
59+ return err
60+ }
61+
62+ return c.queries.DeleteCertificateValidation(ctx, hostname, validation.CertificateHash)
63+}
64+
65+// CertValidationsCache is a specialized cache for certificate validations.
66+// It wraps GenericCache with hostname-specific helper methods.
67+// Key insight: Caches hostname -> validation decision to avoid DB queries on HTTPS cert errors.
68+type CertValidationsCache struct {
69+ *generic.GenericCache[string, db.CertificateValidation]
70+}
71+
72+// NewCertValidationsCache creates a new certificate validations cache.
73+func NewCertValidationsCache(queries db.DatabaseQuerier) *CertValidationsCache {
74+ dbOps := NewCertDBOperations(queries)
75+ return &CertValidationsCache{
76+ GenericCache: generic.NewGenericCache(dbOps),
77+ }
78+}
@@ -0,0 +1,78 @@
1+package cache
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/cache/generic"
7+ "github.com/bnema/dumber/internal/db"
8+)
9+
10+// CertDBOperations implements DatabaseOperations for certificate validations cache.
11+// Handles loading, persisting, and deleting certificate validations from the database.
12+type CertDBOperations struct {
13+ queries db.DatabaseQuerier
14+}
15+
16+// NewCertDBOperations creates a new CertDBOperations instance.
17+func NewCertDBOperations(queries db.DatabaseQuerier) *CertDBOperations {
18+ return &CertDBOperations{
19+ queries: queries,
20+ }
21+}
22+
23+// LoadAll loads all certificate validations from the database.
24+// Returns a map of hostname -> CertificateValidation.
25+// Only loads non-expired validations.
26+func (c *CertDBOperations) LoadAll(ctx context.Context) (map[string]db.CertificateValidation, error) {
27+ validations, err := c.queries.ListCertificateValidations(ctx)
28+ if err != nil {
29+ return nil, err
30+ }
31+
32+ result := make(map[string]db.CertificateValidation, len(validations))
33+ for _, validation := range validations {
34+ // Use hostname as key - most recent validation per hostname
35+ // (query already orders by created_at DESC in GetCertificateValidationByHostname)
36+ result[validation.Hostname] = validation
37+ }
38+
39+ return result, nil
40+}
41+
42+// Persist saves a certificate validation to the database.
43+// Uses INSERT OR REPLACE to handle both new and existing entries.
44+func (c *CertDBOperations) Persist(ctx context.Context, hostname string, validation db.CertificateValidation) error {
45+ return c.queries.StoreCertificateValidation(
46+ ctx,
47+ hostname,
48+ validation.CertificateHash,
49+ validation.UserDecision,
50+ validation.ExpiresAt,
51+ )
52+}
53+
54+// Delete removes a certificate validation from the database.
55+func (c *CertDBOperations) Delete(ctx context.Context, hostname string) error {
56+ // Get the validation to find the certificate hash
57+ validation, err := c.queries.GetCertificateValidationByHostname(ctx, hostname)
58+ if err != nil {
59+ return err
60+ }
61+
62+ return c.queries.DeleteCertificateValidation(ctx, hostname, validation.CertificateHash)
63+}
64+
65+// CertValidationsCache is a specialized cache for certificate validations.
66+// It wraps GenericCache with hostname-specific helper methods.
67+// Key insight: Caches hostname -> validation decision to avoid DB queries on HTTPS cert errors.
68+type CertValidationsCache struct {
69+ *generic.GenericCache[string, db.CertificateValidation]
70+}
71+
72+// NewCertValidationsCache creates a new certificate validations cache.
73+func NewCertValidationsCache(queries db.DatabaseQuerier) *CertValidationsCache {
74+ dbOps := NewCertDBOperations(queries)
75+ return &CertValidationsCache{
76+ GenericCache: generic.NewGenericCache(dbOps),
77+ }
78+}
M internal/cache/filter_cache.go +45 -13
@@ -89,7 +89,11 @@ func (fc *FilterCache) LoadMapped() (*FilterData, error) {
8989 if err != nil {
9090 return nil, fmt.Errorf("failed to open cache file: %w", err)
9191 }
92- defer file.Close()
92+ defer func() {
93+ if err := file.Close(); err != nil {
94+ logging.Warn(fmt.Sprintf("failed to close cache file: %v", err))
95+ }
96+ }()
9397
9498 // Get file size
9599 stat, err := file.Stat()
@@ -135,7 +139,9 @@ func (fc *FilterCache) LoadMapped() (*FilterData, error) {
135139 // Parse filter data without copying
136140 data, err := fc.parseInPlace(header)
137141 if err != nil {
138- fc.Unmap()
142+ if unmapErr := fc.Unmap(); unmapErr != nil {
143+ logging.Warn(fmt.Sprintf("failed to unmap during error handling: %v", unmapErr))
144+ }
139145 return nil, fmt.Errorf("failed to parse cache data: %w", err)
140146 }
141147
@@ -221,7 +227,11 @@ func (fc *FilterCache) Write(data *FilterData) error {
221227 if err != nil {
222228 return fmt.Errorf("failed to create cache file: %w", err)
223229 }
224- defer file.Close()
230+ defer func() {
231+ if err := file.Close(); err != nil {
232+ logging.Warn(fmt.Sprintf("failed to close cache file: %v", err))
233+ }
234+ }()
225235
226236 // Calculate total rule count
227237 totalRules := uint32(len(data.NetworkRules) + len(data.CosmeticRules))
@@ -237,7 +247,9 @@ func (fc *FilterCache) Write(data *FilterData) error {
237247 }
238248
239249 if err := binary.Write(file, binary.LittleEndian, header); err != nil {
240- os.Remove(tmpPath)
250+ if removeErr := os.Remove(tmpPath); removeErr != nil {
251+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
252+ }
241253 return fmt.Errorf("failed to write header: %w", err)
242254 }
243255
@@ -246,7 +258,9 @@ func (fc *FilterCache) Write(data *FilterData) error {
246258 // Write network rules
247259 for _, rule := range data.NetworkRules {
248260 if err := fc.writeNetworkRule(file, rule); err != nil {
249- os.Remove(tmpPath)
261+ if removeErr := os.Remove(tmpPath); removeErr != nil {
262+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
263+ }
250264 return fmt.Errorf("failed to write network rule: %w", err)
251265 }
252266 }
@@ -254,7 +268,9 @@ func (fc *FilterCache) Write(data *FilterData) error {
254268 // Write cosmetic rules
255269 for _, rule := range data.CosmeticRules {
256270 if err := fc.writeCosmeticRule(file, rule); err != nil {
257- os.Remove(tmpPath)
271+ if removeErr := os.Remove(tmpPath); removeErr != nil {
272+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
273+ }
258274 return fmt.Errorf("failed to write cosmetic rule: %w", err)
259275 }
260276 }
@@ -265,7 +281,9 @@ func (fc *FilterCache) Write(data *FilterData) error {
265281
266282 // Update header with data size
267283 if _, err := file.Seek(0, io.SeekStart); err != nil {
268- os.Remove(tmpPath)
284+ if removeErr := os.Remove(tmpPath); removeErr != nil {
285+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
286+ }
269287 return err
270288 }
271289
@@ -273,20 +291,28 @@ func (fc *FilterCache) Write(data *FilterData) error {
273291 // TODO: Calculate and set checksum
274292
275293 if err := binary.Write(file, binary.LittleEndian, header); err != nil {
276- os.Remove(tmpPath)
294+ if removeErr := os.Remove(tmpPath); removeErr != nil {
295+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
296+ }
277297 return fmt.Errorf("failed to update header: %w", err)
278298 }
279299
280300 if err := file.Sync(); err != nil {
281- os.Remove(tmpPath)
301+ if removeErr := os.Remove(tmpPath); removeErr != nil {
302+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
303+ }
282304 return fmt.Errorf("failed to sync cache file: %w", err)
283305 }
284306
285- file.Close()
307+ if err := file.Close(); err != nil {
308+ logging.Warn(fmt.Sprintf("failed to close cache file: %v", err))
309+ }
286310
287311 // Atomic rename
288312 if err := os.Rename(tmpPath, fc.path); err != nil {
289- os.Remove(tmpPath)
313+ if removeErr := os.Remove(tmpPath); removeErr != nil {
314+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
315+ }
290316 return fmt.Errorf("failed to rename cache file: %w", err)
291317 }
292318
@@ -382,7 +408,11 @@ func (fc *FilterCache) IsValid() bool {
382408 if err != nil {
383409 return false
384410 }
385- defer file.Close()
411+ defer func() {
412+ if err := file.Close(); err != nil {
413+ logging.Warn(fmt.Sprintf("failed to close file during validation: %v", err))
414+ }
415+ }()
386416
387417 var magic uint64
388418 if err := binary.Read(file, binary.LittleEndian, &magic); err != nil {
@@ -394,6 +424,8 @@ func (fc *FilterCache) IsValid() bool {
394424
395425 // Remove deletes the cache file
396426 func (fc *FilterCache) Remove() error {
397- fc.Unmap() // Unmap first if mapped
427+ if err := fc.Unmap(); err != nil {
428+ logging.Warn(fmt.Sprintf("failed to unmap file during removal: %v", err))
429+ }
398430 return os.Remove(fc.path)
399431 }
@@ -89,7 +89,11 @@ func (fc *FilterCache) LoadMapped() (*FilterData, error) {
89 if err != nil {89 if err != nil {
90 return nil, fmt.Errorf("failed to open cache file: %w", err)90 return nil, fmt.Errorf("failed to open cache file: %w", err)
91 }91 }
92- defer file.Close()92+ defer func() {
93+ if err := file.Close(); err != nil {
94+ logging.Warn(fmt.Sprintf("failed to close cache file: %v", err))
95+ }
96+ }()
93 97
94 // Get file size98 // Get file size
95 stat, err := file.Stat()99 stat, err := file.Stat()
@@ -135,7 +139,9 @@ func (fc *FilterCache) LoadMapped() (*FilterData, error) {
135 // Parse filter data without copying139 // Parse filter data without copying
136 data, err := fc.parseInPlace(header)140 data, err := fc.parseInPlace(header)
137 if err != nil {141 if err != nil {
138- fc.Unmap()142+ if unmapErr := fc.Unmap(); unmapErr != nil {
143+ logging.Warn(fmt.Sprintf("failed to unmap during error handling: %v", unmapErr))
144+ }
139 return nil, fmt.Errorf("failed to parse cache data: %w", err)145 return nil, fmt.Errorf("failed to parse cache data: %w", err)
140 }146 }
141 147
@@ -221,7 +227,11 @@ func (fc *FilterCache) Write(data *FilterData) error {
221 if err != nil {227 if err != nil {
222 return fmt.Errorf("failed to create cache file: %w", err)228 return fmt.Errorf("failed to create cache file: %w", err)
223 }229 }
224- defer file.Close()230+ defer func() {
231+ if err := file.Close(); err != nil {
232+ logging.Warn(fmt.Sprintf("failed to close cache file: %v", err))
233+ }
234+ }()
225 235
226 // Calculate total rule count236 // Calculate total rule count
227 totalRules := uint32(len(data.NetworkRules) + len(data.CosmeticRules))237 totalRules := uint32(len(data.NetworkRules) + len(data.CosmeticRules))
@@ -237,7 +247,9 @@ func (fc *FilterCache) Write(data *FilterData) error {
237 }247 }
238 248
239 if err := binary.Write(file, binary.LittleEndian, header); err != nil {249 if err := binary.Write(file, binary.LittleEndian, header); err != nil {
240- os.Remove(tmpPath)250+ if removeErr := os.Remove(tmpPath); removeErr != nil {
251+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
252+ }
241 return fmt.Errorf("failed to write header: %w", err)253 return fmt.Errorf("failed to write header: %w", err)
242 }254 }
243 255
@@ -246,7 +258,9 @@ func (fc *FilterCache) Write(data *FilterData) error {
246 // Write network rules258 // Write network rules
247 for _, rule := range data.NetworkRules {259 for _, rule := range data.NetworkRules {
248 if err := fc.writeNetworkRule(file, rule); err != nil {260 if err := fc.writeNetworkRule(file, rule); err != nil {
249- os.Remove(tmpPath)261+ if removeErr := os.Remove(tmpPath); removeErr != nil {
262+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
263+ }
250 return fmt.Errorf("failed to write network rule: %w", err)264 return fmt.Errorf("failed to write network rule: %w", err)
251 }265 }
252 }266 }
@@ -254,7 +268,9 @@ func (fc *FilterCache) Write(data *FilterData) error {
254 // Write cosmetic rules268 // Write cosmetic rules
255 for _, rule := range data.CosmeticRules {269 for _, rule := range data.CosmeticRules {
256 if err := fc.writeCosmeticRule(file, rule); err != nil {270 if err := fc.writeCosmeticRule(file, rule); err != nil {
257- os.Remove(tmpPath)271+ if removeErr := os.Remove(tmpPath); removeErr != nil {
272+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
273+ }
258 return fmt.Errorf("failed to write cosmetic rule: %w", err)274 return fmt.Errorf("failed to write cosmetic rule: %w", err)
259 }275 }
260 }276 }
@@ -265,7 +281,9 @@ func (fc *FilterCache) Write(data *FilterData) error {
265 281
266 // Update header with data size282 // Update header with data size
267 if _, err := file.Seek(0, io.SeekStart); err != nil {283 if _, err := file.Seek(0, io.SeekStart); err != nil {
268- os.Remove(tmpPath)284+ if removeErr := os.Remove(tmpPath); removeErr != nil {
285+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
286+ }
269 return err287 return err
270 }288 }
271 289
@@ -273,20 +291,28 @@ func (fc *FilterCache) Write(data *FilterData) error {
273 // TODO: Calculate and set checksum291 // TODO: Calculate and set checksum
274 292
275 if err := binary.Write(file, binary.LittleEndian, header); err != nil {293 if err := binary.Write(file, binary.LittleEndian, header); err != nil {
276- os.Remove(tmpPath)294+ if removeErr := os.Remove(tmpPath); removeErr != nil {
295+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
296+ }
277 return fmt.Errorf("failed to update header: %w", err)297 return fmt.Errorf("failed to update header: %w", err)
278 }298 }
279 299
280 if err := file.Sync(); err != nil {300 if err := file.Sync(); err != nil {
281- os.Remove(tmpPath)301+ if removeErr := os.Remove(tmpPath); removeErr != nil {
302+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
303+ }
282 return fmt.Errorf("failed to sync cache file: %w", err)304 return fmt.Errorf("failed to sync cache file: %w", err)
283 }305 }
284 306
285- file.Close()307+ if err := file.Close(); err != nil {
308+ logging.Warn(fmt.Sprintf("failed to close cache file: %v", err))
309+ }
286 310
287 // Atomic rename311 // Atomic rename
288 if err := os.Rename(tmpPath, fc.path); err != nil {312 if err := os.Rename(tmpPath, fc.path); err != nil {
289- os.Remove(tmpPath)313+ if removeErr := os.Remove(tmpPath); removeErr != nil {
314+ logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
315+ }
290 return fmt.Errorf("failed to rename cache file: %w", err)316 return fmt.Errorf("failed to rename cache file: %w", err)
291 }317 }
292 318
@@ -382,7 +408,11 @@ func (fc *FilterCache) IsValid() bool {
382 if err != nil {408 if err != nil {
383 return false409 return false
384 }410 }
385- defer file.Close()411+ defer func() {
412+ if err := file.Close(); err != nil {
413+ logging.Warn(fmt.Sprintf("failed to close file during validation: %v", err))
414+ }
415+ }()
386 416
387 var magic uint64417 var magic uint64
388 if err := binary.Read(file, binary.LittleEndian, &magic); err != nil {418 if err := binary.Read(file, binary.LittleEndian, &magic); err != nil {
@@ -394,6 +424,8 @@ func (fc *FilterCache) IsValid() bool {
394 424
395 // Remove deletes the cache file425 // Remove deletes the cache file
396 func (fc *FilterCache) Remove() error {426 func (fc *FilterCache) Remove() error {
397- fc.Unmap() // Unmap first if mapped427+ if err := fc.Unmap(); err != nil {
428+ logging.Warn(fmt.Sprintf("failed to unmap file during removal: %v", err))
429+ }
398 return os.Remove(fc.path)430 return os.Remove(fc.path)
399 }431 }
M internal/cache/fuzzy_cache.go +2 -2
@@ -2,7 +2,7 @@ package cache
22
33 import (
44 "context"
5- "crypto/md5"
5+ "crypto/sha256"
66 "fmt"
77 "os"
88 "path/filepath"
@@ -286,7 +286,7 @@ func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) {
286286 }
287287
288288 // Create hash from URLs, visit counts, and last visited times
289- hasher := md5.New()
289+ hasher := sha256.New()
290290 for _, entry := range recentHistory {
291291 hasher.Write([]byte(entry.Url))
292292 if entry.VisitCount.Valid {
@@ -2,7 +2,7 @@ package cache
2 2
3 import (3 import (
4 "context"4 "context"
5- "crypto/md5"5+ "crypto/sha256"
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
@@ -286,7 +286,7 @@ func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) {
286 }286 }
287 287
288 // Create hash from URLs, visit counts, and last visited times288 // Create hash from URLs, visit counts, and last visited times
289- hasher := md5.New()289+ hasher := sha256.New()
290 for _, entry := range recentHistory {290 for _, entry := range recentHistory {
291 hasher.Write([]byte(entry.Url))291 hasher.Write([]byte(entry.Url))
292 if entry.VisitCount.Valid {292 if entry.VisitCount.Valid {
M internal/cache/fuzzy_cache_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010
1111 mock_cache "github.com/bnema/dumber/internal/cache/mocks"
1212 "github.com/bnema/dumber/internal/db"
13- "github.com/golang/mock/gomock"
13+ "go.uber.org/mock/gomock"
1414 )
1515
1616 func createTestHistory() []db.History {
@@ -10,7 +10,7 @@ import (
10 10
11 mock_cache "github.com/bnema/dumber/internal/cache/mocks"11 mock_cache "github.com/bnema/dumber/internal/cache/mocks"
12 "github.com/bnema/dumber/internal/db"12 "github.com/bnema/dumber/internal/db"
13- "github.com/golang/mock/gomock"13+ "go.uber.org/mock/gomock"
14 )14 )
15 15
16 func createTestHistory() []db.History {16 func createTestHistory() []db.History {
A internal/cache/generic/README.md +244 -0
@@ -0,0 +1,244 @@
1+# Generic Cache Framework
2+
3+A type-safe, high-performance caching layer for Dumber Browser that provides RAM-first access with asynchronous database persistence.
4+
5+## Overview
6+
7+The generic cache framework provides a consistent caching pattern across all Dumber Browser caches:
8+- **RAM-First**: All reads from memory (no DB queries)
9+- **Async Writes**: Updates happen immediately in cache, persisted asynchronously
10+- **Bulk Load**: Load all data from DB at startup
11+- **Graceful Shutdown**: Flush all pending writes on shutdown
12+
13+## Architecture
14+
15+```
16+┌─────────────────────────────────────────────────────────────┐
17+│ Application Code │
18+│ (BrowserService, etc.) │
19+└──────────────────────────┬──────────────────────────────────┘
20+ │
21+ ▼
22+┌─────────────────────────────────────────────────────────────┐
23+│ Cache[K, V] Interface │
24+│ │
25+│ Load(ctx) - Bulk load at startup │
26+│ Get(key) - RAM lookup only (never DB) │
27+│ Set(key, value) - Immediate cache + async DB │
28+│ Delete(key) - Immediate cache + async DB │
29+│ List() - All cached values │
30+│ Flush() - Wait for pending writes │
31+└──────────────────────────┬──────────────────────────────────┘
32+ │
33+ ▼
34+┌─────────────────────────────────────────────────────────────┐
35+│ GenericCache[K, V] Implementation │
36+│ │
37+│ ┌──────────────┐ ┌──────────────────────────────────┐ │
38+│ │ sync.Map │ │ DatabaseOperations[K, V] │ │
39+│ │ (RAM cache) │ │ │ │
40+│ │ │ │ - LoadAll(ctx) → map[K]V │ │
41+│ │ │ │ - Persist(ctx, K, V) → error │ │
42+│ │ │ │ - Delete(ctx, K) → error │ │
43+│ └──────────────┘ └──────────────────────────────────┘ │
44+│ │
45+│ ┌────────────────────────────────────────────────────────┐ │
46+│ │ pendingWrites sync.WaitGroup │ │
47+│ │ (tracks async operations for graceful shutdown) │ │
48+│ └────────────────────────────────────────────────────────┘ │
49+└──────────────────────────┬──────────────────────────────────┘
50+ │
51+ ▼
52+┌─────────────────────────────────────────────────────────────┐
53+│ Database (SQLite) │
54+│ │
55+│ zoom_levels, shortcuts, certificate_validations, etc. │
56+└─────────────────────────────────────────────────────────────┘
57+```
58+
59+## Usage Example
60+
61+### 1. Implement DatabaseOperations
62+
63+First, create a struct that implements the `DatabaseOperations[K, V]` interface:
64+
65+```go
66+package cache
67+
68+import (
69+ "context"
70+ "github.com/bnema/dumber/internal/cache/generic"
71+ "github.com/bnema/dumber/internal/db"
72+)
73+
74+// ZoomDBOperations implements DatabaseOperations for zoom levels
75+type ZoomDBOperations struct {
76+ queries db.DatabaseQuerier
77+}
78+
79+func NewZoomDBOperations(queries db.DatabaseQuerier) *ZoomDBOperations {
80+ return &ZoomDBOperations{queries: queries}
81+}
82+
83+func (z *ZoomDBOperations) LoadAll(ctx context.Context) (map[string]float64, error) {
84+ levels, err := z.queries.ListZoomLevels(ctx)
85+ if err != nil {
86+ return nil, err
87+ }
88+
89+ result := make(map[string]float64, len(levels))
90+ for _, level := range levels {
91+ result[level.Domain] = level.ZoomFactor
92+ }
93+ return result, nil
94+}
95+
96+func (z *ZoomDBOperations) Persist(ctx context.Context, key string, value float64) error {
97+ return z.queries.SetZoomLevel(ctx, key, value)
98+}
99+
100+func (z *ZoomDBOperations) Delete(ctx context.Context, key string) error {
101+ return z.queries.DeleteZoomLevel(ctx, key)
102+}
103+```
104+
105+### 2. Create a Cache Instance
106+
107+```go
108+package cache
109+
110+import "github.com/bnema/dumber/internal/cache/generic"
111+
112+type ZoomCache struct {
113+ *generic.GenericCache[string, float64]
114+}
115+
116+func NewZoomCache(queries db.DatabaseQuerier) *ZoomCache {
117+ dbOps := NewZoomDBOperations(queries)
118+ return &ZoomCache{
119+ GenericCache: generic.NewGenericCache(dbOps),
120+ }
121+}
122+```
123+
124+### 3. Use in Application Code
125+
126+```go
127+// At startup
128+zoomCache := cache.NewZoomCache(dbQueries)
129+if err := zoomCache.Load(ctx); err != nil {
130+ log.Fatalf("Failed to load zoom cache: %v", err)
131+}
132+
133+// Get (always from RAM, never hits DB)
134+if zoomLevel, ok := zoomCache.Get("example.com"); ok {
135+ fmt.Printf("Zoom level: %.2f\n", zoomLevel)
136+}
137+
138+// Set (immediate in cache, async to DB)
139+if err := zoomCache.Set("example.com", 1.5); err != nil {
140+ log.Printf("Failed to set zoom level: %v", err)
141+}
142+
143+// On shutdown
144+if err := zoomCache.Flush(); err != nil {
145+ log.Printf("Failed to flush cache: %v", err)
146+}
147+```
148+
149+## Performance Characteristics
150+
151+| Operation | Latency | Notes |
152+|-----------|-------------|--------------------------------------------|
153+| Load() | ~2-50ms | One-time at startup, bulk loads all data |
154+| Get() | <1µs | sync.Map lookup, never touches DB |
155+| Set() | <1µs | Returns immediately, DB write async |
156+| Delete() | <1µs | Returns immediately, DB delete async |
157+| List() | ~10-100µs | Iterates sync.Map, no DB access |
158+| Flush() | Varies | Blocks until all async writes complete |
159+
160+## Thread Safety
161+
162+All operations are thread-safe:
163+- `sync.Map` provides lock-free concurrent reads and writes
164+- `sync.WaitGroup` coordinates graceful shutdown
165+- Database operations are serialized per key (last write wins)
166+
167+## Testing
168+
169+The framework includes comprehensive unit tests with a mock implementation:
170+
171+```go
172+import "github.com/bnema/dumber/internal/cache/generic"
173+
174+func TestMyCache(t *testing.T) {
175+ mock := generic.NewMockDatabaseOperations[string, int]()
176+
177+ // Configure mock behavior
178+ mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
179+ return map[string]int{"key": 42}, nil
180+ }
181+
182+ cache := generic.NewGenericCache(mock)
183+
184+ // Test cache operations
185+ if err := cache.Load(context.Background()); err != nil {
186+ t.Fatal(err)
187+ }
188+
189+ val, ok := cache.Get("key")
190+ if !ok || val != 42 {
191+ t.Errorf("Expected key=42, got %v, %v", val, ok)
192+ }
193+
194+ // Verify mock was called
195+ if count := mock.GetLoadAllCallCount(); count != 1 {
196+ t.Errorf("Expected LoadAll called once, got %d", count)
197+ }
198+}
199+```
200+
201+## Design Decisions
202+
203+### Why Generic?
204+
205+Type safety and code reuse. The same cache implementation works for:
206+- `Cache[string, float64]` (zoom levels)
207+- `Cache[string, Shortcut]` (search shortcuts)
208+- `Cache[string, CertValidation]` (TLS certificates)
209+
210+### Why Async Persistence?
211+
212+- **Startup Performance**: <500ms startup time requirement
213+- **UI Responsiveness**: Never block user actions on DB writes
214+- **Write Coalescing**: Multiple rapid updates to same key only persist latest value
215+
216+### Why Bulk Load?
217+
218+- Small datasets (10-100 entries typical)
219+- Faster than incremental loading with query overhead
220+- Simpler code: no cache miss logic needed
221+
222+### Why sync.Map Instead of map + RWMutex?
223+
224+- Lock-free reads in common case
225+- Better performance under concurrent load
226+- Standard library, battle-tested
227+
228+## Limitations
229+
230+- **Not for large datasets**: Loads entire dataset into RAM
231+- **No TTL/expiration**: All entries cached indefinitely
232+- **No size limits**: Assumes datasets fit comfortably in memory
233+- **Last write wins**: No conflict resolution for concurrent updates
234+
235+For Dumber Browser's use case (small configuration tables), these tradeoffs are acceptable.
236+
237+## Future Enhancements
238+
239+Potential improvements if needed:
240+- [ ] Add metrics (cache hits, miss rate, persist latency)
241+- [ ] Add TTL support for time-sensitive data
242+- [ ] Add size-based eviction for large caches
243+- [ ] Add batch persistence for write-heavy workloads
244+- [ ] Add versioning/CAS for concurrent update detection
@@ -0,0 +1,244 @@
1+# Generic Cache Framework
2+
3+A type-safe, high-performance caching layer for Dumber Browser that provides RAM-first access with asynchronous database persistence.
4+
5+## Overview
6+
7+The generic cache framework provides a consistent caching pattern across all Dumber Browser caches:
8+- **RAM-First**: All reads from memory (no DB queries)
9+- **Async Writes**: Updates happen immediately in cache, persisted asynchronously
10+- **Bulk Load**: Load all data from DB at startup
11+- **Graceful Shutdown**: Flush all pending writes on shutdown
12+
13+## Architecture
14+
15+```
16+┌─────────────────────────────────────────────────────────────┐
17+│ Application Code │
18+│ (BrowserService, etc.) │
19+└──────────────────────────┬──────────────────────────────────┘
20+ │
21+ ▼
22+┌─────────────────────────────────────────────────────────────┐
23+│ Cache[K, V] Interface │
24+│ │
25+│ Load(ctx) - Bulk load at startup │
26+│ Get(key) - RAM lookup only (never DB) │
27+│ Set(key, value) - Immediate cache + async DB │
28+│ Delete(key) - Immediate cache + async DB │
29+│ List() - All cached values │
30+│ Flush() - Wait for pending writes │
31+└──────────────────────────┬──────────────────────────────────┘
32+ │
33+ ▼
34+┌─────────────────────────────────────────────────────────────┐
35+│ GenericCache[K, V] Implementation │
36+│ │
37+│ ┌──────────────┐ ┌──────────────────────────────────┐ │
38+│ │ sync.Map │ │ DatabaseOperations[K, V] │ │
39+│ │ (RAM cache) │ │ │ │
40+│ │ │ │ - LoadAll(ctx) → map[K]V │ │
41+│ │ │ │ - Persist(ctx, K, V) → error │ │
42+│ │ │ │ - Delete(ctx, K) → error │ │
43+│ └──────────────┘ └──────────────────────────────────┘ │
44+│ │
45+│ ┌────────────────────────────────────────────────────────┐ │
46+│ │ pendingWrites sync.WaitGroup │ │
47+│ │ (tracks async operations for graceful shutdown) │ │
48+│ └────────────────────────────────────────────────────────┘ │
49+└──────────────────────────┬──────────────────────────────────┘
50+ │
51+ ▼
52+┌─────────────────────────────────────────────────────────────┐
53+│ Database (SQLite) │
54+│ │
55+│ zoom_levels, shortcuts, certificate_validations, etc. │
56+└─────────────────────────────────────────────────────────────┘
57+```
58+
59+## Usage Example
60+
61+### 1. Implement DatabaseOperations
62+
63+First, create a struct that implements the `DatabaseOperations[K, V]` interface:
64+
65+```go
66+package cache
67+
68+import (
69+ "context"
70+ "github.com/bnema/dumber/internal/cache/generic"
71+ "github.com/bnema/dumber/internal/db"
72+)
73+
74+// ZoomDBOperations implements DatabaseOperations for zoom levels
75+type ZoomDBOperations struct {
76+ queries db.DatabaseQuerier
77+}
78+
79+func NewZoomDBOperations(queries db.DatabaseQuerier) *ZoomDBOperations {
80+ return &ZoomDBOperations{queries: queries}
81+}
82+
83+func (z *ZoomDBOperations) LoadAll(ctx context.Context) (map[string]float64, error) {
84+ levels, err := z.queries.ListZoomLevels(ctx)
85+ if err != nil {
86+ return nil, err
87+ }
88+
89+ result := make(map[string]float64, len(levels))
90+ for _, level := range levels {
91+ result[level.Domain] = level.ZoomFactor
92+ }
93+ return result, nil
94+}
95+
96+func (z *ZoomDBOperations) Persist(ctx context.Context, key string, value float64) error {
97+ return z.queries.SetZoomLevel(ctx, key, value)
98+}
99+
100+func (z *ZoomDBOperations) Delete(ctx context.Context, key string) error {
101+ return z.queries.DeleteZoomLevel(ctx, key)
102+}
103+```
104+
105+### 2. Create a Cache Instance
106+
107+```go
108+package cache
109+
110+import "github.com/bnema/dumber/internal/cache/generic"
111+
112+type ZoomCache struct {
113+ *generic.GenericCache[string, float64]
114+}
115+
116+func NewZoomCache(queries db.DatabaseQuerier) *ZoomCache {
117+ dbOps := NewZoomDBOperations(queries)
118+ return &ZoomCache{
119+ GenericCache: generic.NewGenericCache(dbOps),
120+ }
121+}
122+```
123+
124+### 3. Use in Application Code
125+
126+```go
127+// At startup
128+zoomCache := cache.NewZoomCache(dbQueries)
129+if err := zoomCache.Load(ctx); err != nil {
130+ log.Fatalf("Failed to load zoom cache: %v", err)
131+}
132+
133+// Get (always from RAM, never hits DB)
134+if zoomLevel, ok := zoomCache.Get("example.com"); ok {
135+ fmt.Printf("Zoom level: %.2f\n", zoomLevel)
136+}
137+
138+// Set (immediate in cache, async to DB)
139+if err := zoomCache.Set("example.com", 1.5); err != nil {
140+ log.Printf("Failed to set zoom level: %v", err)
141+}
142+
143+// On shutdown
144+if err := zoomCache.Flush(); err != nil {
145+ log.Printf("Failed to flush cache: %v", err)
146+}
147+```
148+
149+## Performance Characteristics
150+
151+| Operation | Latency | Notes |
152+|-----------|-------------|--------------------------------------------|
153+| Load() | ~2-50ms | One-time at startup, bulk loads all data |
154+| Get() | <1µs | sync.Map lookup, never touches DB |
155+| Set() | <1µs | Returns immediately, DB write async |
156+| Delete() | <1µs | Returns immediately, DB delete async |
157+| List() | ~10-100µs | Iterates sync.Map, no DB access |
158+| Flush() | Varies | Blocks until all async writes complete |
159+
160+## Thread Safety
161+
162+All operations are thread-safe:
163+- `sync.Map` provides lock-free concurrent reads and writes
164+- `sync.WaitGroup` coordinates graceful shutdown
165+- Database operations are serialized per key (last write wins)
166+
167+## Testing
168+
169+The framework includes comprehensive unit tests with a mock implementation:
170+
171+```go
172+import "github.com/bnema/dumber/internal/cache/generic"
173+
174+func TestMyCache(t *testing.T) {
175+ mock := generic.NewMockDatabaseOperations[string, int]()
176+
177+ // Configure mock behavior
178+ mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
179+ return map[string]int{"key": 42}, nil
180+ }
181+
182+ cache := generic.NewGenericCache(mock)
183+
184+ // Test cache operations
185+ if err := cache.Load(context.Background()); err != nil {
186+ t.Fatal(err)
187+ }
188+
189+ val, ok := cache.Get("key")
190+ if !ok || val != 42 {
191+ t.Errorf("Expected key=42, got %v, %v", val, ok)
192+ }
193+
194+ // Verify mock was called
195+ if count := mock.GetLoadAllCallCount(); count != 1 {
196+ t.Errorf("Expected LoadAll called once, got %d", count)
197+ }
198+}
199+```
200+
201+## Design Decisions
202+
203+### Why Generic?
204+
205+Type safety and code reuse. The same cache implementation works for:
206+- `Cache[string, float64]` (zoom levels)
207+- `Cache[string, Shortcut]` (search shortcuts)
208+- `Cache[string, CertValidation]` (TLS certificates)
209+
210+### Why Async Persistence?
211+
212+- **Startup Performance**: <500ms startup time requirement
213+- **UI Responsiveness**: Never block user actions on DB writes
214+- **Write Coalescing**: Multiple rapid updates to same key only persist latest value
215+
216+### Why Bulk Load?
217+
218+- Small datasets (10-100 entries typical)
219+- Faster than incremental loading with query overhead
220+- Simpler code: no cache miss logic needed
221+
222+### Why sync.Map Instead of map + RWMutex?
223+
224+- Lock-free reads in common case
225+- Better performance under concurrent load
226+- Standard library, battle-tested
227+
228+## Limitations
229+
230+- **Not for large datasets**: Loads entire dataset into RAM
231+- **No TTL/expiration**: All entries cached indefinitely
232+- **No size limits**: Assumes datasets fit comfortably in memory
233+- **Last write wins**: No conflict resolution for concurrent updates
234+
235+For Dumber Browser's use case (small configuration tables), these tradeoffs are acceptable.
236+
237+## Future Enhancements
238+
239+Potential improvements if needed:
240+- [ ] Add metrics (cache hits, miss rate, persist latency)
241+- [ ] Add TTL support for time-sensitive data
242+- [ ] Add size-based eviction for large caches
243+- [ ] Add batch persistence for write-heavy workloads
244+- [ ] Add versioning/CAS for concurrent update detection
A internal/cache/generic/cache.go +157 -0
@@ -0,0 +1,157 @@
1+package generic
2+
3+import (
4+ "context"
5+ "log"
6+ "sync"
7+)
8+
9+// Cache provides a generic interface for caching any type of data.
10+// K is the key type (must be comparable), V is the value type.
11+// All operations are thread-safe via sync.Map.
12+//
13+// Design principles:
14+// - RAM-first: All reads from memory (no DB queries)
15+// - Async writes: Updates happen immediately in cache, persisted asynchronously
16+// - Bulk load: Load all data from DB at startup
17+// - Flush on shutdown: Ensure all pending writes complete gracefully
18+type Cache[K comparable, V any] interface {
19+ // Load bulk-loads all data from storage into memory at startup
20+ Load(ctx context.Context) error
21+
22+ // Get retrieves a value from the cache (RAM only, never queries DB)
23+ Get(key K) (V, bool)
24+
25+ // Set updates the cache immediately and persists to DB asynchronously
26+ Set(key K, value V) error
27+
28+ // Delete removes from cache immediately and persists deletion asynchronously
29+ Delete(key K) error
30+
31+ // List returns all cached values as a slice
32+ List() []V
33+
34+ // Flush waits for all pending async writes to complete (call on shutdown)
35+ Flush() error
36+}
37+
38+// DatabaseOperations defines the interface for database operations required by the cache.
39+// This interface allows for easy mocking in tests using gomock.
40+type DatabaseOperations[K comparable, V any] interface {
41+ // LoadAll loads all entries from the database
42+ LoadAll(ctx context.Context) (map[K]V, error)
43+
44+ // Persist saves a single entry to the database
45+ Persist(ctx context.Context, key K, value V) error
46+
47+ // Delete removes a single entry from the database
48+ Delete(ctx context.Context, key K) error
49+}
50+
51+// GenericCache implements Cache[K, V] using sync.Map for thread-safe storage
52+// and a DatabaseOperations interface for database operations.
53+type GenericCache[K comparable, V any] struct {
54+ cache sync.Map
55+ dbOps DatabaseOperations[K, V]
56+
57+ // Track pending async operations for graceful shutdown
58+ pendingWrites sync.WaitGroup
59+}
60+
61+// NewGenericCache creates a new cache with the provided database operations.
62+//
63+// Parameters:
64+// - dbOps: Implementation of DatabaseOperations for loading, persisting, and deleting data
65+func NewGenericCache[K comparable, V any](
66+ dbOps DatabaseOperations[K, V],
67+) *GenericCache[K, V] {
68+ return &GenericCache[K, V]{
69+ dbOps: dbOps,
70+ }
71+}
72+
73+// Load bulk-loads all data from the database into memory.
74+// Should be called once at application startup.
75+func (c *GenericCache[K, V]) Load(ctx context.Context) error {
76+ data, err := c.dbOps.LoadAll(ctx)
77+ if err != nil {
78+ return err
79+ }
80+
81+ for k, v := range data {
82+ c.cache.Store(k, v)
83+ }
84+
85+ return nil
86+}
87+
88+// Get retrieves a value from the cache. Returns (value, true) if found,
89+// or (zero value, false) if not found. Never queries the database.
90+func (c *GenericCache[K, V]) Get(key K) (V, bool) {
91+ val, ok := c.cache.Load(key)
92+ if !ok {
93+ var zero V
94+ return zero, false
95+ }
96+ return val.(V), true
97+}
98+
99+// Set updates the cache immediately (synchronous) and persists to the database
100+// asynchronously (non-blocking). Returns immediately without waiting for DB write.
101+func (c *GenericCache[K, V]) Set(key K, value V) error {
102+ // Update cache immediately (sync, fast)
103+ c.cache.Store(key, value)
104+
105+ // Persist to DB asynchronously (non-blocking)
106+ c.pendingWrites.Add(1)
107+ go func() {
108+ defer c.pendingWrites.Done()
109+
110+ ctx := context.Background()
111+ if err := c.dbOps.Persist(ctx, key, value); err != nil {
112+ log.Printf("Warning: async persist failed for key %v: %v", key, err)
113+ }
114+ }()
115+
116+ return nil
117+}
118+
119+// Delete removes from cache immediately (synchronous) and persists deletion
120+// to the database asynchronously (non-blocking).
121+func (c *GenericCache[K, V]) Delete(key K) error {
122+ // Remove from cache immediately (sync, fast)
123+ c.cache.Delete(key)
124+
125+ // Persist deletion to DB asynchronously (non-blocking)
126+ c.pendingWrites.Add(1)
127+ go func() {
128+ defer c.pendingWrites.Done()
129+
130+ ctx := context.Background()
131+ if err := c.dbOps.Delete(ctx, key); err != nil {
132+ log.Printf("Warning: async delete failed for key %v: %v", key, err)
133+ }
134+ }()
135+
136+ return nil
137+}
138+
139+// List returns all values currently in the cache as a slice.
140+// The order is not guaranteed.
141+func (c *GenericCache[K, V]) List() []V {
142+ var values []V
143+
144+ c.cache.Range(func(key, value interface{}) bool {
145+ values = append(values, value.(V))
146+ return true
147+ })
148+
149+ return values
150+}
151+
152+// Flush blocks until all pending async writes complete.
153+// Should be called during graceful shutdown to ensure no data loss.
154+func (c *GenericCache[K, V]) Flush() error {
155+ c.pendingWrites.Wait()
156+ return nil
157+}
@@ -0,0 +1,157 @@
1+package generic
2+
3+import (
4+ "context"
5+ "log"
6+ "sync"
7+)
8+
9+// Cache provides a generic interface for caching any type of data.
10+// K is the key type (must be comparable), V is the value type.
11+// All operations are thread-safe via sync.Map.
12+//
13+// Design principles:
14+// - RAM-first: All reads from memory (no DB queries)
15+// - Async writes: Updates happen immediately in cache, persisted asynchronously
16+// - Bulk load: Load all data from DB at startup
17+// - Flush on shutdown: Ensure all pending writes complete gracefully
18+type Cache[K comparable, V any] interface {
19+ // Load bulk-loads all data from storage into memory at startup
20+ Load(ctx context.Context) error
21+
22+ // Get retrieves a value from the cache (RAM only, never queries DB)
23+ Get(key K) (V, bool)
24+
25+ // Set updates the cache immediately and persists to DB asynchronously
26+ Set(key K, value V) error
27+
28+ // Delete removes from cache immediately and persists deletion asynchronously
29+ Delete(key K) error
30+
31+ // List returns all cached values as a slice
32+ List() []V
33+
34+ // Flush waits for all pending async writes to complete (call on shutdown)
35+ Flush() error
36+}
37+
38+// DatabaseOperations defines the interface for database operations required by the cache.
39+// This interface allows for easy mocking in tests using gomock.
40+type DatabaseOperations[K comparable, V any] interface {
41+ // LoadAll loads all entries from the database
42+ LoadAll(ctx context.Context) (map[K]V, error)
43+
44+ // Persist saves a single entry to the database
45+ Persist(ctx context.Context, key K, value V) error
46+
47+ // Delete removes a single entry from the database
48+ Delete(ctx context.Context, key K) error
49+}
50+
51+// GenericCache implements Cache[K, V] using sync.Map for thread-safe storage
52+// and a DatabaseOperations interface for database operations.
53+type GenericCache[K comparable, V any] struct {
54+ cache sync.Map
55+ dbOps DatabaseOperations[K, V]
56+
57+ // Track pending async operations for graceful shutdown
58+ pendingWrites sync.WaitGroup
59+}
60+
61+// NewGenericCache creates a new cache with the provided database operations.
62+//
63+// Parameters:
64+// - dbOps: Implementation of DatabaseOperations for loading, persisting, and deleting data
65+func NewGenericCache[K comparable, V any](
66+ dbOps DatabaseOperations[K, V],
67+) *GenericCache[K, V] {
68+ return &GenericCache[K, V]{
69+ dbOps: dbOps,
70+ }
71+}
72+
73+// Load bulk-loads all data from the database into memory.
74+// Should be called once at application startup.
75+func (c *GenericCache[K, V]) Load(ctx context.Context) error {
76+ data, err := c.dbOps.LoadAll(ctx)
77+ if err != nil {
78+ return err
79+ }
80+
81+ for k, v := range data {
82+ c.cache.Store(k, v)
83+ }
84+
85+ return nil
86+}
87+
88+// Get retrieves a value from the cache. Returns (value, true) if found,
89+// or (zero value, false) if not found. Never queries the database.
90+func (c *GenericCache[K, V]) Get(key K) (V, bool) {
91+ val, ok := c.cache.Load(key)
92+ if !ok {
93+ var zero V
94+ return zero, false
95+ }
96+ return val.(V), true
97+}
98+
99+// Set updates the cache immediately (synchronous) and persists to the database
100+// asynchronously (non-blocking). Returns immediately without waiting for DB write.
101+func (c *GenericCache[K, V]) Set(key K, value V) error {
102+ // Update cache immediately (sync, fast)
103+ c.cache.Store(key, value)
104+
105+ // Persist to DB asynchronously (non-blocking)
106+ c.pendingWrites.Add(1)
107+ go func() {
108+ defer c.pendingWrites.Done()
109+
110+ ctx := context.Background()
111+ if err := c.dbOps.Persist(ctx, key, value); err != nil {
112+ log.Printf("Warning: async persist failed for key %v: %v", key, err)
113+ }
114+ }()
115+
116+ return nil
117+}
118+
119+// Delete removes from cache immediately (synchronous) and persists deletion
120+// to the database asynchronously (non-blocking).
121+func (c *GenericCache[K, V]) Delete(key K) error {
122+ // Remove from cache immediately (sync, fast)
123+ c.cache.Delete(key)
124+
125+ // Persist deletion to DB asynchronously (non-blocking)
126+ c.pendingWrites.Add(1)
127+ go func() {
128+ defer c.pendingWrites.Done()
129+
130+ ctx := context.Background()
131+ if err := c.dbOps.Delete(ctx, key); err != nil {
132+ log.Printf("Warning: async delete failed for key %v: %v", key, err)
133+ }
134+ }()
135+
136+ return nil
137+}
138+
139+// List returns all values currently in the cache as a slice.
140+// The order is not guaranteed.
141+func (c *GenericCache[K, V]) List() []V {
142+ var values []V
143+
144+ c.cache.Range(func(key, value interface{}) bool {
145+ values = append(values, value.(V))
146+ return true
147+ })
148+
149+ return values
150+}
151+
152+// Flush blocks until all pending async writes complete.
153+// Should be called during graceful shutdown to ensure no data loss.
154+func (c *GenericCache[K, V]) Flush() error {
155+ c.pendingWrites.Wait()
156+ return nil
157+}
A internal/cache/generic/cache_test.go +461 -0
@@ -0,0 +1,461 @@
1+package generic
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+)
10+
11+// TestLoad verifies that Load correctly populates the cache from the database
12+func TestLoad(t *testing.T) {
13+ mock := NewMockDatabaseOperations[string, int]()
14+ mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
15+ return map[string]int{
16+ "one": 1,
17+ "two": 2,
18+ "three": 3,
19+ }, nil
20+ }
21+
22+ cache := NewGenericCache(mock)
23+
24+ err := cache.Load(context.Background())
25+ if err != nil {
26+ t.Fatalf("Load() failed: %v", err)
27+ }
28+
29+ // Verify LoadAll was called once
30+ if count := mock.GetLoadAllCallCount(); count != 1 {
31+ t.Errorf("Expected LoadAll to be called once, got %d", count)
32+ }
33+
34+ // Verify all entries were loaded into cache
35+ if val, ok := cache.Get("one"); !ok || val != 1 {
36+ t.Errorf("Expected one=1, got %v, %v", val, ok)
37+ }
38+ if val, ok := cache.Get("two"); !ok || val != 2 {
39+ t.Errorf("Expected two=2, got %v, %v", val, ok)
40+ }
41+ if val, ok := cache.Get("three"); !ok || val != 3 {
42+ t.Errorf("Expected three=3, got %v, %v", val, ok)
43+ }
44+}
45+
46+// TestLoadError verifies that Load propagates errors from the database
47+func TestLoadError(t *testing.T) {
48+ expectedErr := errors.New("database connection failed")
49+
50+ mock := NewMockDatabaseOperations[string, int]()
51+ mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
52+ return nil, expectedErr
53+ }
54+
55+ cache := NewGenericCache(mock)
56+
57+ err := cache.Load(context.Background())
58+ if !errors.Is(err, expectedErr) {
59+ t.Errorf("Expected error %v, got %v", expectedErr, err)
60+ }
61+
62+ // Verify LoadAll was called
63+ if count := mock.GetLoadAllCallCount(); count != 1 {
64+ t.Errorf("Expected LoadAll to be called once, got %d", count)
65+ }
66+}
67+
68+// TestGetNotFound verifies that Get returns false for non-existent keys
69+func TestGetNotFound(t *testing.T) {
70+ mock := NewMockDatabaseOperations[string, int]()
71+ cache := NewGenericCache(mock)
72+
73+ if err := cache.Load(context.Background()); err != nil {
74+ t.Fatalf("Load() failed: %v", err)
75+ }
76+
77+ val, ok := cache.Get("nonexistent")
78+ if ok {
79+ t.Errorf("Expected Get to return false for nonexistent key, got %v", val)
80+ }
81+ if val != 0 {
82+ t.Errorf("Expected zero value, got %v", val)
83+ }
84+}
85+
86+// TestSet verifies that Set immediately updates the cache and persists asynchronously
87+func TestSet(t *testing.T) {
88+ mock := NewMockDatabaseOperations[string, int]()
89+
90+ cache := NewGenericCache(mock)
91+ if err := cache.Load(context.Background()); err != nil {
92+ t.Fatalf("Load() failed: %v", err)
93+ }
94+
95+ // Set should return immediately
96+ err := cache.Set("test", 42)
97+ if err != nil {
98+ t.Fatalf("Set() failed: %v", err)
99+ }
100+
101+ // Value should be in cache immediately
102+ val, ok := cache.Get("test")
103+ if !ok || val != 42 {
104+ t.Errorf("Expected test=42 immediately after Set, got %v, %v", val, ok)
105+ }
106+
107+ // Wait for async persist to complete
108+ if err := cache.Flush(); err != nil {
109+ t.Fatalf("Flush() failed: %v", err)
110+ }
111+
112+ // Verify Persist was called
113+ if count := mock.GetPersistCallCount(); count != 1 {
114+ t.Errorf("Expected Persist to be called once, got %d", count)
115+ }
116+
117+ // Verify correct parameters
118+ lastCall := mock.GetLastPersistCall()
119+ if lastCall == nil {
120+ t.Fatal("Expected Persist to be called")
121+ }
122+ if lastCall.Key != "test" || lastCall.Value != 42 {
123+ t.Errorf("Expected Persist(test, 42), got Persist(%v, %v)", lastCall.Key, lastCall.Value)
124+ }
125+}
126+
127+// TestSetAsyncPersist verifies that Set doesn't block on slow database writes
128+func TestSetAsyncPersist(t *testing.T) {
129+ mock := NewMockDatabaseOperations[string, int]()
130+ slowPersist := make(chan struct{})
131+
132+ mock.PersistFunc = func(ctx context.Context, key string, value int) error {
133+ <-slowPersist // Block until we signal
134+ return nil
135+ }
136+
137+ cache := NewGenericCache(mock)
138+ if err := cache.Load(context.Background()); err != nil {
139+ t.Fatalf("Load() failed: %v", err)
140+ }
141+
142+ // Set should return immediately even though persist is blocked
143+ done := make(chan struct{})
144+ go func() {
145+ if err := cache.Set("test", 42); err != nil {
146+ t.Errorf("Set() failed: %v", err)
147+ }
148+ close(done)
149+ }()
150+
151+ select {
152+ case <-done:
153+ // Good, Set returned immediately
154+ case <-time.After(100 * time.Millisecond):
155+ t.Error("Set() blocked for too long")
156+ }
157+
158+ // Verify value is in cache even though persist hasn't completed
159+ val, ok := cache.Get("test")
160+ if !ok || val != 42 {
161+ t.Errorf("Expected test=42 in cache before persist completes, got %v, %v", val, ok)
162+ }
163+
164+ // Now allow persist to complete
165+ close(slowPersist)
166+ if err := cache.Flush(); err != nil {
167+ t.Fatalf("Flush() failed: %v", err)
168+ }
169+
170+ // Verify Persist was called
171+ if count := mock.GetPersistCallCount(); count != 1 {
172+ t.Errorf("Expected Persist to be called once, got %d", count)
173+ }
174+}
175+
176+// TestDelete verifies that Delete immediately removes from cache and persists async
177+func TestDelete(t *testing.T) {
178+ mock := NewMockDatabaseOperations[string, int]()
179+ mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
180+ return map[string]int{"test": 42}, nil
181+ }
182+
183+ cache := NewGenericCache(mock)
184+ if err := cache.Load(context.Background()); err != nil {
185+ t.Fatalf("Load() failed: %v", err)
186+ }
187+
188+ // Verify entry exists
189+ if _, ok := cache.Get("test"); !ok {
190+ t.Fatal("Expected test to exist before Delete")
191+ }
192+
193+ // Delete should return immediately
194+ err := cache.Delete("test")
195+ if err != nil {
196+ t.Fatalf("Delete() failed: %v", err)
197+ }
198+
199+ // Value should be removed from cache immediately
200+ if val, ok := cache.Get("test"); ok {
201+ t.Errorf("Expected test to be deleted immediately, got %v", val)
202+ }
203+
204+ // Wait for async delete to complete
205+ if err := cache.Flush(); err != nil {
206+ t.Fatalf("Flush() failed: %v", err)
207+ }
208+
209+ // Verify Delete was called
210+ if count := mock.GetDeleteCallCount(); count != 1 {
211+ t.Errorf("Expected Delete to be called once, got %d", count)
212+ }
213+
214+ // Verify correct parameters
215+ lastCall := mock.GetLastDeleteCall()
216+ if lastCall == nil {
217+ t.Fatal("Expected Delete to be called")
218+ }
219+ if lastCall.Key != "test" {
220+ t.Errorf("Expected Delete(test), got Delete(%v)", lastCall.Key)
221+ }
222+}
223+
224+// TestList verifies that List returns all cached values
225+func TestList(t *testing.T) {
226+ mock := NewMockDatabaseOperations[string, int]()
227+ mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
228+ return map[string]int{
229+ "one": 1,
230+ "two": 2,
231+ "three": 3,
232+ }, nil
233+ }
234+
235+ cache := NewGenericCache(mock)
236+ if err := cache.Load(context.Background()); err != nil {
237+ t.Fatalf("Load() failed: %v", err)
238+ }
239+
240+ values := cache.List()
241+ if len(values) != 3 {
242+ t.Errorf("Expected 3 values, got %d", len(values))
243+ }
244+
245+ // Verify all values are present (order not guaranteed)
246+ found := make(map[int]bool)
247+ for _, v := range values {
248+ found[v] = true
249+ }
250+
251+ for _, expected := range []int{1, 2, 3} {
252+ if !found[expected] {
253+ t.Errorf("Expected value %d in List(), not found", expected)
254+ }
255+ }
256+}
257+
258+// TestFlush verifies that Flush waits for all pending operations
259+func TestFlush(t *testing.T) {
260+ mock := NewMockDatabaseOperations[string, int]()
261+ persistDelay := make(chan struct{})
262+
263+ mock.PersistFunc = func(ctx context.Context, key string, value int) error {
264+ <-persistDelay // Wait for signal
265+ return nil
266+ }
267+
268+ cache := NewGenericCache(mock)
269+ if err := cache.Load(context.Background()); err != nil {
270+ t.Fatalf("Load() failed: %v", err)
271+ }
272+
273+ // Start multiple async operations
274+ for i := 0; i < 5; i++ {
275+ if err := cache.Set("test", i); err != nil {
276+ t.Fatalf("Set() failed: %v", err)
277+ }
278+ }
279+
280+ // Verify operations haven't completed yet
281+ if count := mock.GetPersistCallCount(); count != 0 {
282+ t.Logf("Note: %d persists completed before signal", count)
283+ }
284+
285+ // Allow all operations to proceed
286+ close(persistDelay)
287+
288+ // Flush should wait for all to complete
289+ if err := cache.Flush(); err != nil {
290+ t.Fatalf("Flush() failed: %v", err)
291+ }
292+
293+ // All persists should have completed
294+ if count := mock.GetPersistCallCount(); count != 5 {
295+ t.Errorf("Expected 5 persists after Flush, got %d", count)
296+ }
297+}
298+
299+// TestConcurrentAccess verifies thread-safety of cache operations
300+func TestConcurrentAccess(t *testing.T) {
301+ mock := NewMockDatabaseOperations[int, int]()
302+
303+ cache := NewGenericCache(mock)
304+ if err := cache.Load(context.Background()); err != nil {
305+ t.Fatalf("Load() failed: %v", err)
306+ }
307+
308+ var wg sync.WaitGroup
309+ numGoroutines := 10
310+ numOperations := 100
311+
312+ // Concurrent writes
313+ for i := 0; i < numGoroutines; i++ {
314+ wg.Add(1)
315+ go func(id int) {
316+ defer wg.Done()
317+ for j := 0; j < numOperations; j++ {
318+ key := id*numOperations + j
319+ if err := cache.Set(key, key*2); err != nil {
320+ t.Errorf("Set() failed: %v", err)
321+ }
322+ }
323+ }(i)
324+ }
325+
326+ // Concurrent reads
327+ for i := 0; i < numGoroutines; i++ {
328+ wg.Add(1)
329+ go func() {
330+ defer wg.Done()
331+ for j := 0; j < numOperations; j++ {
332+ cache.Get(j) // Result doesn't matter, just testing for races
333+ }
334+ }()
335+ }
336+
337+ wg.Wait()
338+ if err := cache.Flush(); err != nil {
339+ t.Fatalf("Flush() failed: %v", err)
340+ }
341+
342+ // Verify expected number of entries
343+ values := cache.List()
344+ expectedEntries := numGoroutines * numOperations
345+ if len(values) != expectedEntries {
346+ t.Errorf("Expected %d entries after concurrent writes, got %d", expectedEntries, len(values))
347+ }
348+
349+ // Verify all Persists were called
350+ if count := mock.GetPersistCallCount(); count != expectedEntries {
351+ t.Errorf("Expected %d Persist calls, got %d", expectedEntries, count)
352+ }
353+}
354+
355+// TestPersistError verifies that persist errors are logged but don't break the cache
356+func TestPersistError(t *testing.T) {
357+ mock := NewMockDatabaseOperations[string, int]()
358+ expectedErr := errors.New("database write failed")
359+
360+ mock.PersistFunc = func(ctx context.Context, key string, value int) error {
361+ return expectedErr
362+ }
363+
364+ cache := NewGenericCache(mock)
365+ if err := cache.Load(context.Background()); err != nil {
366+ t.Fatalf("Load() failed: %v", err)
367+ }
368+
369+ // Set should succeed even if persist fails
370+ err := cache.Set("test", 42)
371+ if err != nil {
372+ t.Errorf("Set() should not return error even if persist fails, got %v", err)
373+ }
374+
375+ // Value should still be in cache
376+ val, ok := cache.Get("test")
377+ if !ok || val != 42 {
378+ t.Errorf("Expected test=42 in cache despite persist error, got %v, %v", val, ok)
379+ }
380+
381+ // Flush should complete (errors are logged, not returned)
382+ if err := cache.Flush(); err != nil {
383+ t.Fatalf("Flush() failed: %v", err)
384+ }
385+
386+ // Verify Persist was called despite error
387+ if count := mock.GetPersistCallCount(); count != 1 {
388+ t.Errorf("Expected Persist to be called once, got %d", count)
389+ }
390+}
391+
392+// TestDeleteError verifies that delete errors are logged but don't break the cache
393+func TestDeleteError(t *testing.T) {
394+ mock := NewMockDatabaseOperations[string, int]()
395+ expectedErr := errors.New("database delete failed")
396+
397+ mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
398+ return map[string]int{"test": 42}, nil
399+ }
400+
401+ mock.DeleteFunc = func(ctx context.Context, key string) error {
402+ return expectedErr
403+ }
404+
405+ cache := NewGenericCache(mock)
406+ if err := cache.Load(context.Background()); err != nil {
407+ t.Fatalf("Load() failed: %v", err)
408+ }
409+
410+ // Delete should succeed even if DB delete fails
411+ err := cache.Delete("test")
412+ if err != nil {
413+ t.Errorf("Delete() should not return error even if DB delete fails, got %v", err)
414+ }
415+
416+ // Value should still be removed from cache
417+ if val, ok := cache.Get("test"); ok {
418+ t.Errorf("Expected test to be deleted from cache despite DB error, got %v", val)
419+ }
420+
421+ if err := cache.Flush(); err != nil {
422+ t.Fatalf("Flush() failed: %v", err)
423+ }
424+
425+ // Verify Delete was called despite error
426+ if count := mock.GetDeleteCallCount(); count != 1 {
427+ t.Errorf("Expected Delete to be called once, got %d", count)
428+ }
429+}
430+
431+// TestMultipleSetsAndDeletes verifies correct handling of multiple operations
432+func TestMultipleSetsAndDeletes(t *testing.T) {
433+ mock := NewMockDatabaseOperations[string, int]()
434+
435+ cache := NewGenericCache(mock)
436+ if err := cache.Load(context.Background()); err != nil {
437+ t.Fatalf("Load() failed: %v", err)
438+ }
439+
440+ // Perform multiple sets
441+ for i := 0; i < 10; i++ {
442+ if err := cache.Set("key", i); err != nil {
443+ t.Fatalf("Set() failed: %v", err)
444+ }
445+ }
446+
447+ // Latest value should be in cache
448+ val, ok := cache.Get("key")
449+ if !ok || val != 9 {
450+ t.Errorf("Expected key=9, got %v, %v", val, ok)
451+ }
452+
453+ // Flush and verify all persists were called
454+ if err := cache.Flush(); err != nil {
455+ t.Fatalf("Flush() failed: %v", err)
456+ }
457+
458+ if count := mock.GetPersistCallCount(); count != 10 {
459+ t.Errorf("Expected 10 Persist calls, got %d", count)
460+ }
461+}
@@ -0,0 +1,461 @@
1+package generic
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+)
10+
11+// TestLoad verifies that Load correctly populates the cache from the database
12+func TestLoad(t *testing.T) {
13+ mock := NewMockDatabaseOperations[string, int]()
14+ mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
15+ return map[string]int{
16+ "one": 1,
17+ "two": 2,
18+ "three": 3,
19+ }, nil
20+ }
21+
22+ cache := NewGenericCache(mock)
23+
24+ err := cache.Load(context.Background())
25+ if err != nil {
26+ t.Fatalf("Load() failed: %v", err)
27+ }
28+
29+ // Verify LoadAll was called once
30+ if count := mock.GetLoadAllCallCount(); count != 1 {
31+ t.Errorf("Expected LoadAll to be called once, got %d", count)
32+ }
33+
34+ // Verify all entries were loaded into cache
35+ if val, ok := cache.Get("one"); !ok || val != 1 {
36+ t.Errorf("Expected one=1, got %v, %v", val, ok)
37+ }
38+ if val, ok := cache.Get("two"); !ok || val != 2 {
39+ t.Errorf("Expected two=2, got %v, %v", val, ok)
40+ }
41+ if val, ok := cache.Get("three"); !ok || val != 3 {
42+ t.Errorf("Expected three=3, got %v, %v", val, ok)
43+ }
44+}
45+
46+// TestLoadError verifies that Load propagates errors from the database
47+func TestLoadError(t *testing.T) {
48+ expectedErr := errors.New("database connection failed")
49+
50+ mock := NewMockDatabaseOperations[string, int]()
51+ mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
52+ return nil, expectedErr
53+ }
54+
55+ cache := NewGenericCache(mock)
56+
57+ err := cache.Load(context.Background())
58+ if !errors.Is(err, expectedErr) {
59+ t.Errorf("Expected error %v, got %v", expectedErr, err)
60+ }
61+
62+ // Verify LoadAll was called
63+ if count := mock.GetLoadAllCallCount(); count != 1 {
64+ t.Errorf("Expected LoadAll to be called once, got %d", count)
65+ }
66+}
67+
68+// TestGetNotFound verifies that Get returns false for non-existent keys
69+func TestGetNotFound(t *testing.T) {
70+ mock := NewMockDatabaseOperations[string, int]()
71+ cache := NewGenericCache(mock)
72+
73+ if err := cache.Load(context.Background()); err != nil {
74+ t.Fatalf("Load() failed: %v", err)
75+ }
76+
77+ val, ok := cache.Get("nonexistent")
78+ if ok {
79+ t.Errorf("Expected Get to return false for nonexistent key, got %v", val)
80+ }
81+ if val != 0 {
82+ t.Errorf("Expected zero value, got %v", val)
83+ }
84+}
85+
86+// TestSet verifies that Set immediately updates the cache and persists asynchronously
87+func TestSet(t *testing.T) {
88+ mock := NewMockDatabaseOperations[string, int]()
89+
90+ cache := NewGenericCache(mock)
91+ if err := cache.Load(context.Background()); err != nil {
92+ t.Fatalf("Load() failed: %v", err)
93+ }
94+
95+ // Set should return immediately
96+ err := cache.Set("test", 42)
97+ if err != nil {
98+ t.Fatalf("Set() failed: %v", err)
99+ }
100+
101+ // Value should be in cache immediately
102+ val, ok := cache.Get("test")
103+ if !ok || val != 42 {
104+ t.Errorf("Expected test=42 immediately after Set, got %v, %v", val, ok)
105+ }
106+
107+ // Wait for async persist to complete
108+ if err := cache.Flush(); err != nil {
109+ t.Fatalf("Flush() failed: %v", err)
110+ }
111+
112+ // Verify Persist was called
113+ if count := mock.GetPersistCallCount(); count != 1 {
114+ t.Errorf("Expected Persist to be called once, got %d", count)
115+ }
116+
117+ // Verify correct parameters
118+ lastCall := mock.GetLastPersistCall()
119+ if lastCall == nil {
120+ t.Fatal("Expected Persist to be called")
121+ }
122+ if lastCall.Key != "test" || lastCall.Value != 42 {
123+ t.Errorf("Expected Persist(test, 42), got Persist(%v, %v)", lastCall.Key, lastCall.Value)
124+ }
125+}
126+
127+// TestSetAsyncPersist verifies that Set doesn't block on slow database writes
128+func TestSetAsyncPersist(t *testing.T) {
129+ mock := NewMockDatabaseOperations[string, int]()
130+ slowPersist := make(chan struct{})
131+
132+ mock.PersistFunc = func(ctx context.Context, key string, value int) error {
133+ <-slowPersist // Block until we signal
134+ return nil
135+ }
136+
137+ cache := NewGenericCache(mock)
138+ if err := cache.Load(context.Background()); err != nil {
139+ t.Fatalf("Load() failed: %v", err)
140+ }
141+
142+ // Set should return immediately even though persist is blocked
143+ done := make(chan struct{})
144+ go func() {
145+ if err := cache.Set("test", 42); err != nil {
146+ t.Errorf("Set() failed: %v", err)
147+ }
148+ close(done)
149+ }()
150+
151+ select {
152+ case <-done:
153+ // Good, Set returned immediately
154+ case <-time.After(100 * time.Millisecond):
155+ t.Error("Set() blocked for too long")
156+ }
157+
158+ // Verify value is in cache even though persist hasn't completed
159+ val, ok := cache.Get("test")
160+ if !ok || val != 42 {
161+ t.Errorf("Expected test=42 in cache before persist completes, got %v, %v", val, ok)
162+ }
163+
164+ // Now allow persist to complete
165+ close(slowPersist)
166+ if err := cache.Flush(); err != nil {
167+ t.Fatalf("Flush() failed: %v", err)
168+ }
169+
170+ // Verify Persist was called
171+ if count := mock.GetPersistCallCount(); count != 1 {
172+ t.Errorf("Expected Persist to be called once, got %d", count)
173+ }
174+}
175+
176+// TestDelete verifies that Delete immediately removes from cache and persists async
177+func TestDelete(t *testing.T) {
178+ mock := NewMockDatabaseOperations[string, int]()
179+ mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
180+ return map[string]int{"test": 42}, nil
181+ }
182+
183+ cache := NewGenericCache(mock)
184+ if err := cache.Load(context.Background()); err != nil {
185+ t.Fatalf("Load() failed: %v", err)
186+ }
187+
188+ // Verify entry exists
189+ if _, ok := cache.Get("test"); !ok {
190+ t.Fatal("Expected test to exist before Delete")
191+ }
192+
193+ // Delete should return immediately
194+ err := cache.Delete("test")
195+ if err != nil {
196+ t.Fatalf("Delete() failed: %v", err)
197+ }
198+
199+ // Value should be removed from cache immediately
200+ if val, ok := cache.Get("test"); ok {
201+ t.Errorf("Expected test to be deleted immediately, got %v", val)
202+ }
203+
204+ // Wait for async delete to complete
205+ if err := cache.Flush(); err != nil {
206+ t.Fatalf("Flush() failed: %v", err)
207+ }
208+
209+ // Verify Delete was called
210+ if count := mock.GetDeleteCallCount(); count != 1 {
211+ t.Errorf("Expected Delete to be called once, got %d", count)
212+ }
213+
214+ // Verify correct parameters
215+ lastCall := mock.GetLastDeleteCall()
216+ if lastCall == nil {
217+ t.Fatal("Expected Delete to be called")
218+ }
219+ if lastCall.Key != "test" {
220+ t.Errorf("Expected Delete(test), got Delete(%v)", lastCall.Key)
221+ }
222+}
223+
224+// TestList verifies that List returns all cached values
225+func TestList(t *testing.T) {
226+ mock := NewMockDatabaseOperations[string, int]()
227+ mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
228+ return map[string]int{
229+ "one": 1,
230+ "two": 2,
231+ "three": 3,
232+ }, nil
233+ }
234+
235+ cache := NewGenericCache(mock)
236+ if err := cache.Load(context.Background()); err != nil {
237+ t.Fatalf("Load() failed: %v", err)
238+ }
239+
240+ values := cache.List()
241+ if len(values) != 3 {
242+ t.Errorf("Expected 3 values, got %d", len(values))
243+ }
244+
245+ // Verify all values are present (order not guaranteed)
246+ found := make(map[int]bool)
247+ for _, v := range values {
248+ found[v] = true
249+ }
250+
251+ for _, expected := range []int{1, 2, 3} {
252+ if !found[expected] {
253+ t.Errorf("Expected value %d in List(), not found", expected)
254+ }
255+ }
256+}
257+
258+// TestFlush verifies that Flush waits for all pending operations
259+func TestFlush(t *testing.T) {
260+ mock := NewMockDatabaseOperations[string, int]()
261+ persistDelay := make(chan struct{})
262+
263+ mock.PersistFunc = func(ctx context.Context, key string, value int) error {
264+ <-persistDelay // Wait for signal
265+ return nil
266+ }
267+
268+ cache := NewGenericCache(mock)
269+ if err := cache.Load(context.Background()); err != nil {
270+ t.Fatalf("Load() failed: %v", err)
271+ }
272+
273+ // Start multiple async operations
274+ for i := 0; i < 5; i++ {
275+ if err := cache.Set("test", i); err != nil {
276+ t.Fatalf("Set() failed: %v", err)
277+ }
278+ }
279+
280+ // Verify operations haven't completed yet
281+ if count := mock.GetPersistCallCount(); count != 0 {
282+ t.Logf("Note: %d persists completed before signal", count)
283+ }
284+
285+ // Allow all operations to proceed
286+ close(persistDelay)
287+
288+ // Flush should wait for all to complete
289+ if err := cache.Flush(); err != nil {
290+ t.Fatalf("Flush() failed: %v", err)
291+ }
292+
293+ // All persists should have completed
294+ if count := mock.GetPersistCallCount(); count != 5 {
295+ t.Errorf("Expected 5 persists after Flush, got %d", count)
296+ }
297+}
298+
299+// TestConcurrentAccess verifies thread-safety of cache operations
300+func TestConcurrentAccess(t *testing.T) {
301+ mock := NewMockDatabaseOperations[int, int]()
302+
303+ cache := NewGenericCache(mock)
304+ if err := cache.Load(context.Background()); err != nil {
305+ t.Fatalf("Load() failed: %v", err)
306+ }
307+
308+ var wg sync.WaitGroup
309+ numGoroutines := 10
310+ numOperations := 100
311+
312+ // Concurrent writes
313+ for i := 0; i < numGoroutines; i++ {
314+ wg.Add(1)
315+ go func(id int) {
316+ defer wg.Done()
317+ for j := 0; j < numOperations; j++ {
318+ key := id*numOperations + j
319+ if err := cache.Set(key, key*2); err != nil {
320+ t.Errorf("Set() failed: %v", err)
321+ }
322+ }
323+ }(i)
324+ }
325+
326+ // Concurrent reads
327+ for i := 0; i < numGoroutines; i++ {
328+ wg.Add(1)
329+ go func() {
330+ defer wg.Done()
331+ for j := 0; j < numOperations; j++ {
332+ cache.Get(j) // Result doesn't matter, just testing for races
333+ }
334+ }()
335+ }
336+
337+ wg.Wait()
338+ if err := cache.Flush(); err != nil {
339+ t.Fatalf("Flush() failed: %v", err)
340+ }
341+
342+ // Verify expected number of entries
343+ values := cache.List()
344+ expectedEntries := numGoroutines * numOperations
345+ if len(values) != expectedEntries {
346+ t.Errorf("Expected %d entries after concurrent writes, got %d", expectedEntries, len(values))
347+ }
348+
349+ // Verify all Persists were called
350+ if count := mock.GetPersistCallCount(); count != expectedEntries {
351+ t.Errorf("Expected %d Persist calls, got %d", expectedEntries, count)
352+ }
353+}
354+
355+// TestPersistError verifies that persist errors are logged but don't break the cache
356+func TestPersistError(t *testing.T) {
357+ mock := NewMockDatabaseOperations[string, int]()
358+ expectedErr := errors.New("database write failed")
359+
360+ mock.PersistFunc = func(ctx context.Context, key string, value int) error {
361+ return expectedErr
362+ }
363+
364+ cache := NewGenericCache(mock)
365+ if err := cache.Load(context.Background()); err != nil {
366+ t.Fatalf("Load() failed: %v", err)
367+ }
368+
369+ // Set should succeed even if persist fails
370+ err := cache.Set("test", 42)
371+ if err != nil {
372+ t.Errorf("Set() should not return error even if persist fails, got %v", err)
373+ }
374+
375+ // Value should still be in cache
376+ val, ok := cache.Get("test")
377+ if !ok || val != 42 {
378+ t.Errorf("Expected test=42 in cache despite persist error, got %v, %v", val, ok)
379+ }
380+
381+ // Flush should complete (errors are logged, not returned)
382+ if err := cache.Flush(); err != nil {
383+ t.Fatalf("Flush() failed: %v", err)
384+ }
385+
386+ // Verify Persist was called despite error
387+ if count := mock.GetPersistCallCount(); count != 1 {
388+ t.Errorf("Expected Persist to be called once, got %d", count)
389+ }
390+}
391+
392+// TestDeleteError verifies that delete errors are logged but don't break the cache
393+func TestDeleteError(t *testing.T) {
394+ mock := NewMockDatabaseOperations[string, int]()
395+ expectedErr := errors.New("database delete failed")
396+
397+ mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
398+ return map[string]int{"test": 42}, nil
399+ }
400+
401+ mock.DeleteFunc = func(ctx context.Context, key string) error {
402+ return expectedErr
403+ }
404+
405+ cache := NewGenericCache(mock)
406+ if err := cache.Load(context.Background()); err != nil {
407+ t.Fatalf("Load() failed: %v", err)
408+ }
409+
410+ // Delete should succeed even if DB delete fails
411+ err := cache.Delete("test")
412+ if err != nil {
413+ t.Errorf("Delete() should not return error even if DB delete fails, got %v", err)
414+ }
415+
416+ // Value should still be removed from cache
417+ if val, ok := cache.Get("test"); ok {
418+ t.Errorf("Expected test to be deleted from cache despite DB error, got %v", val)
419+ }
420+
421+ if err := cache.Flush(); err != nil {
422+ t.Fatalf("Flush() failed: %v", err)
423+ }
424+
425+ // Verify Delete was called despite error
426+ if count := mock.GetDeleteCallCount(); count != 1 {
427+ t.Errorf("Expected Delete to be called once, got %d", count)
428+ }
429+}
430+
431+// TestMultipleSetsAndDeletes verifies correct handling of multiple operations
432+func TestMultipleSetsAndDeletes(t *testing.T) {
433+ mock := NewMockDatabaseOperations[string, int]()
434+
435+ cache := NewGenericCache(mock)
436+ if err := cache.Load(context.Background()); err != nil {
437+ t.Fatalf("Load() failed: %v", err)
438+ }
439+
440+ // Perform multiple sets
441+ for i := 0; i < 10; i++ {
442+ if err := cache.Set("key", i); err != nil {
443+ t.Fatalf("Set() failed: %v", err)
444+ }
445+ }
446+
447+ // Latest value should be in cache
448+ val, ok := cache.Get("key")
449+ if !ok || val != 9 {
450+ t.Errorf("Expected key=9, got %v, %v", val, ok)
451+ }
452+
453+ // Flush and verify all persists were called
454+ if err := cache.Flush(); err != nil {
455+ t.Fatalf("Flush() failed: %v", err)
456+ }
457+
458+ if count := mock.GetPersistCallCount(); count != 10 {
459+ t.Errorf("Expected 10 Persist calls, got %d", count)
460+ }
461+}
A internal/cache/generic/mock_db_ops.go +137 -0
@@ -0,0 +1,137 @@
1+package generic
2+
3+import (
4+ "context"
5+ "sync"
6+)
7+
8+// MockDatabaseOperations is a mock implementation of DatabaseOperations for testing.
9+// It's generic and thread-safe, suitable for testing GenericCache.
10+type MockDatabaseOperations[K comparable, V any] struct {
11+ mu sync.Mutex
12+
13+ // Behavior configuration
14+ LoadAllFunc func(ctx context.Context) (map[K]V, error)
15+ PersistFunc func(ctx context.Context, key K, value V) error
16+ DeleteFunc func(ctx context.Context, key K) error
17+
18+ // Call tracking
19+ LoadAllCalls []context.Context
20+ PersistCalls []struct {
21+ Ctx context.Context
22+ Key K
23+ Value V
24+ }
25+ DeleteCalls []struct {
26+ Ctx context.Context
27+ Key K
28+ }
29+}
30+
31+// NewMockDatabaseOperations creates a new mock with default no-op implementations.
32+func NewMockDatabaseOperations[K comparable, V any]() *MockDatabaseOperations[K, V] {
33+ return &MockDatabaseOperations[K, V]{
34+ LoadAllFunc: func(ctx context.Context) (map[K]V, error) {
35+ return make(map[K]V), nil
36+ },
37+ PersistFunc: func(ctx context.Context, key K, value V) error {
38+ return nil
39+ },
40+ DeleteFunc: func(ctx context.Context, key K) error {
41+ return nil
42+ },
43+ }
44+}
45+
46+// LoadAll implements DatabaseOperations.LoadAll
47+func (m *MockDatabaseOperations[K, V]) LoadAll(ctx context.Context) (map[K]V, error) {
48+ m.mu.Lock()
49+ m.LoadAllCalls = append(m.LoadAllCalls, ctx)
50+ m.mu.Unlock()
51+
52+ return m.LoadAllFunc(ctx)
53+}
54+
55+// Persist implements DatabaseOperations.Persist
56+func (m *MockDatabaseOperations[K, V]) Persist(ctx context.Context, key K, value V) error {
57+ m.mu.Lock()
58+ m.PersistCalls = append(m.PersistCalls, struct {
59+ Ctx context.Context
60+ Key K
61+ Value V
62+ }{ctx, key, value})
63+ m.mu.Unlock()
64+
65+ return m.PersistFunc(ctx, key, value)
66+}
67+
68+// Delete implements DatabaseOperations.Delete
69+func (m *MockDatabaseOperations[K, V]) Delete(ctx context.Context, key K) error {
70+ m.mu.Lock()
71+ m.DeleteCalls = append(m.DeleteCalls, struct {
72+ Ctx context.Context
73+ Key K
74+ }{ctx, key})
75+ m.mu.Unlock()
76+
77+ return m.DeleteFunc(ctx, key)
78+}
79+
80+// GetLoadAllCallCount returns the number of times LoadAll was called
81+func (m *MockDatabaseOperations[K, V]) GetLoadAllCallCount() int {
82+ m.mu.Lock()
83+ defer m.mu.Unlock()
84+ return len(m.LoadAllCalls)
85+}
86+
87+// GetPersistCallCount returns the number of times Persist was called
88+func (m *MockDatabaseOperations[K, V]) GetPersistCallCount() int {
89+ m.mu.Lock()
90+ defer m.mu.Unlock()
91+ return len(m.PersistCalls)
92+}
93+
94+// GetDeleteCallCount returns the number of times Delete was called
95+func (m *MockDatabaseOperations[K, V]) GetDeleteCallCount() int {
96+ m.mu.Lock()
97+ defer m.mu.Unlock()
98+ return len(m.DeleteCalls)
99+}
100+
101+// GetLastPersistCall returns the last call to Persist, or nil if none
102+func (m *MockDatabaseOperations[K, V]) GetLastPersistCall() *struct {
103+ Ctx context.Context
104+ Key K
105+ Value V
106+} {
107+ m.mu.Lock()
108+ defer m.mu.Unlock()
109+ if len(m.PersistCalls) == 0 {
110+ return nil
111+ }
112+ last := m.PersistCalls[len(m.PersistCalls)-1]
113+ return &last
114+}
115+
116+// GetLastDeleteCall returns the last call to Delete, or nil if none
117+func (m *MockDatabaseOperations[K, V]) GetLastDeleteCall() *struct {
118+ Ctx context.Context
119+ Key K
120+} {
121+ m.mu.Lock()
122+ defer m.mu.Unlock()
123+ if len(m.DeleteCalls) == 0 {
124+ return nil
125+ }
126+ last := m.DeleteCalls[len(m.DeleteCalls)-1]
127+ return &last
128+}
129+
130+// Reset clears all call tracking
131+func (m *MockDatabaseOperations[K, V]) Reset() {
132+ m.mu.Lock()
133+ defer m.mu.Unlock()
134+ m.LoadAllCalls = nil
135+ m.PersistCalls = nil
136+ m.DeleteCalls = nil
137+}
@@ -0,0 +1,137 @@
1+package generic
2+
3+import (
4+ "context"
5+ "sync"
6+)
7+
8+// MockDatabaseOperations is a mock implementation of DatabaseOperations for testing.
9+// It's generic and thread-safe, suitable for testing GenericCache.
10+type MockDatabaseOperations[K comparable, V any] struct {
11+ mu sync.Mutex
12+
13+ // Behavior configuration
14+ LoadAllFunc func(ctx context.Context) (map[K]V, error)
15+ PersistFunc func(ctx context.Context, key K, value V) error
16+ DeleteFunc func(ctx context.Context, key K) error
17+
18+ // Call tracking
19+ LoadAllCalls []context.Context
20+ PersistCalls []struct {
21+ Ctx context.Context
22+ Key K
23+ Value V
24+ }
25+ DeleteCalls []struct {
26+ Ctx context.Context
27+ Key K
28+ }
29+}
30+
31+// NewMockDatabaseOperations creates a new mock with default no-op implementations.
32+func NewMockDatabaseOperations[K comparable, V any]() *MockDatabaseOperations[K, V] {
33+ return &MockDatabaseOperations[K, V]{
34+ LoadAllFunc: func(ctx context.Context) (map[K]V, error) {
35+ return make(map[K]V), nil
36+ },
37+ PersistFunc: func(ctx context.Context, key K, value V) error {
38+ return nil
39+ },
40+ DeleteFunc: func(ctx context.Context, key K) error {
41+ return nil
42+ },
43+ }
44+}
45+
46+// LoadAll implements DatabaseOperations.LoadAll
47+func (m *MockDatabaseOperations[K, V]) LoadAll(ctx context.Context) (map[K]V, error) {
48+ m.mu.Lock()
49+ m.LoadAllCalls = append(m.LoadAllCalls, ctx)
50+ m.mu.Unlock()
51+
52+ return m.LoadAllFunc(ctx)
53+}
54+
55+// Persist implements DatabaseOperations.Persist
56+func (m *MockDatabaseOperations[K, V]) Persist(ctx context.Context, key K, value V) error {
57+ m.mu.Lock()
58+ m.PersistCalls = append(m.PersistCalls, struct {
59+ Ctx context.Context
60+ Key K
61+ Value V
62+ }{ctx, key, value})
63+ m.mu.Unlock()
64+
65+ return m.PersistFunc(ctx, key, value)
66+}
67+
68+// Delete implements DatabaseOperations.Delete
69+func (m *MockDatabaseOperations[K, V]) Delete(ctx context.Context, key K) error {
70+ m.mu.Lock()
71+ m.DeleteCalls = append(m.DeleteCalls, struct {
72+ Ctx context.Context
73+ Key K
74+ }{ctx, key})
75+ m.mu.Unlock()
76+
77+ return m.DeleteFunc(ctx, key)
78+}
79+
80+// GetLoadAllCallCount returns the number of times LoadAll was called
81+func (m *MockDatabaseOperations[K, V]) GetLoadAllCallCount() int {
82+ m.mu.Lock()
83+ defer m.mu.Unlock()
84+ return len(m.LoadAllCalls)
85+}
86+
87+// GetPersistCallCount returns the number of times Persist was called
88+func (m *MockDatabaseOperations[K, V]) GetPersistCallCount() int {
89+ m.mu.Lock()
90+ defer m.mu.Unlock()
91+ return len(m.PersistCalls)
92+}
93+
94+// GetDeleteCallCount returns the number of times Delete was called
95+func (m *MockDatabaseOperations[K, V]) GetDeleteCallCount() int {
96+ m.mu.Lock()
97+ defer m.mu.Unlock()
98+ return len(m.DeleteCalls)
99+}
100+
101+// GetLastPersistCall returns the last call to Persist, or nil if none
102+func (m *MockDatabaseOperations[K, V]) GetLastPersistCall() *struct {
103+ Ctx context.Context
104+ Key K
105+ Value V
106+} {
107+ m.mu.Lock()
108+ defer m.mu.Unlock()
109+ if len(m.PersistCalls) == 0 {
110+ return nil
111+ }
112+ last := m.PersistCalls[len(m.PersistCalls)-1]
113+ return &last
114+}
115+
116+// GetLastDeleteCall returns the last call to Delete, or nil if none
117+func (m *MockDatabaseOperations[K, V]) GetLastDeleteCall() *struct {
118+ Ctx context.Context
119+ Key K
120+} {
121+ m.mu.Lock()
122+ defer m.mu.Unlock()
123+ if len(m.DeleteCalls) == 0 {
124+ return nil
125+ }
126+ last := m.DeleteCalls[len(m.DeleteCalls)-1]
127+ return &last
128+}
129+
130+// Reset clears all call tracking
131+func (m *MockDatabaseOperations[K, V]) Reset() {
132+ m.mu.Lock()
133+ defer m.mu.Unlock()
134+ m.LoadAllCalls = nil
135+ m.PersistCalls = nil
136+ m.DeleteCalls = nil
137+}
M internal/cache/interfaces.go +0 -1
@@ -11,5 +11,4 @@ import (
1111 // HistoryQuerier defines the interface for querying history data.
1212 type HistoryQuerier interface {
1313 GetHistory(ctx context.Context, limit int64) ([]db.History, error)
14- GetShortcuts(ctx context.Context) ([]db.Shortcut, error)
1514 }
@@ -11,5 +11,4 @@ import (
11 // HistoryQuerier defines the interface for querying history data.11 // HistoryQuerier defines the interface for querying history data.
12 type HistoryQuerier interface {12 type HistoryQuerier interface {
13 GetHistory(ctx context.Context, limit int64) ([]db.History, error)13 GetHistory(ctx context.Context, limit int64) ([]db.History, error)
14- GetShortcuts(ctx context.Context) ([]db.Shortcut, error)
15 }14 }
M internal/cache/mocks/mock_querier.go +8 -17
@@ -1,5 +1,10 @@
11 // Code generated by MockGen. DO NOT EDIT.
22 // Source: interfaces.go
3+//
4+// Generated by this command:
5+//
6+// mockgen -source=interfaces.go -destination=mocks/mock_querier.go
7+//
38
49 // Package mock_cache is a generated GoMock package.
510 package mock_cache
@@ -9,13 +14,14 @@ import (
914 reflect "reflect"
1015
1116 db "github.com/bnema/dumber/internal/db"
12- gomock "github.com/golang/mock/gomock"
17+ gomock "go.uber.org/mock/gomock"
1318 )
1419
1520 // MockHistoryQuerier is a mock of HistoryQuerier interface.
1621 type MockHistoryQuerier struct {
1722 ctrl *gomock.Controller
1823 recorder *MockHistoryQuerierMockRecorder
24+ isgomock struct{}
1925 }
2026
2127 // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier.
@@ -45,22 +51,7 @@ func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.
4551 }
4652
4753 // GetHistory indicates an expected call of GetHistory.
48-func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call {
54+func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call {
4955 mr.mock.ctrl.T.Helper()
5056 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit)
5157 }
52-
53-// GetShortcuts mocks base method.
54-func (m *MockHistoryQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) {
55- m.ctrl.T.Helper()
56- ret := m.ctrl.Call(m, "GetShortcuts", ctx)
57- ret0, _ := ret[0].([]db.Shortcut)
58- ret1, _ := ret[1].(error)
59- return ret0, ret1
60-}
61-
62-// GetShortcuts indicates an expected call of GetShortcuts.
63-func (mr *MockHistoryQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call {
64- mr.mock.ctrl.T.Helper()
65- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockHistoryQuerier)(nil).GetShortcuts), ctx)
66-}
@@ -1,5 +1,10 @@
1 // Code generated by MockGen. DO NOT EDIT.1 // Code generated by MockGen. DO NOT EDIT.
2 // Source: interfaces.go2 // Source: interfaces.go
3+//
4+// Generated by this command:
5+//
6+// mockgen -source=interfaces.go -destination=mocks/mock_querier.go
7+//
3 8
4 // Package mock_cache is a generated GoMock package.9 // Package mock_cache is a generated GoMock package.
5 package mock_cache10 package mock_cache
@@ -9,13 +14,14 @@ import (
9 reflect "reflect"14 reflect "reflect"
10 15
11 db "github.com/bnema/dumber/internal/db"16 db "github.com/bnema/dumber/internal/db"
12- gomock "github.com/golang/mock/gomock"17+ gomock "go.uber.org/mock/gomock"
13 )18 )
14 19
15 // MockHistoryQuerier is a mock of HistoryQuerier interface.20 // MockHistoryQuerier is a mock of HistoryQuerier interface.
16 type MockHistoryQuerier struct {21 type MockHistoryQuerier struct {
17 ctrl *gomock.Controller22 ctrl *gomock.Controller
18 recorder *MockHistoryQuerierMockRecorder23 recorder *MockHistoryQuerierMockRecorder
24+ isgomock struct{}
19 }25 }
20 26
21 // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier.27 // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier.
@@ -45,22 +51,7 @@ func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.
45 }51 }
46 52
47 // GetHistory indicates an expected call of GetHistory.53 // GetHistory indicates an expected call of GetHistory.
48-func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call {54+func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call {
49 mr.mock.ctrl.T.Helper()55 mr.mock.ctrl.T.Helper()
50 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit)56 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit)
51 }57 }
52-
53-// GetShortcuts mocks base method.
54-func (m *MockHistoryQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) {
55- m.ctrl.T.Helper()
56- ret := m.ctrl.Call(m, "GetShortcuts", ctx)
57- ret0, _ := ret[0].([]db.Shortcut)
58- ret1, _ := ret[1].(error)
59- return ret0, ret1
60-}
61-
62-// GetShortcuts indicates an expected call of GetShortcuts.
63-func (mr *MockHistoryQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call {
64- mr.mock.ctrl.T.Helper()
65- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockHistoryQuerier)(nil).GetShortcuts), ctx)
66-}
A internal/cache/zoom_cache.go +62 -0
@@ -0,0 +1,62 @@
1+package cache
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/cache/generic"
7+ "github.com/bnema/dumber/internal/db"
8+)
9+
10+// ZoomDBOperations implements DatabaseOperations for zoom level cache.
11+// Handles loading, persisting, and deleting zoom levels from the database.
12+type ZoomDBOperations struct {
13+ queries db.DatabaseQuerier
14+}
15+
16+// NewZoomDBOperations creates a new ZoomDBOperations instance.
17+func NewZoomDBOperations(queries db.DatabaseQuerier) *ZoomDBOperations {
18+ return &ZoomDBOperations{
19+ queries: queries,
20+ }
21+}
22+
23+// LoadAll loads all zoom levels from the database.
24+// Returns a map of domain -> zoom factor.
25+func (z *ZoomDBOperations) LoadAll(ctx context.Context) (map[string]float64, error) {
26+ levels, err := z.queries.ListZoomLevels(ctx)
27+ if err != nil {
28+ return nil, err
29+ }
30+
31+ result := make(map[string]float64, len(levels))
32+ for _, level := range levels {
33+ result[level.Domain] = level.ZoomFactor
34+ }
35+
36+ return result, nil
37+}
38+
39+// Persist saves a zoom level to the database.
40+// Uses UPSERT (INSERT OR UPDATE) to handle both new and existing entries.
41+func (z *ZoomDBOperations) Persist(ctx context.Context, domain string, zoomFactor float64) error {
42+ return z.queries.SetZoomLevel(ctx, domain, zoomFactor)
43+}
44+
45+// Delete removes a zoom level from the database.
46+func (z *ZoomDBOperations) Delete(ctx context.Context, domain string) error {
47+ return z.queries.DeleteZoomLevel(ctx, domain)
48+}
49+
50+// ZoomCache is a specialized cache for zoom levels.
51+// It wraps GenericCache with domain-specific helper methods.
52+type ZoomCache struct {
53+ *generic.GenericCache[string, float64]
54+}
55+
56+// NewZoomCache creates a new zoom level cache.
57+func NewZoomCache(queries db.DatabaseQuerier) *ZoomCache {
58+ dbOps := NewZoomDBOperations(queries)
59+ return &ZoomCache{
60+ GenericCache: generic.NewGenericCache(dbOps),
61+ }
62+}
@@ -0,0 +1,62 @@
1+package cache
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/cache/generic"
7+ "github.com/bnema/dumber/internal/db"
8+)
9+
10+// ZoomDBOperations implements DatabaseOperations for zoom level cache.
11+// Handles loading, persisting, and deleting zoom levels from the database.
12+type ZoomDBOperations struct {
13+ queries db.DatabaseQuerier
14+}
15+
16+// NewZoomDBOperations creates a new ZoomDBOperations instance.
17+func NewZoomDBOperations(queries db.DatabaseQuerier) *ZoomDBOperations {
18+ return &ZoomDBOperations{
19+ queries: queries,
20+ }
21+}
22+
23+// LoadAll loads all zoom levels from the database.
24+// Returns a map of domain -> zoom factor.
25+func (z *ZoomDBOperations) LoadAll(ctx context.Context) (map[string]float64, error) {
26+ levels, err := z.queries.ListZoomLevels(ctx)
27+ if err != nil {
28+ return nil, err
29+ }
30+
31+ result := make(map[string]float64, len(levels))
32+ for _, level := range levels {
33+ result[level.Domain] = level.ZoomFactor
34+ }
35+
36+ return result, nil
37+}
38+
39+// Persist saves a zoom level to the database.
40+// Uses UPSERT (INSERT OR UPDATE) to handle both new and existing entries.
41+func (z *ZoomDBOperations) Persist(ctx context.Context, domain string, zoomFactor float64) error {
42+ return z.queries.SetZoomLevel(ctx, domain, zoomFactor)
43+}
44+
45+// Delete removes a zoom level from the database.
46+func (z *ZoomDBOperations) Delete(ctx context.Context, domain string) error {
47+ return z.queries.DeleteZoomLevel(ctx, domain)
48+}
49+
50+// ZoomCache is a specialized cache for zoom levels.
51+// It wraps GenericCache with domain-specific helper methods.
52+type ZoomCache struct {
53+ *generic.GenericCache[string, float64]
54+}
55+
56+// NewZoomCache creates a new zoom level cache.
57+func NewZoomCache(queries db.DatabaseQuerier) *ZoomCache {
58+ dbOps := NewZoomDBOperations(queries)
59+ return &ZoomCache{
60+ GenericCache: generic.NewGenericCache(dbOps),
61+ }
62+}
M internal/cli/dmenu.go +7 -10
@@ -172,11 +172,8 @@ func generateOptionsFallback(cli *CLI) error {
172172 return fmt.Errorf("failed to get history: %w", err)
173173 }
174174
175- // Get shortcuts
176- shortcuts, err := cli.Queries.GetShortcuts(ctx)
177- if err != nil {
178- return fmt.Errorf("failed to get shortcuts: %w", err)
179- }
175+ // Get shortcuts from configuration (not database)
176+ shortcuts := cli.Config.SearchShortcuts
180177
181178 // Generate options
182179 options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
@@ -190,15 +187,15 @@ func generateOptionsFallback(cli *CLI) error {
190187 })
191188
192189 // Add shortcuts with examples
193- for _, shortcut := range shortcuts {
194- desc := shortcut.Description.String
195- if !shortcut.Description.Valid {
190+ for shortcutKey, shortcut := range shortcuts {
191+ desc := shortcut.Description
192+ if desc == "" {
196193 desc = "Custom shortcut"
197194 }
198195
199196 options = append(options, DmenuOption{
200- Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc),
201- Value: shortcut.Shortcut + ":",
197+ Display: fmt.Sprintf("🔍 %s: (%s)", shortcutKey, desc),
198+ Value: shortcutKey + ":",
202199 Type: "shortcut",
203200 Description: desc,
204201 })
@@ -172,11 +172,8 @@ func generateOptionsFallback(cli *CLI) error {
172 return fmt.Errorf("failed to get history: %w", err)172 return fmt.Errorf("failed to get history: %w", err)
173 }173 }
174 174
175- // Get shortcuts175+ // Get shortcuts from configuration (not database)
176- shortcuts, err := cli.Queries.GetShortcuts(ctx)176+ shortcuts := cli.Config.SearchShortcuts
177- if err != nil {
178- return fmt.Errorf("failed to get shortcuts: %w", err)
179- }
180 177
181 // Generate options178 // Generate options
182 options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)179 options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
@@ -190,15 +187,15 @@ func generateOptionsFallback(cli *CLI) error {
190 })187 })
191 188
192 // Add shortcuts with examples189 // Add shortcuts with examples
193- for _, shortcut := range shortcuts {190+ for shortcutKey, shortcut := range shortcuts {
194- desc := shortcut.Description.String191+ desc := shortcut.Description
195- if !shortcut.Description.Valid {192+ if desc == "" {
196 desc = "Custom shortcut"193 desc = "Custom shortcut"
197 }194 }
198 195
199 options = append(options, DmenuOption{196 options = append(options, DmenuOption{
200- Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc),197+ Display: fmt.Sprintf("🔍 %s: (%s)", shortcutKey, desc),
201- Value: shortcut.Shortcut + ":",198+ Value: shortcutKey + ":",
202 Type: "shortcut",199 Type: "shortcut",
203 Description: desc,200 Description: desc,
204 })201 })
M internal/cli/logs.go +10 -2
@@ -163,7 +163,11 @@ func tailFile(filePath string, lines int) error {
163163 }
164164 return fmt.Errorf("failed to open log file: %w", err)
165165 }
166- defer file.Close()
166+ defer func() {
167+ if err := file.Close(); err != nil {
168+ fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", err)
169+ }
170+ }()
167171
168172 // Read all lines
169173 var allLines []string
@@ -335,7 +339,11 @@ func tailConsoleMessages(filePath string, lines int, level string) error {
335339 }
336340 return fmt.Errorf("failed to open log file: %w", err)
337341 }
338- defer file.Close()
342+ defer func() {
343+ if err := file.Close(); err != nil {
344+ fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", err)
345+ }
346+ }()
339347
340348 // Read all lines and filter for console messages
341349 var consoleLines []string
@@ -163,7 +163,11 @@ func tailFile(filePath string, lines int) error {
163 }163 }
164 return fmt.Errorf("failed to open log file: %w", err)164 return fmt.Errorf("failed to open log file: %w", err)
165 }165 }
166- defer file.Close()166+ defer func() {
167+ if err := file.Close(); err != nil {
168+ fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", err)
169+ }
170+ }()
167 171
168 // Read all lines172 // Read all lines
169 var allLines []string173 var allLines []string
@@ -335,7 +339,11 @@ func tailConsoleMessages(filePath string, lines int, level string) error {
335 }339 }
336 return fmt.Errorf("failed to open log file: %w", err)340 return fmt.Errorf("failed to open log file: %w", err)
337 }341 }
338- defer file.Close()342+ defer func() {
343+ if err := file.Close(); err != nil {
344+ fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", err)
345+ }
346+ }()
339 347
340 // Read all lines and filter for console messages348 // Read all lines and filter for console messages
341 var consoleLines []string349 var consoleLines []string
M internal/cli/purge.go +17 -19
@@ -45,10 +45,13 @@ func NewPurgeCmd() *cobra.Command {
4545 Long: `Purge various dumber data and cache files. By default, purges everything.
4646
4747 Available purge targets:
48- --database, -d Purge the SQLite database (history, shortcuts, zoom levels)
49- --history-cache, -H Purge dmenu fuzzy search cache for history
48+ --database, -d Purge the SQLite database (history, zoom levels, cookies,
49+ certificate validations, and fuzzy cache metadata)
50+ --history-cache, -H Purge dmenu fuzzy search cache for history (binary file, will be
51+ database-backed in the future)
5052 --browser-cache, -c Purge WebKit browser cache (cached images, files, etc.)
51- --browser-data, -b Purge WebKit browser data (cookies, localStorage, sessionStorage)
53+ --browser-data, -b Purge WebKit browser data (localStorage, sessionStorage, IndexedDB)
54+ Note: Cookies are now stored in the main database and deleted via --database
5255 --filter-cache, -F Purge compiled ad blocking filters cache
5356 --state, -s Purge all state data (includes database and caches)
5457 --config Purge configuration files
@@ -56,34 +59,29 @@ Available purge targets:
5659
5760 Use --force to skip the confirmation prompt.
5861
62+Database Consolidation:
63+ Since version with database consolidation, cookies are stored in dumber.sqlite.
64+ Use --database to delete cookies along with history and other browser data.
65+ The --browser-data flag now only affects localStorage and other WebKit-managed data.
66+
5967 Examples:
6068 dumber purge # Purge everything (with confirmation)
6169 dumber purge --force # Purge everything (no confirmation)
6270 dumber purge -d -H -c -F # Purge database and all caches
63- dumber purge --filter-cache -f # Force purge filter cache only`,
71+ dumber purge --filter-cache -f # Force purge filter cache only
72+ dumber purge -d # Purge database (includes cookies)`,
6473 RunE: func(_ *cobra.Command, _ []string) error {
65- // Initialize CLI to get config paths
66- cli, err := NewCLI()
67- if err != nil {
68- return fmt.Errorf("failed to initialize CLI: %w", err)
69- }
70- defer func() {
71- if closeErr := cli.Close(); closeErr != nil {
72- fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
73- }
74- }()
75-
7674 return executePurge(flags)
7775 },
7876 }
7977
8078 // Add flags
81- cmd.Flags().BoolVarP(&flags.Database, "database", "d", false, "Purge the SQLite database")
82- cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge dmenu fuzzy search cache")
79+ cmd.Flags().BoolVarP(&flags.Database, "database", "d", false, "Purge the SQLite database (includes cookies)")
80+ cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge dmenu fuzzy search cache (binary file)")
8381 cmd.Flags().BoolVarP(&flags.BrowserCache, "browser-cache", "c", false, "Purge WebKit browser cache")
84- cmd.Flags().BoolVarP(&flags.BrowserData, "browser-data", "b", false, "Purge WebKit browser data (cookies, localStorage)")
82+ cmd.Flags().BoolVarP(&flags.BrowserData, "browser-data", "b", false, "Purge WebKit browser data (localStorage, sessionStorage)")
8583 cmd.Flags().BoolVarP(&flags.FilterCache, "filter-cache", "F", false, "Purge compiled ad blocking filters cache")
86- cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data")
84+ cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data (database and caches)")
8785 cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files")
8886 cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything")
8987 cmd.Flags().BoolVarP(&flags.Force, "force", "f", false, "Skip confirmation prompt")
@@ -45,10 +45,13 @@ func NewPurgeCmd() *cobra.Command {
45 Long: `Purge various dumber data and cache files. By default, purges everything.45 Long: `Purge various dumber data and cache files. By default, purges everything.
46 46
47 Available purge targets:47 Available purge targets:
48- --database, -d Purge the SQLite database (history, shortcuts, zoom levels)48+ --database, -d Purge the SQLite database (history, zoom levels, cookies,
49- --history-cache, -H Purge dmenu fuzzy search cache for history49+ certificate validations, and fuzzy cache metadata)
50+ --history-cache, -H Purge dmenu fuzzy search cache for history (binary file, will be
51+ database-backed in the future)
50 --browser-cache, -c Purge WebKit browser cache (cached images, files, etc.)52 --browser-cache, -c Purge WebKit browser cache (cached images, files, etc.)
51- --browser-data, -b Purge WebKit browser data (cookies, localStorage, sessionStorage)53+ --browser-data, -b Purge WebKit browser data (localStorage, sessionStorage, IndexedDB)
54+ Note: Cookies are now stored in the main database and deleted via --database
52 --filter-cache, -F Purge compiled ad blocking filters cache55 --filter-cache, -F Purge compiled ad blocking filters cache
53 --state, -s Purge all state data (includes database and caches)56 --state, -s Purge all state data (includes database and caches)
54 --config Purge configuration files57 --config Purge configuration files
@@ -56,34 +59,29 @@ Available purge targets:
56 59
57 Use --force to skip the confirmation prompt.60 Use --force to skip the confirmation prompt.
58 61
62+Database Consolidation:
63+ Since version with database consolidation, cookies are stored in dumber.sqlite.
64+ Use --database to delete cookies along with history and other browser data.
65+ The --browser-data flag now only affects localStorage and other WebKit-managed data.
66+
59 Examples:67 Examples:
60 dumber purge # Purge everything (with confirmation)68 dumber purge # Purge everything (with confirmation)
61 dumber purge --force # Purge everything (no confirmation)69 dumber purge --force # Purge everything (no confirmation)
62 dumber purge -d -H -c -F # Purge database and all caches70 dumber purge -d -H -c -F # Purge database and all caches
63- dumber purge --filter-cache -f # Force purge filter cache only`,71+ dumber purge --filter-cache -f # Force purge filter cache only
72+ dumber purge -d # Purge database (includes cookies)`,
64 RunE: func(_ *cobra.Command, _ []string) error {73 RunE: func(_ *cobra.Command, _ []string) error {
65- // Initialize CLI to get config paths
66- cli, err := NewCLI()
67- if err != nil {
68- return fmt.Errorf("failed to initialize CLI: %w", err)
69- }
70- defer func() {
71- if closeErr := cli.Close(); closeErr != nil {
72- fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
73- }
74- }()
75-
76 return executePurge(flags)74 return executePurge(flags)
77 },75 },
78 }76 }
79 77
80 // Add flags78 // Add flags
81- cmd.Flags().BoolVarP(&flags.Database, "database", "d", false, "Purge the SQLite database")79+ cmd.Flags().BoolVarP(&flags.Database, "database", "d", false, "Purge the SQLite database (includes cookies)")
82- cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge dmenu fuzzy search cache")80+ cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge dmenu fuzzy search cache (binary file)")
83 cmd.Flags().BoolVarP(&flags.BrowserCache, "browser-cache", "c", false, "Purge WebKit browser cache")81 cmd.Flags().BoolVarP(&flags.BrowserCache, "browser-cache", "c", false, "Purge WebKit browser cache")
84- cmd.Flags().BoolVarP(&flags.BrowserData, "browser-data", "b", false, "Purge WebKit browser data (cookies, localStorage)")82+ cmd.Flags().BoolVarP(&flags.BrowserData, "browser-data", "b", false, "Purge WebKit browser data (localStorage, sessionStorage)")
85 cmd.Flags().BoolVarP(&flags.FilterCache, "filter-cache", "F", false, "Purge compiled ad blocking filters cache")83 cmd.Flags().BoolVarP(&flags.FilterCache, "filter-cache", "F", false, "Purge compiled ad blocking filters cache")
86- cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data")84+ cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data (database and caches)")
87 cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files")85 cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files")
88 cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything")86 cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything")
89 cmd.Flags().BoolVarP(&flags.Force, "force", "f", false, "Skip confirmation prompt")87 cmd.Flags().BoolVarP(&flags.Force, "force", "f", false, "Skip confirmation prompt")
M internal/cli/root.go +0 -25
@@ -65,14 +65,6 @@ func NewCLI() (*CLI, error) {
6565 return nil, fmt.Errorf("failed to connect to database: %w", err)
6666 }
6767
68- // Initialize database schema with configuration-based shortcuts
69- if err := initializeDatabase(database, cfg); err != nil {
70- if err := database.Close(); err != nil {
71- log.Printf("Warning: failed to close database: %v", err)
72- }
73- return nil, fmt.Errorf("failed to initialize database: %w", err)
74- }
75-
7668 queries := db.New(database)
7769 parserService := services.NewParserService(cfg, queries)
7870
@@ -166,20 +158,3 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
166158
167159 return rootCmd
168160 }
169-
170-// initializeDatabase ensures configured shortcuts are inserted
171-// Migrations are run automatically in db.InitDB()
172-func initializeDatabase(database *sql.DB, cfg *config.Config) error {
173- // Insert configured shortcuts (these are additive, won't override existing)
174- for shortcut, shortcutCfg := range cfg.SearchShortcuts {
175- _, err := database.Exec(
176- "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
177- shortcut, shortcutCfg.URL, shortcutCfg.Description,
178- )
179- if err != nil {
180- return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err)
181- }
182- }
183-
184- return nil
185-}
@@ -65,14 +65,6 @@ func NewCLI() (*CLI, error) {
65 return nil, fmt.Errorf("failed to connect to database: %w", err)65 return nil, fmt.Errorf("failed to connect to database: %w", err)
66 }66 }
67 67
68- // Initialize database schema with configuration-based shortcuts
69- if err := initializeDatabase(database, cfg); err != nil {
70- if err := database.Close(); err != nil {
71- log.Printf("Warning: failed to close database: %v", err)
72- }
73- return nil, fmt.Errorf("failed to initialize database: %w", err)
74- }
75-
76 queries := db.New(database)68 queries := db.New(database)
77 parserService := services.NewParserService(cfg, queries)69 parserService := services.NewParserService(cfg, queries)
78 70
@@ -166,20 +158,3 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
166 158
167 return rootCmd159 return rootCmd
168 }160 }
169-
170-// initializeDatabase ensures configured shortcuts are inserted
171-// Migrations are run automatically in db.InitDB()
172-func initializeDatabase(database *sql.DB, cfg *config.Config) error {
173- // Insert configured shortcuts (these are additive, won't override existing)
174- for shortcut, shortcutCfg := range cfg.SearchShortcuts {
175- _, err := database.Exec(
176- "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
177- shortcut, shortcutCfg.URL, shortcutCfg.Description,
178- )
179- if err != nil {
180- return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err)
181- }
182- }
183-
184- return nil
185-}
M internal/config/config.go +14 -94
@@ -8,7 +8,6 @@ import (
88 "path/filepath"
99 "strings"
1010 "sync"
11- "time"
1211
1312 "github.com/bnema/dumber/internal/logging"
1413 "github.com/bnema/dumber/pkg/gpu"
@@ -32,9 +31,7 @@ type Config struct {
3231 Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
3332 VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
3433 CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"`
35- WebkitMemory WebkitMemoryConfig `mapstructure:"webkit_memory" yaml:"webkit_memory"`
3634 Debug DebugConfig `mapstructure:"debug" yaml:"debug"`
37- APISecurity APISecurityConfig `mapstructure:"api_security" yaml:"api_security"`
3835 // RenderingMode controls GPU/CPU rendering selection for WebKit
3936 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
4037 // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
@@ -58,10 +55,7 @@ const (
5855
5956 // DatabaseConfig holds database-related configuration.
6057 type DatabaseConfig struct {
61- Path string `mapstructure:"path" yaml:"path"`
62- MaxConnections int `mapstructure:"max_connections" yaml:"max_connections"`
63- MaxIdleTime time.Duration `mapstructure:"max_idle_time" yaml:"max_idle_time"`
64- QueryTimeout time.Duration `mapstructure:"query_timeout" yaml:"query_timeout"`
58+ Path string `mapstructure:"path" yaml:"path"`
6559 }
6660
6761 // HistoryConfig holds history-related configuration.
@@ -178,35 +172,6 @@ type CodecConfig struct {
178172 DisableTwitchCodecControl bool `mapstructure:"disable_twitch_codec_control" yaml:"disable_twitch_codec_control"`
179173 }
180174
181-// WebkitMemoryConfig holds WebKit memory optimization settings
182-type WebkitMemoryConfig struct {
183- // Cache model (document_viewer | web_browser | primary_web_browser)
184- CacheModel string `mapstructure:"cache_model" yaml:"cache_model"`
185-
186- // Enable page cache for back/forward navigation
187- EnablePageCache bool `mapstructure:"enable_page_cache" yaml:"enable_page_cache"`
188-
189- // Memory limit in MB (0 = system default)
190- MemoryLimitMB int `mapstructure:"memory_limit_mb" yaml:"memory_limit_mb"`
191-
192- // Memory pressure thresholds (0.0-1.0)
193- ConservativeThreshold float64 `mapstructure:"conservative_threshold" yaml:"conservative_threshold"`
194- StrictThreshold float64 `mapstructure:"strict_threshold" yaml:"strict_threshold"`
195- KillThreshold float64 `mapstructure:"kill_threshold" yaml:"kill_threshold"`
196-
197- // Memory monitoring interval in seconds
198- PollIntervalSeconds float64 `mapstructure:"poll_interval_seconds" yaml:"poll_interval_seconds"`
199-
200- // Garbage collection interval in seconds (0 = disabled)
201- EnableGCInterval int `mapstructure:"enable_gc_interval" yaml:"enable_gc_interval"`
202-
203- // Process recycling threshold (number of page loads)
204- ProcessRecycleThreshold int `mapstructure:"process_recycle_threshold" yaml:"process_recycle_threshold"`
205-
206- // Enable memory monitoring logs
207- EnableMemoryMonitoring bool `mapstructure:"enable_memory_monitoring" yaml:"enable_memory_monitoring"`
208-}
209-
210175 // DebugConfig holds debug and troubleshooting options
211176 type DebugConfig struct {
212177 // Enable WebKit internal debug logging
@@ -247,14 +212,6 @@ type DebugConfig struct {
247212 EnablePaneCloseDebug bool `mapstructure:"enable_pane_close_debug" yaml:"enable_pane_close_debug"`
248213 }
249214
250-// APISecurityConfig holds optional API key protection for dumb://api endpoints
251-type APISecurityConfig struct {
252- // If non-empty, token that requests must present via `token` query param
253- Token string `mapstructure:"token" yaml:"token"`
254- // If true, require token for all API endpoints (except a minimal allowlist)
255- RequireToken bool `mapstructure:"require_token" yaml:"require_token"`
256-}
257-
258215 // WorkspaceConfig captures layout, pane, and tab behaviour preferences.
259216 type WorkspaceConfig struct {
260217 // EnableZellijControls toggles Zellij-inspired keybindings.
@@ -371,12 +328,6 @@ func NewManager() (*Manager, error) {
371328 // Bind specific environment variables
372329 bindings := map[string]string{
373330 "database.path": "DATABASE_PATH",
374- "database.max_connections": "DATABASE_MAX_CONNECTIONS",
375- "database.max_idle_time": "DATABASE_MAX_IDLE_TIME",
376- "database.query_timeout": "DATABASE_QUERY_TIMEOUT",
377- "browser.command": "BROWSER_COMMAND",
378- "browser.timeout": "BROWSER_TIMEOUT",
379- "browser.detach_process": "BROWSER_DETACH_PROCESS",
380331 "history.max_entries": "HISTORY_MAX_ENTRIES",
381332 "history.retention_period": "HISTORY_RETENTION_PERIOD",
382333 "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL",
@@ -395,9 +346,6 @@ func NewManager() (*Manager, error) {
395346 "logging.max_backups": "LOGGING_MAX_BACKUPS",
396347 "logging.max_age": "LOGGING_MAX_AGE",
397348 "logging.compress": "LOGGING_COMPRESS",
398- // API security
399- "api_security.token": "API_TOKEN",
400- "api_security.require_token": "API_REQUIRE_TOKEN",
401349 }
402350
403351 for key, env := range bindings {
@@ -453,26 +401,6 @@ func NewManager() (*Manager, error) {
453401 }
454402 }
455403
456- // WebKit memory environment variable bindings
457- memoryEnvBindings := map[string]string{
458- "webkit_memory.cache_model": "DUMBER_CACHE_MODEL",
459- "webkit_memory.enable_page_cache": "DUMBER_ENABLE_PAGE_CACHE",
460- "webkit_memory.memory_limit_mb": "DUMBER_MEMORY_LIMIT_MB",
461- "webkit_memory.conservative_threshold": "DUMBER_MEMORY_CONSERVATIVE",
462- "webkit_memory.strict_threshold": "DUMBER_MEMORY_STRICT",
463- "webkit_memory.kill_threshold": "DUMBER_MEMORY_KILL",
464- "webkit_memory.poll_interval_seconds": "DUMBER_MEMORY_POLL_INTERVAL",
465- "webkit_memory.enable_gc_interval": "DUMBER_GC_INTERVAL",
466- "webkit_memory.process_recycle_threshold": "DUMBER_RECYCLE_THRESHOLD",
467- "webkit_memory.enable_memory_monitoring": "DUMBER_ENABLE_MEMORY_MONITORING",
468- }
469-
470- for key, env := range memoryEnvBindings {
471- if err := v.BindEnv(key, env); err != nil {
472- return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
473- }
474- }
475-
476404 return &Manager{
477405 viper: v,
478406 callbacks: make([]func(*Config), 0),
@@ -659,10 +587,7 @@ func (m *Manager) reload() error {
659587 func (m *Manager) setDefaults() {
660588 defaults := DefaultConfig()
661589
662- // Database defaults
663- m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections)
664- m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime)
665- m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout)
590+ // Note: Database.Path is set dynamically in Load(), no defaults needed
666591
667592 // Note: Browser config removed - we build our own browser
668593
@@ -743,18 +668,6 @@ func (m *Manager) setDefaults() {
743668 m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution)
744669 m.viper.SetDefault("codec_preferences.disable_twitch_codec_control", defaults.CodecPreferences.DisableTwitchCodecControl)
745670
746- // WebKit memory defaults
747- m.viper.SetDefault("webkit_memory.cache_model", defaults.WebkitMemory.CacheModel)
748- m.viper.SetDefault("webkit_memory.enable_page_cache", defaults.WebkitMemory.EnablePageCache)
749- m.viper.SetDefault("webkit_memory.memory_limit_mb", defaults.WebkitMemory.MemoryLimitMB)
750- m.viper.SetDefault("webkit_memory.conservative_threshold", defaults.WebkitMemory.ConservativeThreshold)
751- m.viper.SetDefault("webkit_memory.strict_threshold", defaults.WebkitMemory.StrictThreshold)
752- m.viper.SetDefault("webkit_memory.kill_threshold", defaults.WebkitMemory.KillThreshold)
753- m.viper.SetDefault("webkit_memory.poll_interval_seconds", defaults.WebkitMemory.PollIntervalSeconds)
754- m.viper.SetDefault("webkit_memory.enable_gc_interval", defaults.WebkitMemory.EnableGCInterval)
755- m.viper.SetDefault("webkit_memory.process_recycle_threshold", defaults.WebkitMemory.ProcessRecycleThreshold)
756- m.viper.SetDefault("webkit_memory.enable_memory_monitoring", defaults.WebkitMemory.EnableMemoryMonitoring)
757-
758671 // Rendering defaults
759672 m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
760673 m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
@@ -802,6 +715,13 @@ func (m *Manager) createDefaultConfig() error {
802715 }
803716
804717 fmt.Printf("Created default configuration file: %s\n", configFile)
718+
719+ // Generate JSON schema file
720+ if err := GenerateSchemaFile(); err != nil {
721+ // Log error but don't fail config creation
722+ fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err)
723+ }
724+
805725 return nil
806726 }
807727
@@ -907,17 +827,17 @@ func (m *Manager) validateAndConfigureCodecPreferences(config *Config) *Config {
907827 // validateColorScheme validates and normalizes the ColorScheme setting
908828 func (m *Manager) validateColorScheme(config *Config) {
909829 switch config.Appearance.ColorScheme {
910- case "prefer-dark", "prefer-light", "default", "":
830+ case "prefer-dark", "prefer-light", ThemeDefault, "":
911831 // Valid values - no changes needed
912832 // Empty string is treated the same as "default"
913833 if config.Appearance.ColorScheme == "" {
914- config.Appearance.ColorScheme = "default"
915- logging.Info("Config: ColorScheme not set, defaulting to 'default' (follows system)")
834+ config.Appearance.ColorScheme = ThemeDefault
835+ logging.Info("Config: ColorScheme not set, defaulting to '" + ThemeDefault + "' (follows system)")
916836 }
917837 default:
918838 // Invalid value - warn and reset to default
919- logging.Info(fmt.Sprintf("Config: Invalid color_scheme value '%s', valid values are: 'prefer-dark', 'prefer-light', 'default'. Resetting to 'default'",
839+ logging.Info(fmt.Sprintf("Config: Invalid color_scheme value '%s', valid values are: 'prefer-dark', 'prefer-light', '"+ThemeDefault+"'. Resetting to '"+ThemeDefault+"'",
920840 config.Appearance.ColorScheme))
921- config.Appearance.ColorScheme = "default"
841+ config.Appearance.ColorScheme = ThemeDefault
922842 }
923843 }
@@ -8,7 +8,6 @@ import (
8 "path/filepath"8 "path/filepath"
9 "strings"9 "strings"
10 "sync"10 "sync"
11- "time"
12 11
13 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/dumber/pkg/gpu"13 "github.com/bnema/dumber/pkg/gpu"
@@ -32,9 +31,7 @@ type Config struct {
32 Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`31 Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
33 VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`32 VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
34 CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"`33 CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"`
35- WebkitMemory WebkitMemoryConfig `mapstructure:"webkit_memory" yaml:"webkit_memory"`
36 Debug DebugConfig `mapstructure:"debug" yaml:"debug"`34 Debug DebugConfig `mapstructure:"debug" yaml:"debug"`
37- APISecurity APISecurityConfig `mapstructure:"api_security" yaml:"api_security"`
38 // RenderingMode controls GPU/CPU rendering selection for WebKit35 // RenderingMode controls GPU/CPU rendering selection for WebKit
39 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`36 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
40 // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.37 // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
@@ -58,10 +55,7 @@ const (
58 55
59 // DatabaseConfig holds database-related configuration.56 // DatabaseConfig holds database-related configuration.
60 type DatabaseConfig struct {57 type DatabaseConfig struct {
61- Path string `mapstructure:"path" yaml:"path"`58+ Path string `mapstructure:"path" yaml:"path"`
62- MaxConnections int `mapstructure:"max_connections" yaml:"max_connections"`
63- MaxIdleTime time.Duration `mapstructure:"max_idle_time" yaml:"max_idle_time"`
64- QueryTimeout time.Duration `mapstructure:"query_timeout" yaml:"query_timeout"`
65 }59 }
66 60
67 // HistoryConfig holds history-related configuration.61 // HistoryConfig holds history-related configuration.
@@ -178,35 +172,6 @@ type CodecConfig struct {
178 DisableTwitchCodecControl bool `mapstructure:"disable_twitch_codec_control" yaml:"disable_twitch_codec_control"`172 DisableTwitchCodecControl bool `mapstructure:"disable_twitch_codec_control" yaml:"disable_twitch_codec_control"`
179 }173 }
180 174
181-// WebkitMemoryConfig holds WebKit memory optimization settings
182-type WebkitMemoryConfig struct {
183- // Cache model (document_viewer | web_browser | primary_web_browser)
184- CacheModel string `mapstructure:"cache_model" yaml:"cache_model"`
185-
186- // Enable page cache for back/forward navigation
187- EnablePageCache bool `mapstructure:"enable_page_cache" yaml:"enable_page_cache"`
188-
189- // Memory limit in MB (0 = system default)
190- MemoryLimitMB int `mapstructure:"memory_limit_mb" yaml:"memory_limit_mb"`
191-
192- // Memory pressure thresholds (0.0-1.0)
193- ConservativeThreshold float64 `mapstructure:"conservative_threshold" yaml:"conservative_threshold"`
194- StrictThreshold float64 `mapstructure:"strict_threshold" yaml:"strict_threshold"`
195- KillThreshold float64 `mapstructure:"kill_threshold" yaml:"kill_threshold"`
196-
197- // Memory monitoring interval in seconds
198- PollIntervalSeconds float64 `mapstructure:"poll_interval_seconds" yaml:"poll_interval_seconds"`
199-
200- // Garbage collection interval in seconds (0 = disabled)
201- EnableGCInterval int `mapstructure:"enable_gc_interval" yaml:"enable_gc_interval"`
202-
203- // Process recycling threshold (number of page loads)
204- ProcessRecycleThreshold int `mapstructure:"process_recycle_threshold" yaml:"process_recycle_threshold"`
205-
206- // Enable memory monitoring logs
207- EnableMemoryMonitoring bool `mapstructure:"enable_memory_monitoring" yaml:"enable_memory_monitoring"`
208-}
209-
210 // DebugConfig holds debug and troubleshooting options175 // DebugConfig holds debug and troubleshooting options
211 type DebugConfig struct {176 type DebugConfig struct {
212 // Enable WebKit internal debug logging177 // Enable WebKit internal debug logging
@@ -247,14 +212,6 @@ type DebugConfig struct {
247 EnablePaneCloseDebug bool `mapstructure:"enable_pane_close_debug" yaml:"enable_pane_close_debug"`212 EnablePaneCloseDebug bool `mapstructure:"enable_pane_close_debug" yaml:"enable_pane_close_debug"`
248 }213 }
249 214
250-// APISecurityConfig holds optional API key protection for dumb://api endpoints
251-type APISecurityConfig struct {
252- // If non-empty, token that requests must present via `token` query param
253- Token string `mapstructure:"token" yaml:"token"`
254- // If true, require token for all API endpoints (except a minimal allowlist)
255- RequireToken bool `mapstructure:"require_token" yaml:"require_token"`
256-}
257-
258 // WorkspaceConfig captures layout, pane, and tab behaviour preferences.215 // WorkspaceConfig captures layout, pane, and tab behaviour preferences.
259 type WorkspaceConfig struct {216 type WorkspaceConfig struct {
260 // EnableZellijControls toggles Zellij-inspired keybindings.217 // EnableZellijControls toggles Zellij-inspired keybindings.
@@ -371,12 +328,6 @@ func NewManager() (*Manager, error) {
371 // Bind specific environment variables328 // Bind specific environment variables
372 bindings := map[string]string{329 bindings := map[string]string{
373 "database.path": "DATABASE_PATH",330 "database.path": "DATABASE_PATH",
374- "database.max_connections": "DATABASE_MAX_CONNECTIONS",
375- "database.max_idle_time": "DATABASE_MAX_IDLE_TIME",
376- "database.query_timeout": "DATABASE_QUERY_TIMEOUT",
377- "browser.command": "BROWSER_COMMAND",
378- "browser.timeout": "BROWSER_TIMEOUT",
379- "browser.detach_process": "BROWSER_DETACH_PROCESS",
380 "history.max_entries": "HISTORY_MAX_ENTRIES",331 "history.max_entries": "HISTORY_MAX_ENTRIES",
381 "history.retention_period": "HISTORY_RETENTION_PERIOD",332 "history.retention_period": "HISTORY_RETENTION_PERIOD",
382 "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL",333 "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL",
@@ -395,9 +346,6 @@ func NewManager() (*Manager, error) {
395 "logging.max_backups": "LOGGING_MAX_BACKUPS",346 "logging.max_backups": "LOGGING_MAX_BACKUPS",
396 "logging.max_age": "LOGGING_MAX_AGE",347 "logging.max_age": "LOGGING_MAX_AGE",
397 "logging.compress": "LOGGING_COMPRESS",348 "logging.compress": "LOGGING_COMPRESS",
398- // API security
399- "api_security.token": "API_TOKEN",
400- "api_security.require_token": "API_REQUIRE_TOKEN",
401 }349 }
402 350
403 for key, env := range bindings {351 for key, env := range bindings {
@@ -453,26 +401,6 @@ func NewManager() (*Manager, error) {
453 }401 }
454 }402 }
455 403
456- // WebKit memory environment variable bindings
457- memoryEnvBindings := map[string]string{
458- "webkit_memory.cache_model": "DUMBER_CACHE_MODEL",
459- "webkit_memory.enable_page_cache": "DUMBER_ENABLE_PAGE_CACHE",
460- "webkit_memory.memory_limit_mb": "DUMBER_MEMORY_LIMIT_MB",
461- "webkit_memory.conservative_threshold": "DUMBER_MEMORY_CONSERVATIVE",
462- "webkit_memory.strict_threshold": "DUMBER_MEMORY_STRICT",
463- "webkit_memory.kill_threshold": "DUMBER_MEMORY_KILL",
464- "webkit_memory.poll_interval_seconds": "DUMBER_MEMORY_POLL_INTERVAL",
465- "webkit_memory.enable_gc_interval": "DUMBER_GC_INTERVAL",
466- "webkit_memory.process_recycle_threshold": "DUMBER_RECYCLE_THRESHOLD",
467- "webkit_memory.enable_memory_monitoring": "DUMBER_ENABLE_MEMORY_MONITORING",
468- }
469-
470- for key, env := range memoryEnvBindings {
471- if err := v.BindEnv(key, env); err != nil {
472- return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
473- }
474- }
475-
476 return &Manager{404 return &Manager{
477 viper: v,405 viper: v,
478 callbacks: make([]func(*Config), 0),406 callbacks: make([]func(*Config), 0),
@@ -659,10 +587,7 @@ func (m *Manager) reload() error {
659 func (m *Manager) setDefaults() {587 func (m *Manager) setDefaults() {
660 defaults := DefaultConfig()588 defaults := DefaultConfig()
661 589
662- // Database defaults590+ // Note: Database.Path is set dynamically in Load(), no defaults needed
663- m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections)
664- m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime)
665- m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout)
666 591
667 // Note: Browser config removed - we build our own browser592 // Note: Browser config removed - we build our own browser
668 593
@@ -743,18 +668,6 @@ func (m *Manager) setDefaults() {
743 m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution)668 m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution)
744 m.viper.SetDefault("codec_preferences.disable_twitch_codec_control", defaults.CodecPreferences.DisableTwitchCodecControl)669 m.viper.SetDefault("codec_preferences.disable_twitch_codec_control", defaults.CodecPreferences.DisableTwitchCodecControl)
745 670
746- // WebKit memory defaults
747- m.viper.SetDefault("webkit_memory.cache_model", defaults.WebkitMemory.CacheModel)
748- m.viper.SetDefault("webkit_memory.enable_page_cache", defaults.WebkitMemory.EnablePageCache)
749- m.viper.SetDefault("webkit_memory.memory_limit_mb", defaults.WebkitMemory.MemoryLimitMB)
750- m.viper.SetDefault("webkit_memory.conservative_threshold", defaults.WebkitMemory.ConservativeThreshold)
751- m.viper.SetDefault("webkit_memory.strict_threshold", defaults.WebkitMemory.StrictThreshold)
752- m.viper.SetDefault("webkit_memory.kill_threshold", defaults.WebkitMemory.KillThreshold)
753- m.viper.SetDefault("webkit_memory.poll_interval_seconds", defaults.WebkitMemory.PollIntervalSeconds)
754- m.viper.SetDefault("webkit_memory.enable_gc_interval", defaults.WebkitMemory.EnableGCInterval)
755- m.viper.SetDefault("webkit_memory.process_recycle_threshold", defaults.WebkitMemory.ProcessRecycleThreshold)
756- m.viper.SetDefault("webkit_memory.enable_memory_monitoring", defaults.WebkitMemory.EnableMemoryMonitoring)
757-
758 // Rendering defaults671 // Rendering defaults
759 m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))672 m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
760 m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)673 m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
@@ -802,6 +715,13 @@ func (m *Manager) createDefaultConfig() error {
802 }715 }
803 716
804 fmt.Printf("Created default configuration file: %s\n", configFile)717 fmt.Printf("Created default configuration file: %s\n", configFile)
718+
719+ // Generate JSON schema file
720+ if err := GenerateSchemaFile(); err != nil {
721+ // Log error but don't fail config creation
722+ fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err)
723+ }
724+
805 return nil725 return nil
806 }726 }
807 727
@@ -907,17 +827,17 @@ func (m *Manager) validateAndConfigureCodecPreferences(config *Config) *Config {
907 // validateColorScheme validates and normalizes the ColorScheme setting827 // validateColorScheme validates and normalizes the ColorScheme setting
908 func (m *Manager) validateColorScheme(config *Config) {828 func (m *Manager) validateColorScheme(config *Config) {
909 switch config.Appearance.ColorScheme {829 switch config.Appearance.ColorScheme {
910- case "prefer-dark", "prefer-light", "default", "":830+ case "prefer-dark", "prefer-light", ThemeDefault, "":
911 // Valid values - no changes needed831 // Valid values - no changes needed
912 // Empty string is treated the same as "default"832 // Empty string is treated the same as "default"
913 if config.Appearance.ColorScheme == "" {833 if config.Appearance.ColorScheme == "" {
914- config.Appearance.ColorScheme = "default"834+ config.Appearance.ColorScheme = ThemeDefault
915- logging.Info("Config: ColorScheme not set, defaulting to 'default' (follows system)")835+ logging.Info("Config: ColorScheme not set, defaulting to '" + ThemeDefault + "' (follows system)")
916 }836 }
917 default:837 default:
918 // Invalid value - warn and reset to default838 // Invalid value - warn and reset to default
919- logging.Info(fmt.Sprintf("Config: Invalid color_scheme value '%s', valid values are: 'prefer-dark', 'prefer-light', 'default'. Resetting to 'default'",839+ logging.Info(fmt.Sprintf("Config: Invalid color_scheme value '%s', valid values are: 'prefer-dark', 'prefer-light', '"+ThemeDefault+"'. Resetting to '"+ThemeDefault+"'",
920 config.Appearance.ColorScheme))840 config.Appearance.ColorScheme))
921- config.Appearance.ColorScheme = "default"841+ config.Appearance.ColorScheme = ThemeDefault
922 }842 }
923 }843 }
A internal/config/constants.go +6 -0
@@ -0,0 +1,6 @@
1+package config
2+
3+// Color scheme constants
4+const (
5+ ThemeDefault = "default"
6+)
@@ -0,0 +1,6 @@
1+package config
2+
3+// Color scheme constants
4+const (
5+ ThemeDefault = "default"
6+)
M internal/config/defaults.go +8 -34
@@ -1,16 +1,8 @@
11 // Package config provides default configuration values for dumber.
22 package config
33
4-import (
5- "time"
6-)
7-
84 // Default configuration constants
95 const (
10- // Database defaults
11- defaultMaxIdleTimeMin = 5 // minutes
12- defaultQueryTimeoutSec = 30 // seconds
13-
146 // History defaults
157 defaultMaxHistoryEntries = 10000 // entries
168 defaultRetentionDays = 365 // 1 year
@@ -27,7 +19,7 @@ const (
2719 defaultFontSize = 16 // points
2820
2921 // Workspace defaults
30- defaultPaneActivationShortcut = "cmdorctrl+p"
22+ defaultPaneActivationShortcut = "ctrl+p"
3123 defaultPaneTimeoutMilliseconds = 3000
3224 defaultPopupPlacement = "right"
3325
@@ -52,9 +44,7 @@ func getDefaultLogDir() string {
5244 func DefaultConfig() *Config {
5345 return &Config{
5446 Database: DatabaseConfig{
55- MaxConnections: 1,
56- MaxIdleTime: time.Minute * defaultMaxIdleTimeMin,
57- QueryTimeout: time.Second * defaultQueryTimeoutSec,
47+ // Path is set dynamically in config.Load()
5848 },
5949 History: HistoryConfig{
6050 MaxEntries: defaultMaxHistoryEntries,
@@ -131,20 +121,8 @@ func DefaultConfig() *Config {
131121 VideoBufferSizeMB: 16, // Larger buffer for AV1 streams
132122 QueueBufferTimeSec: 10, // More buffering time for smooth playback
133123 CustomUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", // Chrome UA with AV1 support
134- AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res
135- DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing)
136- },
137- WebkitMemory: WebkitMemoryConfig{
138- CacheModel: "web_browser", // Aggressive caching for fast page loads
139- EnablePageCache: true, // Instant back/forward navigation
140- MemoryLimitMB: 0, // Use system default
141- ConservativeThreshold: 0.4, // Start cleanup at 40%
142- StrictThreshold: 0.6, // Strict cleanup at 60%
143- KillThreshold: 0.8, // Kill processes at 80%
144- PollIntervalSeconds: 45.0, // Check every 45 seconds
145- EnableGCInterval: 120, // GC every 2 minutes
146- ProcessRecycleThreshold: 50, // Recycle after 50 page loads
147- EnableMemoryMonitoring: true, // Monitor for production tuning
124+ AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res
125+ DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing)
148126 },
149127 Debug: DebugConfig{
150128 EnableWebKitDebug: false,
@@ -160,10 +138,6 @@ func DefaultConfig() *Config {
160138 EnableFocusMetrics: false,
161139 EnablePaneCloseDebug: false,
162140 },
163- APISecurity: APISecurityConfig{
164- Token: "",
165- RequireToken: false,
166- },
167141 RenderingMode: RenderingModeGPU,
168142 UseDomZoom: false,
169143 DefaultZoom: 1.2, // 120% default zoom for better readability
@@ -187,10 +161,10 @@ func DefaultConfig() *Config {
187161 },
188162 },
189163 Tabs: TabKeyConfig{
190- NewTab: "cmdorctrl+t",
191- CloseTab: "cmdorctrl+w",
192- NextTab: "cmdorctrl+tab",
193- PreviousTab: "cmdorctrl+shift+tab",
164+ NewTab: "ctrl+t",
165+ CloseTab: "ctrl+w",
166+ NextTab: "ctrl+tab",
167+ PreviousTab: "ctrl+shift+tab",
194168 },
195169 Popups: PopupBehaviorConfig{
196170 Behavior: PopupBehaviorSplit, // Default: open popups in split panes
@@ -1,16 +1,8 @@
1 // Package config provides default configuration values for dumber.1 // Package config provides default configuration values for dumber.
2 package config2 package config
3 3
4-import (
5- "time"
6-)
7-
8 // Default configuration constants4 // Default configuration constants
9 const (5 const (
10- // Database defaults
11- defaultMaxIdleTimeMin = 5 // minutes
12- defaultQueryTimeoutSec = 30 // seconds
13-
14 // History defaults6 // History defaults
15 defaultMaxHistoryEntries = 10000 // entries7 defaultMaxHistoryEntries = 10000 // entries
16 defaultRetentionDays = 365 // 1 year8 defaultRetentionDays = 365 // 1 year
@@ -27,7 +19,7 @@ const (
27 defaultFontSize = 16 // points19 defaultFontSize = 16 // points
28 20
29 // Workspace defaults21 // Workspace defaults
30- defaultPaneActivationShortcut = "cmdorctrl+p"22+ defaultPaneActivationShortcut = "ctrl+p"
31 defaultPaneTimeoutMilliseconds = 300023 defaultPaneTimeoutMilliseconds = 3000
32 defaultPopupPlacement = "right"24 defaultPopupPlacement = "right"
33 25
@@ -52,9 +44,7 @@ func getDefaultLogDir() string {
52 func DefaultConfig() *Config {44 func DefaultConfig() *Config {
53 return &Config{45 return &Config{
54 Database: DatabaseConfig{46 Database: DatabaseConfig{
55- MaxConnections: 1,47+ // Path is set dynamically in config.Load()
56- MaxIdleTime: time.Minute * defaultMaxIdleTimeMin,
57- QueryTimeout: time.Second * defaultQueryTimeoutSec,
58 },48 },
59 History: HistoryConfig{49 History: HistoryConfig{
60 MaxEntries: defaultMaxHistoryEntries,50 MaxEntries: defaultMaxHistoryEntries,
@@ -131,20 +121,8 @@ func DefaultConfig() *Config {
131 VideoBufferSizeMB: 16, // Larger buffer for AV1 streams121 VideoBufferSizeMB: 16, // Larger buffer for AV1 streams
132 QueueBufferTimeSec: 10, // More buffering time for smooth playback122 QueueBufferTimeSec: 10, // More buffering time for smooth playback
133 CustomUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", // Chrome UA with AV1 support123 CustomUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", // Chrome UA with AV1 support
134- AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res124+ AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res
135- DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing)125+ DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing)
136- },
137- WebkitMemory: WebkitMemoryConfig{
138- CacheModel: "web_browser", // Aggressive caching for fast page loads
139- EnablePageCache: true, // Instant back/forward navigation
140- MemoryLimitMB: 0, // Use system default
141- ConservativeThreshold: 0.4, // Start cleanup at 40%
142- StrictThreshold: 0.6, // Strict cleanup at 60%
143- KillThreshold: 0.8, // Kill processes at 80%
144- PollIntervalSeconds: 45.0, // Check every 45 seconds
145- EnableGCInterval: 120, // GC every 2 minutes
146- ProcessRecycleThreshold: 50, // Recycle after 50 page loads
147- EnableMemoryMonitoring: true, // Monitor for production tuning
148 },126 },
149 Debug: DebugConfig{127 Debug: DebugConfig{
150 EnableWebKitDebug: false,128 EnableWebKitDebug: false,
@@ -160,10 +138,6 @@ func DefaultConfig() *Config {
160 EnableFocusMetrics: false,138 EnableFocusMetrics: false,
161 EnablePaneCloseDebug: false,139 EnablePaneCloseDebug: false,
162 },140 },
163- APISecurity: APISecurityConfig{
164- Token: "",
165- RequireToken: false,
166- },
167 RenderingMode: RenderingModeGPU,141 RenderingMode: RenderingModeGPU,
168 UseDomZoom: false,142 UseDomZoom: false,
169 DefaultZoom: 1.2, // 120% default zoom for better readability143 DefaultZoom: 1.2, // 120% default zoom for better readability
@@ -187,10 +161,10 @@ func DefaultConfig() *Config {
187 },161 },
188 },162 },
189 Tabs: TabKeyConfig{163 Tabs: TabKeyConfig{
190- NewTab: "cmdorctrl+t",164+ NewTab: "ctrl+t",
191- CloseTab: "cmdorctrl+w",165+ CloseTab: "ctrl+w",
192- NextTab: "cmdorctrl+tab",166+ NextTab: "ctrl+tab",
193- PreviousTab: "cmdorctrl+shift+tab",167+ PreviousTab: "ctrl+shift+tab",
194 },168 },
195 Popups: PopupBehaviorConfig{169 Popups: PopupBehaviorConfig{
196 Behavior: PopupBehaviorSplit, // Default: open popups in split panes170 Behavior: PopupBehaviorSplit, // Default: open popups in split panes
A internal/config/schema.go +44 -0
@@ -0,0 +1,44 @@
1+package config
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/invopop/jsonschema"
10+)
11+
12+// GenerateSchemaFile generates a JSON schema file for the configuration.
13+// This is called automatically when a default config is created.
14+func GenerateSchemaFile() error {
15+ configDir, err := GetConfigDir()
16+ if err != nil {
17+ return fmt.Errorf("failed to get config directory: %w", err)
18+ }
19+
20+ schemaFile := filepath.Join(configDir, "config.schema.json")
21+
22+ // Create reflector and generate schema
23+ r := new(jsonschema.Reflector)
24+ schema := r.Reflect(&Config{})
25+
26+ // Set schema metadata
27+ schema.ID = "https://github.com/bnema/dumber/config.schema.json"
28+ schema.Title = "Dumber Browser Configuration"
29+ schema.Description = "Configuration schema for Dumber, a minimalist browser for tiling window managers"
30+
31+ // Marshal to pretty JSON
32+ data, err := json.MarshalIndent(schema, "", " ")
33+ if err != nil {
34+ return fmt.Errorf("failed to marshal schema: %w", err)
35+ }
36+
37+ // Write schema file
38+ if err := os.WriteFile(schemaFile, data, filePerm); err != nil {
39+ return fmt.Errorf("failed to write schema file: %w", err)
40+ }
41+
42+ fmt.Printf("Generated JSON schema: %s\n", schemaFile)
43+ return nil
44+}
@@ -0,0 +1,44 @@
1+package config
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/invopop/jsonschema"
10+)
11+
12+// GenerateSchemaFile generates a JSON schema file for the configuration.
13+// This is called automatically when a default config is created.
14+func GenerateSchemaFile() error {
15+ configDir, err := GetConfigDir()
16+ if err != nil {
17+ return fmt.Errorf("failed to get config directory: %w", err)
18+ }
19+
20+ schemaFile := filepath.Join(configDir, "config.schema.json")
21+
22+ // Create reflector and generate schema
23+ r := new(jsonschema.Reflector)
24+ schema := r.Reflect(&Config{})
25+
26+ // Set schema metadata
27+ schema.ID = "https://github.com/bnema/dumber/config.schema.json"
28+ schema.Title = "Dumber Browser Configuration"
29+ schema.Description = "Configuration schema for Dumber, a minimalist browser for tiling window managers"
30+
31+ // Marshal to pretty JSON
32+ data, err := json.MarshalIndent(schema, "", " ")
33+ if err != nil {
34+ return fmt.Errorf("failed to marshal schema: %w", err)
35+ }
36+
37+ // Write schema file
38+ if err := os.WriteFile(schemaFile, data, filePerm); err != nil {
39+ return fmt.Errorf("failed to write schema file: %w", err)
40+ }
41+
42+ fmt.Printf("Generated JSON schema: %s\n", schemaFile)
43+ return nil
44+}
M internal/config/xdg.go +9 -4
@@ -6,7 +6,10 @@ import (
66 "path/filepath"
77 )
88
9-const appName = "dumber"
9+const (
10+ appName = "dumber"
11+ databaseName = "dumber.sqlite"
12+)
1013
1114 // XDGDirs holds the XDG Base Directory paths for the application.
1215 type XDGDirs struct {
@@ -114,13 +117,15 @@ func GetConfigFile() (string, error) {
114117 return filepath.Join(configDir, "config.json"), nil
115118 }
116119
117-// GetDatabaseFile returns the path to the database file in the state directory.
120+// GetDatabaseFile returns the path to the database file in the data directory.
121+// The database contains important user data (history, cookies, preferences) and
122+// therefore belongs in XDG_DATA_HOME, not XDG_STATE_HOME.
118123 func GetDatabaseFile() (string, error) {
119- stateDir, err := GetStateDir()
124+ dataDir, err := GetDataDir()
120125 if err != nil {
121126 return "", err
122127 }
123- return filepath.Join(stateDir, "history.db"), nil
128+ return filepath.Join(dataDir, databaseName), nil
124129 }
125130
126131 // GetFilterCacheDir returns the XDG-compliant filter cache directory for dumber.
@@ -6,7 +6,10 @@ import (
6 "path/filepath"6 "path/filepath"
7 )7 )
8 8
9-const appName = "dumber"9+const (
10+ appName = "dumber"
11+ databaseName = "dumber.sqlite"
12+)
10 13
11 // XDGDirs holds the XDG Base Directory paths for the application.14 // XDGDirs holds the XDG Base Directory paths for the application.
12 type XDGDirs struct {15 type XDGDirs struct {
@@ -114,13 +117,15 @@ func GetConfigFile() (string, error) {
114 return filepath.Join(configDir, "config.json"), nil117 return filepath.Join(configDir, "config.json"), nil
115 }118 }
116 119
117-// GetDatabaseFile returns the path to the database file in the state directory.120+// GetDatabaseFile returns the path to the database file in the data directory.
121+// The database contains important user data (history, cookies, preferences) and
122+// therefore belongs in XDG_DATA_HOME, not XDG_STATE_HOME.
118 func GetDatabaseFile() (string, error) {123 func GetDatabaseFile() (string, error) {
119- stateDir, err := GetStateDir()124+ dataDir, err := GetDataDir()
120 if err != nil {125 if err != nil {
121 return "", err126 return "", err
122 }127 }
123- return filepath.Join(stateDir, "history.db"), nil128+ return filepath.Join(dataDir, databaseName), nil
124 }129 }
125 130
126 // GetFilterCacheDir returns the XDG-compliant filter cache directory for dumber.131 // GetFilterCacheDir returns the XDG-compliant filter cache directory for dumber.
M internal/db/certificate_validations.sql.go +37 -1
@@ -11,7 +11,7 @@ import (
1111 )
1212
1313 const DeleteCertificateValidation = `-- name: DeleteCertificateValidation :exec
14-DELETE FROM certificate_validations
14+DELETE FROM certificate_validations
1515 WHERE hostname = ? AND certificate_hash = ?
1616 `
1717
@@ -73,6 +73,42 @@ func (q *Queries) GetCertificateValidationByHostname(ctx context.Context, hostna
7373 return i, err
7474 }
7575
76+const ListCertificateValidations = `-- name: ListCertificateValidations :many
77+SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations
78+WHERE (expires_at IS NULL OR expires_at > datetime('now'))
79+ORDER BY hostname
80+`
81+
82+func (q *Queries) ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error) {
83+ rows, err := q.db.QueryContext(ctx, ListCertificateValidations)
84+ if err != nil {
85+ return nil, err
86+ }
87+ defer rows.Close()
88+ items := []CertificateValidation{}
89+ for rows.Next() {
90+ var i CertificateValidation
91+ if err := rows.Scan(
92+ &i.ID,
93+ &i.Hostname,
94+ &i.CertificateHash,
95+ &i.UserDecision,
96+ &i.CreatedAt,
97+ &i.ExpiresAt,
98+ ); err != nil {
99+ return nil, err
100+ }
101+ items = append(items, i)
102+ }
103+ if err := rows.Close(); err != nil {
104+ return nil, err
105+ }
106+ if err := rows.Err(); err != nil {
107+ return nil, err
108+ }
109+ return items, nil
110+}
111+
76112 const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec
77113 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)
78114 VALUES (?, ?, ?, ?)
@@ -11,7 +11,7 @@ import (
11 )11 )
12 12
13 const DeleteCertificateValidation = `-- name: DeleteCertificateValidation :exec13 const DeleteCertificateValidation = `-- name: DeleteCertificateValidation :exec
14-DELETE FROM certificate_validations 14+DELETE FROM certificate_validations
15 WHERE hostname = ? AND certificate_hash = ?15 WHERE hostname = ? AND certificate_hash = ?
16 `16 `
17 17
@@ -73,6 +73,42 @@ func (q *Queries) GetCertificateValidationByHostname(ctx context.Context, hostna
73 return i, err73 return i, err
74 }74 }
75 75
76+const ListCertificateValidations = `-- name: ListCertificateValidations :many
77+SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations
78+WHERE (expires_at IS NULL OR expires_at > datetime('now'))
79+ORDER BY hostname
80+`
81+
82+func (q *Queries) ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error) {
83+ rows, err := q.db.QueryContext(ctx, ListCertificateValidations)
84+ if err != nil {
85+ return nil, err
86+ }
87+ defer rows.Close()
88+ items := []CertificateValidation{}
89+ for rows.Next() {
90+ var i CertificateValidation
91+ if err := rows.Scan(
92+ &i.ID,
93+ &i.Hostname,
94+ &i.CertificateHash,
95+ &i.UserDecision,
96+ &i.CreatedAt,
97+ &i.ExpiresAt,
98+ ); err != nil {
99+ return nil, err
100+ }
101+ items = append(items, i)
102+ }
103+ if err := rows.Close(); err != nil {
104+ return nil, err
105+ }
106+ if err := rows.Err(); err != nil {
107+ return nil, err
108+ }
109+ return items, nil
110+}
111+
76 const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec112 const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec
77 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)113 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)
78 VALUES (?, ?, ?, ?)114 VALUES (?, ?, ?, ?)
M internal/db/history.sql.go +0 -35
@@ -133,41 +133,6 @@ func (q *Queries) GetHistoryWithOffset(ctx context.Context, limit int64, offset
133133 return items, nil
134134 }
135135
136-const GetShortcuts = `-- name: GetShortcuts :many
137-SELECT id, shortcut, url_template, description, created_at
138-FROM shortcuts
139-ORDER BY shortcut
140-`
141-
142-func (q *Queries) GetShortcuts(ctx context.Context) ([]Shortcut, error) {
143- rows, err := q.db.QueryContext(ctx, GetShortcuts)
144- if err != nil {
145- return nil, err
146- }
147- defer rows.Close()
148- items := []Shortcut{}
149- for rows.Next() {
150- var i Shortcut
151- if err := rows.Scan(
152- &i.ID,
153- &i.Shortcut,
154- &i.UrlTemplate,
155- &i.Description,
156- &i.CreatedAt,
157- ); err != nil {
158- return nil, err
159- }
160- items = append(items, i)
161- }
162- if err := rows.Close(); err != nil {
163- return nil, err
164- }
165- if err := rows.Err(); err != nil {
166- return nil, err
167- }
168- return items, nil
169-}
170-
171136 const SearchHistory = `-- name: SearchHistory :many
172137 SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
173138 FROM history
@@ -133,41 +133,6 @@ func (q *Queries) GetHistoryWithOffset(ctx context.Context, limit int64, offset
133 return items, nil133 return items, nil
134 }134 }
135 135
136-const GetShortcuts = `-- name: GetShortcuts :many
137-SELECT id, shortcut, url_template, description, created_at
138-FROM shortcuts
139-ORDER BY shortcut
140-`
141-
142-func (q *Queries) GetShortcuts(ctx context.Context) ([]Shortcut, error) {
143- rows, err := q.db.QueryContext(ctx, GetShortcuts)
144- if err != nil {
145- return nil, err
146- }
147- defer rows.Close()
148- items := []Shortcut{}
149- for rows.Next() {
150- var i Shortcut
151- if err := rows.Scan(
152- &i.ID,
153- &i.Shortcut,
154- &i.UrlTemplate,
155- &i.Description,
156- &i.CreatedAt,
157- ); err != nil {
158- return nil, err
159- }
160- items = append(items, i)
161- }
162- if err := rows.Close(); err != nil {
163- return nil, err
164- }
165- if err := rows.Err(); err != nil {
166- return nil, err
167- }
168- return items, nil
169-}
170-
171 const SearchHistory = `-- name: SearchHistory :many136 const SearchHistory = `-- name: SearchHistory :many
172 SELECT id, url, title, visit_count, last_visited, created_at, favicon_url137 SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
173 FROM history 138 FROM history
M internal/db/init.go +1 -40
@@ -7,7 +7,6 @@ import (
77 "os"
88 "path/filepath"
99
10- "github.com/bnema/dumber/internal/config"
1110 "github.com/bnema/dumber/internal/migrations"
1211 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
1312 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite
@@ -20,7 +19,7 @@ func InitDB(dbPath string) (*sql.DB, error) {
2019 }
2120
2221 // Ensure database directory exists
23- if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
22+ if err := os.MkdirAll(filepath.Dir(dbPath), 0750); err != nil {
2423 return nil, fmt.Errorf("failed to create database directory: %w", err)
2524 }
2625
@@ -70,41 +69,3 @@ func InitDB(dbPath string) (*sql.DB, error) {
7069
7170 return database, nil
7271 }
73-
74-// InitDBWithConfig initializes the database with configuration-based shortcuts
75-func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) {
76- db, err := InitDB(dbPath)
77- if err != nil {
78- return nil, err
79- }
80-
81- // Initialize shortcuts from configuration
82- if err := initializeShortcuts(db, cfg); err != nil {
83- if err := db.Close(); err != nil {
84- log.Printf("Warning: failed to close database: %v", err)
85- }
86- return nil, fmt.Errorf("failed to initialize shortcuts: %w", err)
87- }
88-
89- return db, nil
90-}
91-
92-// initializeShortcuts inserts configured shortcuts into the database
93-func initializeShortcuts(db *sql.DB, cfg *config.Config) error {
94- if cfg == nil || cfg.SearchShortcuts == nil {
95- return nil // No shortcuts to initialize
96- }
97-
98- // Insert configured shortcuts
99- for shortcut, shortcutCfg := range cfg.SearchShortcuts {
100- _, err := db.Exec(
101- "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
102- shortcut, shortcutCfg.URL, shortcutCfg.Description,
103- )
104- if err != nil {
105- return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err)
106- }
107- }
108-
109- return nil
110-}
@@ -7,7 +7,6 @@ import (
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9 9
10- "github.com/bnema/dumber/internal/config"
11 "github.com/bnema/dumber/internal/migrations"10 "github.com/bnema/dumber/internal/migrations"
12 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver11 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
13 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite12 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite
@@ -20,7 +19,7 @@ func InitDB(dbPath string) (*sql.DB, error) {
20 }19 }
21 20
22 // Ensure database directory exists21 // Ensure database directory exists
23- if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {22+ if err := os.MkdirAll(filepath.Dir(dbPath), 0750); err != nil {
24 return nil, fmt.Errorf("failed to create database directory: %w", err)23 return nil, fmt.Errorf("failed to create database directory: %w", err)
25 }24 }
26 25
@@ -70,41 +69,3 @@ func InitDB(dbPath string) (*sql.DB, error) {
70 69
71 return database, nil70 return database, nil
72 }71 }
73-
74-// InitDBWithConfig initializes the database with configuration-based shortcuts
75-func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) {
76- db, err := InitDB(dbPath)
77- if err != nil {
78- return nil, err
79- }
80-
81- // Initialize shortcuts from configuration
82- if err := initializeShortcuts(db, cfg); err != nil {
83- if err := db.Close(); err != nil {
84- log.Printf("Warning: failed to close database: %v", err)
85- }
86- return nil, fmt.Errorf("failed to initialize shortcuts: %w", err)
87- }
88-
89- return db, nil
90-}
91-
92-// initializeShortcuts inserts configured shortcuts into the database
93-func initializeShortcuts(db *sql.DB, cfg *config.Config) error {
94- if cfg == nil || cfg.SearchShortcuts == nil {
95- return nil // No shortcuts to initialize
96- }
97-
98- // Insert configured shortcuts
99- for shortcut, shortcutCfg := range cfg.SearchShortcuts {
100- _, err := db.Exec(
101- "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
102- shortcut, shortcutCfg.URL, shortcutCfg.Description,
103- )
104- if err != nil {
105- return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err)
106- }
107- }
108-
109- return nil
110-}
M internal/db/interfaces.go +9 -4
@@ -26,16 +26,21 @@ type HistoryQuerier interface {
2626 DeleteHistory(ctx context.Context, id int64) error
2727 }
2828
29-// ShortcutQuerier defines the interface for shortcut-related database operations
30-type ShortcutQuerier interface {
31- GetShortcuts(ctx context.Context) ([]Shortcut, error)
29+// CertificateQuerier defines the interface for certificate validation-related database operations
30+type CertificateQuerier interface {
31+ ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error)
32+ GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
33+ GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error)
34+ StoreCertificateValidation(ctx context.Context, hostname string, certificateHash string, userDecision string, expiresAt sql.NullTime) error
35+ DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error
36+ DeleteExpiredCertificateValidations(ctx context.Context) error
3237 }
3338
3439 // DatabaseQuerier combines all database operation interfaces
3540 type DatabaseQuerier interface {
3641 ZoomQuerier
3742 HistoryQuerier
38- ShortcutQuerier
43+ CertificateQuerier
3944 }
4045
4146 // Ensure that *Queries implements DatabaseQuerier interface
@@ -26,16 +26,21 @@ type HistoryQuerier interface {
26 DeleteHistory(ctx context.Context, id int64) error26 DeleteHistory(ctx context.Context, id int64) error
27 }27 }
28 28
29-// ShortcutQuerier defines the interface for shortcut-related database operations29+// CertificateQuerier defines the interface for certificate validation-related database operations
30-type ShortcutQuerier interface {30+type CertificateQuerier interface {
31- GetShortcuts(ctx context.Context) ([]Shortcut, error)31+ ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error)
32+ GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
33+ GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error)
34+ StoreCertificateValidation(ctx context.Context, hostname string, certificateHash string, userDecision string, expiresAt sql.NullTime) error
35+ DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error
36+ DeleteExpiredCertificateValidations(ctx context.Context) error
32 }37 }
33 38
34 // DatabaseQuerier combines all database operation interfaces39 // DatabaseQuerier combines all database operation interfaces
35 type DatabaseQuerier interface {40 type DatabaseQuerier interface {
36 ZoomQuerier41 ZoomQuerier
37 HistoryQuerier42 HistoryQuerier
38- ShortcutQuerier43+ CertificateQuerier
39 }44 }
40 45
41 // Ensure that *Queries implements DatabaseQuerier interface46 // Ensure that *Queries implements DatabaseQuerier interface
M internal/db/mocks/mock_db.go +337 -38
@@ -1,5 +1,10 @@
11 // Code generated by MockGen. DO NOT EDIT.
22 // Source: interfaces.go
3+//
4+// Generated by this command:
5+//
6+// mockgen -source=interfaces.go -destination=mocks/mock_db.go
7+//
38
49 // Package mock_db is a generated GoMock package.
510 package mock_db
@@ -10,13 +15,14 @@ import (
1015 reflect "reflect"
1116
1217 db "github.com/bnema/dumber/internal/db"
13- gomock "github.com/golang/mock/gomock"
18+ gomock "go.uber.org/mock/gomock"
1419 )
1520
1621 // MockZoomQuerier is a mock of ZoomQuerier interface.
1722 type MockZoomQuerier struct {
1823 ctrl *gomock.Controller
1924 recorder *MockZoomQuerierMockRecorder
25+ isgomock struct{}
2026 }
2127
2228 // MockZoomQuerierMockRecorder is the mock recorder for MockZoomQuerier.
@@ -45,7 +51,7 @@ func (m *MockZoomQuerier) DeleteZoomLevel(ctx context.Context, domain string) er
4551 }
4652
4753 // DeleteZoomLevel indicates an expected call of DeleteZoomLevel.
48-func (mr *MockZoomQuerierMockRecorder) DeleteZoomLevel(ctx, domain interface{}) *gomock.Call {
54+func (mr *MockZoomQuerierMockRecorder) DeleteZoomLevel(ctx, domain any) *gomock.Call {
4955 mr.mock.ctrl.T.Helper()
5056 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).DeleteZoomLevel), ctx, domain)
5157 }
@@ -60,11 +66,26 @@ func (m *MockZoomQuerier) GetZoomLevel(ctx context.Context, domain string) (floa
6066 }
6167
6268 // GetZoomLevel indicates an expected call of GetZoomLevel.
63-func (mr *MockZoomQuerierMockRecorder) GetZoomLevel(ctx, domain interface{}) *gomock.Call {
69+func (mr *MockZoomQuerierMockRecorder) GetZoomLevel(ctx, domain any) *gomock.Call {
6470 mr.mock.ctrl.T.Helper()
6571 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).GetZoomLevel), ctx, domain)
6672 }
6773
74+// ListZoomLevels mocks base method.
75+func (m *MockZoomQuerier) ListZoomLevels(ctx context.Context) ([]db.ZoomLevel, error) {
76+ m.ctrl.T.Helper()
77+ ret := m.ctrl.Call(m, "ListZoomLevels", ctx)
78+ ret0, _ := ret[0].([]db.ZoomLevel)
79+ ret1, _ := ret[1].(error)
80+ return ret0, ret1
81+}
82+
83+// ListZoomLevels indicates an expected call of ListZoomLevels.
84+func (mr *MockZoomQuerierMockRecorder) ListZoomLevels(ctx any) *gomock.Call {
85+ mr.mock.ctrl.T.Helper()
86+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListZoomLevels", reflect.TypeOf((*MockZoomQuerier)(nil).ListZoomLevels), ctx)
87+}
88+
6889 // SetZoomLevel mocks base method.
6990 func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error {
7091 m.ctrl.T.Helper()
@@ -74,7 +95,7 @@ func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomL
7495 }
7596
7697 // SetZoomLevel indicates an expected call of SetZoomLevel.
77-func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel interface{}) *gomock.Call {
98+func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel any) *gomock.Call {
7899 mr.mock.ctrl.T.Helper()
79100 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel)
80101 }
@@ -83,6 +104,7 @@ func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel inter
83104 type MockHistoryQuerier struct {
84105 ctrl *gomock.Controller
85106 recorder *MockHistoryQuerierMockRecorder
107+ isgomock struct{}
86108 }
87109
88110 // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier.
@@ -111,11 +133,25 @@ func (m *MockHistoryQuerier) AddOrUpdateHistory(ctx context.Context, url string,
111133 }
112134
113135 // AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory.
114-func (mr *MockHistoryQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title interface{}) *gomock.Call {
136+func (mr *MockHistoryQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title any) *gomock.Call {
115137 mr.mock.ctrl.T.Helper()
116138 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).AddOrUpdateHistory), ctx, url, title)
117139 }
118140
141+// DeleteHistory mocks base method.
142+func (m *MockHistoryQuerier) DeleteHistory(ctx context.Context, id int64) error {
143+ m.ctrl.T.Helper()
144+ ret := m.ctrl.Call(m, "DeleteHistory", ctx, id)
145+ ret0, _ := ret[0].(error)
146+ return ret0
147+}
148+
149+// DeleteHistory indicates an expected call of DeleteHistory.
150+func (mr *MockHistoryQuerierMockRecorder) DeleteHistory(ctx, id any) *gomock.Call {
151+ mr.mock.ctrl.T.Helper()
152+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).DeleteHistory), ctx, id)
153+}
154+
119155 // GetHistory mocks base method.
120156 func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {
121157 m.ctrl.T.Helper()
@@ -126,11 +162,41 @@ func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.
126162 }
127163
128164 // GetHistory indicates an expected call of GetHistory.
129-func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call {
165+func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call {
130166 mr.mock.ctrl.T.Helper()
131167 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit)
132168 }
133169
170+// GetHistoryEntry mocks base method.
171+func (m *MockHistoryQuerier) GetHistoryEntry(ctx context.Context, url string) (db.History, error) {
172+ m.ctrl.T.Helper()
173+ ret := m.ctrl.Call(m, "GetHistoryEntry", ctx, url)
174+ ret0, _ := ret[0].(db.History)
175+ ret1, _ := ret[1].(error)
176+ return ret0, ret1
177+}
178+
179+// GetHistoryEntry indicates an expected call of GetHistoryEntry.
180+func (mr *MockHistoryQuerierMockRecorder) GetHistoryEntry(ctx, url any) *gomock.Call {
181+ mr.mock.ctrl.T.Helper()
182+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryEntry", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistoryEntry), ctx, url)
183+}
184+
185+// GetHistoryWithOffset mocks base method.
186+func (m *MockHistoryQuerier) GetHistoryWithOffset(ctx context.Context, limit, offset int64) ([]db.History, error) {
187+ m.ctrl.T.Helper()
188+ ret := m.ctrl.Call(m, "GetHistoryWithOffset", ctx, limit, offset)
189+ ret0, _ := ret[0].([]db.History)
190+ ret1, _ := ret[1].(error)
191+ return ret0, ret1
192+}
193+
194+// GetHistoryWithOffset indicates an expected call of GetHistoryWithOffset.
195+func (mr *MockHistoryQuerierMockRecorder) GetHistoryWithOffset(ctx, limit, offset any) *gomock.Call {
196+ mr.mock.ctrl.T.Helper()
197+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryWithOffset", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistoryWithOffset), ctx, limit, offset)
198+}
199+
134200 // SearchHistory mocks base method.
135201 func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) {
136202 m.ctrl.T.Helper()
@@ -141,53 +207,141 @@ func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2
141207 }
142208
143209 // SearchHistory indicates an expected call of SearchHistory.
144-func (mr *MockHistoryQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit interface{}) *gomock.Call {
210+func (mr *MockHistoryQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit any) *gomock.Call {
145211 mr.mock.ctrl.T.Helper()
146212 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).SearchHistory), ctx, column1, column2, limit)
147213 }
148214
149-// MockShortcutQuerier is a mock of ShortcutQuerier interface.
150-type MockShortcutQuerier struct {
215+// UpdateHistoryFavicon mocks base method.
216+func (m *MockHistoryQuerier) UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error {
217+ m.ctrl.T.Helper()
218+ ret := m.ctrl.Call(m, "UpdateHistoryFavicon", ctx, faviconUrl, url)
219+ ret0, _ := ret[0].(error)
220+ return ret0
221+}
222+
223+// UpdateHistoryFavicon indicates an expected call of UpdateHistoryFavicon.
224+func (mr *MockHistoryQuerierMockRecorder) UpdateHistoryFavicon(ctx, faviconUrl, url any) *gomock.Call {
225+ mr.mock.ctrl.T.Helper()
226+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateHistoryFavicon", reflect.TypeOf((*MockHistoryQuerier)(nil).UpdateHistoryFavicon), ctx, faviconUrl, url)
227+}
228+
229+// MockCertificateQuerier is a mock of CertificateQuerier interface.
230+type MockCertificateQuerier struct {
151231 ctrl *gomock.Controller
152- recorder *MockShortcutQuerierMockRecorder
232+ recorder *MockCertificateQuerierMockRecorder
233+ isgomock struct{}
153234 }
154235
155-// MockShortcutQuerierMockRecorder is the mock recorder for MockShortcutQuerier.
156-type MockShortcutQuerierMockRecorder struct {
157- mock *MockShortcutQuerier
236+// MockCertificateQuerierMockRecorder is the mock recorder for MockCertificateQuerier.
237+type MockCertificateQuerierMockRecorder struct {
238+ mock *MockCertificateQuerier
158239 }
159240
160-// NewMockShortcutQuerier creates a new mock instance.
161-func NewMockShortcutQuerier(ctrl *gomock.Controller) *MockShortcutQuerier {
162- mock := &MockShortcutQuerier{ctrl: ctrl}
163- mock.recorder = &MockShortcutQuerierMockRecorder{mock}
241+// NewMockCertificateQuerier creates a new mock instance.
242+func NewMockCertificateQuerier(ctrl *gomock.Controller) *MockCertificateQuerier {
243+ mock := &MockCertificateQuerier{ctrl: ctrl}
244+ mock.recorder = &MockCertificateQuerierMockRecorder{mock}
164245 return mock
165246 }
166247
167248 // EXPECT returns an object that allows the caller to indicate expected use.
168-func (m *MockShortcutQuerier) EXPECT() *MockShortcutQuerierMockRecorder {
249+func (m *MockCertificateQuerier) EXPECT() *MockCertificateQuerierMockRecorder {
169250 return m.recorder
170251 }
171252
172-// GetShortcuts mocks base method.
173-func (m *MockShortcutQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) {
253+// DeleteCertificateValidation mocks base method.
254+func (m *MockCertificateQuerier) DeleteCertificateValidation(ctx context.Context, hostname, certificateHash string) error {
174255 m.ctrl.T.Helper()
175- ret := m.ctrl.Call(m, "GetShortcuts", ctx)
176- ret0, _ := ret[0].([]db.Shortcut)
256+ ret := m.ctrl.Call(m, "DeleteCertificateValidation", ctx, hostname, certificateHash)
257+ ret0, _ := ret[0].(error)
258+ return ret0
259+}
260+
261+// DeleteCertificateValidation indicates an expected call of DeleteCertificateValidation.
262+func (mr *MockCertificateQuerierMockRecorder) DeleteCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call {
263+ mr.mock.ctrl.T.Helper()
264+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCertificateValidation", reflect.TypeOf((*MockCertificateQuerier)(nil).DeleteCertificateValidation), ctx, hostname, certificateHash)
265+}
266+
267+// DeleteExpiredCertificateValidations mocks base method.
268+func (m *MockCertificateQuerier) DeleteExpiredCertificateValidations(ctx context.Context) error {
269+ m.ctrl.T.Helper()
270+ ret := m.ctrl.Call(m, "DeleteExpiredCertificateValidations", ctx)
271+ ret0, _ := ret[0].(error)
272+ return ret0
273+}
274+
275+// DeleteExpiredCertificateValidations indicates an expected call of DeleteExpiredCertificateValidations.
276+func (mr *MockCertificateQuerierMockRecorder) DeleteExpiredCertificateValidations(ctx any) *gomock.Call {
277+ mr.mock.ctrl.T.Helper()
278+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExpiredCertificateValidations", reflect.TypeOf((*MockCertificateQuerier)(nil).DeleteExpiredCertificateValidations), ctx)
279+}
280+
281+// GetCertificateValidation mocks base method.
282+func (m *MockCertificateQuerier) GetCertificateValidation(ctx context.Context, hostname, certificateHash string) (db.CertificateValidation, error) {
283+ m.ctrl.T.Helper()
284+ ret := m.ctrl.Call(m, "GetCertificateValidation", ctx, hostname, certificateHash)
285+ ret0, _ := ret[0].(db.CertificateValidation)
286+ ret1, _ := ret[1].(error)
287+ return ret0, ret1
288+}
289+
290+// GetCertificateValidation indicates an expected call of GetCertificateValidation.
291+func (mr *MockCertificateQuerierMockRecorder) GetCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call {
292+ mr.mock.ctrl.T.Helper()
293+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidation", reflect.TypeOf((*MockCertificateQuerier)(nil).GetCertificateValidation), ctx, hostname, certificateHash)
294+}
295+
296+// GetCertificateValidationByHostname mocks base method.
297+func (m *MockCertificateQuerier) GetCertificateValidationByHostname(ctx context.Context, hostname string) (db.CertificateValidation, error) {
298+ m.ctrl.T.Helper()
299+ ret := m.ctrl.Call(m, "GetCertificateValidationByHostname", ctx, hostname)
300+ ret0, _ := ret[0].(db.CertificateValidation)
301+ ret1, _ := ret[1].(error)
302+ return ret0, ret1
303+}
304+
305+// GetCertificateValidationByHostname indicates an expected call of GetCertificateValidationByHostname.
306+func (mr *MockCertificateQuerierMockRecorder) GetCertificateValidationByHostname(ctx, hostname any) *gomock.Call {
307+ mr.mock.ctrl.T.Helper()
308+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidationByHostname", reflect.TypeOf((*MockCertificateQuerier)(nil).GetCertificateValidationByHostname), ctx, hostname)
309+}
310+
311+// ListCertificateValidations mocks base method.
312+func (m *MockCertificateQuerier) ListCertificateValidations(ctx context.Context) ([]db.CertificateValidation, error) {
313+ m.ctrl.T.Helper()
314+ ret := m.ctrl.Call(m, "ListCertificateValidations", ctx)
315+ ret0, _ := ret[0].([]db.CertificateValidation)
177316 ret1, _ := ret[1].(error)
178317 return ret0, ret1
179318 }
180319
181-// GetShortcuts indicates an expected call of GetShortcuts.
182-func (mr *MockShortcutQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call {
320+// ListCertificateValidations indicates an expected call of ListCertificateValidations.
321+func (mr *MockCertificateQuerierMockRecorder) ListCertificateValidations(ctx any) *gomock.Call {
322+ mr.mock.ctrl.T.Helper()
323+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCertificateValidations", reflect.TypeOf((*MockCertificateQuerier)(nil).ListCertificateValidations), ctx)
324+}
325+
326+// StoreCertificateValidation mocks base method.
327+func (m *MockCertificateQuerier) StoreCertificateValidation(ctx context.Context, hostname, certificateHash, userDecision string, expiresAt sql.NullTime) error {
328+ m.ctrl.T.Helper()
329+ ret := m.ctrl.Call(m, "StoreCertificateValidation", ctx, hostname, certificateHash, userDecision, expiresAt)
330+ ret0, _ := ret[0].(error)
331+ return ret0
332+}
333+
334+// StoreCertificateValidation indicates an expected call of StoreCertificateValidation.
335+func (mr *MockCertificateQuerierMockRecorder) StoreCertificateValidation(ctx, hostname, certificateHash, userDecision, expiresAt any) *gomock.Call {
183336 mr.mock.ctrl.T.Helper()
184- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockShortcutQuerier)(nil).GetShortcuts), ctx)
337+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreCertificateValidation", reflect.TypeOf((*MockCertificateQuerier)(nil).StoreCertificateValidation), ctx, hostname, certificateHash, userDecision, expiresAt)
185338 }
186339
187340 // MockDatabaseQuerier is a mock of DatabaseQuerier interface.
188341 type MockDatabaseQuerier struct {
189342 ctrl *gomock.Controller
190343 recorder *MockDatabaseQuerierMockRecorder
344+ isgomock struct{}
191345 }
192346
193347 // MockDatabaseQuerierMockRecorder is the mock recorder for MockDatabaseQuerier.
@@ -216,11 +370,53 @@ func (m *MockDatabaseQuerier) AddOrUpdateHistory(ctx context.Context, url string
216370 }
217371
218372 // AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory.
219-func (mr *MockDatabaseQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title interface{}) *gomock.Call {
373+func (mr *MockDatabaseQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title any) *gomock.Call {
220374 mr.mock.ctrl.T.Helper()
221375 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).AddOrUpdateHistory), ctx, url, title)
222376 }
223377
378+// DeleteCertificateValidation mocks base method.
379+func (m *MockDatabaseQuerier) DeleteCertificateValidation(ctx context.Context, hostname, certificateHash string) error {
380+ m.ctrl.T.Helper()
381+ ret := m.ctrl.Call(m, "DeleteCertificateValidation", ctx, hostname, certificateHash)
382+ ret0, _ := ret[0].(error)
383+ return ret0
384+}
385+
386+// DeleteCertificateValidation indicates an expected call of DeleteCertificateValidation.
387+func (mr *MockDatabaseQuerierMockRecorder) DeleteCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call {
388+ mr.mock.ctrl.T.Helper()
389+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCertificateValidation", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteCertificateValidation), ctx, hostname, certificateHash)
390+}
391+
392+// DeleteExpiredCertificateValidations mocks base method.
393+func (m *MockDatabaseQuerier) DeleteExpiredCertificateValidations(ctx context.Context) error {
394+ m.ctrl.T.Helper()
395+ ret := m.ctrl.Call(m, "DeleteExpiredCertificateValidations", ctx)
396+ ret0, _ := ret[0].(error)
397+ return ret0
398+}
399+
400+// DeleteExpiredCertificateValidations indicates an expected call of DeleteExpiredCertificateValidations.
401+func (mr *MockDatabaseQuerierMockRecorder) DeleteExpiredCertificateValidations(ctx any) *gomock.Call {
402+ mr.mock.ctrl.T.Helper()
403+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExpiredCertificateValidations", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteExpiredCertificateValidations), ctx)
404+}
405+
406+// DeleteHistory mocks base method.
407+func (m *MockDatabaseQuerier) DeleteHistory(ctx context.Context, id int64) error {
408+ m.ctrl.T.Helper()
409+ ret := m.ctrl.Call(m, "DeleteHistory", ctx, id)
410+ ret0, _ := ret[0].(error)
411+ return ret0
412+}
413+
414+// DeleteHistory indicates an expected call of DeleteHistory.
415+func (mr *MockDatabaseQuerierMockRecorder) DeleteHistory(ctx, id any) *gomock.Call {
416+ mr.mock.ctrl.T.Helper()
417+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteHistory), ctx, id)
418+}
419+
224420 // DeleteZoomLevel mocks base method.
225421 func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string) error {
226422 m.ctrl.T.Helper()
@@ -230,11 +426,41 @@ func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string
230426 }
231427
232428 // DeleteZoomLevel indicates an expected call of DeleteZoomLevel.
233-func (mr *MockDatabaseQuerierMockRecorder) DeleteZoomLevel(ctx, domain interface{}) *gomock.Call {
429+func (mr *MockDatabaseQuerierMockRecorder) DeleteZoomLevel(ctx, domain any) *gomock.Call {
234430 mr.mock.ctrl.T.Helper()
235431 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteZoomLevel), ctx, domain)
236432 }
237433
434+// GetCertificateValidation mocks base method.
435+func (m *MockDatabaseQuerier) GetCertificateValidation(ctx context.Context, hostname, certificateHash string) (db.CertificateValidation, error) {
436+ m.ctrl.T.Helper()
437+ ret := m.ctrl.Call(m, "GetCertificateValidation", ctx, hostname, certificateHash)
438+ ret0, _ := ret[0].(db.CertificateValidation)
439+ ret1, _ := ret[1].(error)
440+ return ret0, ret1
441+}
442+
443+// GetCertificateValidation indicates an expected call of GetCertificateValidation.
444+func (mr *MockDatabaseQuerierMockRecorder) GetCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call {
445+ mr.mock.ctrl.T.Helper()
446+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidation", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetCertificateValidation), ctx, hostname, certificateHash)
447+}
448+
449+// GetCertificateValidationByHostname mocks base method.
450+func (m *MockDatabaseQuerier) GetCertificateValidationByHostname(ctx context.Context, hostname string) (db.CertificateValidation, error) {
451+ m.ctrl.T.Helper()
452+ ret := m.ctrl.Call(m, "GetCertificateValidationByHostname", ctx, hostname)
453+ ret0, _ := ret[0].(db.CertificateValidation)
454+ ret1, _ := ret[1].(error)
455+ return ret0, ret1
456+}
457+
458+// GetCertificateValidationByHostname indicates an expected call of GetCertificateValidationByHostname.
459+func (mr *MockDatabaseQuerierMockRecorder) GetCertificateValidationByHostname(ctx, hostname any) *gomock.Call {
460+ mr.mock.ctrl.T.Helper()
461+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidationByHostname", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetCertificateValidationByHostname), ctx, hostname)
462+}
463+
238464 // GetHistory mocks base method.
239465 func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {
240466 m.ctrl.T.Helper()
@@ -245,24 +471,39 @@ func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db
245471 }
246472
247473 // GetHistory indicates an expected call of GetHistory.
248-func (mr *MockDatabaseQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call {
474+func (mr *MockDatabaseQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call {
249475 mr.mock.ctrl.T.Helper()
250476 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistory), ctx, limit)
251477 }
252478
253-// GetShortcuts mocks base method.
254-func (m *MockDatabaseQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) {
479+// GetHistoryEntry mocks base method.
480+func (m *MockDatabaseQuerier) GetHistoryEntry(ctx context.Context, url string) (db.History, error) {
255481 m.ctrl.T.Helper()
256- ret := m.ctrl.Call(m, "GetShortcuts", ctx)
257- ret0, _ := ret[0].([]db.Shortcut)
482+ ret := m.ctrl.Call(m, "GetHistoryEntry", ctx, url)
483+ ret0, _ := ret[0].(db.History)
258484 ret1, _ := ret[1].(error)
259485 return ret0, ret1
260486 }
261487
262-// GetShortcuts indicates an expected call of GetShortcuts.
263-func (mr *MockDatabaseQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call {
488+// GetHistoryEntry indicates an expected call of GetHistoryEntry.
489+func (mr *MockDatabaseQuerierMockRecorder) GetHistoryEntry(ctx, url any) *gomock.Call {
264490 mr.mock.ctrl.T.Helper()
265- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetShortcuts), ctx)
491+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryEntry", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistoryEntry), ctx, url)
492+}
493+
494+// GetHistoryWithOffset mocks base method.
495+func (m *MockDatabaseQuerier) GetHistoryWithOffset(ctx context.Context, limit, offset int64) ([]db.History, error) {
496+ m.ctrl.T.Helper()
497+ ret := m.ctrl.Call(m, "GetHistoryWithOffset", ctx, limit, offset)
498+ ret0, _ := ret[0].([]db.History)
499+ ret1, _ := ret[1].(error)
500+ return ret0, ret1
501+}
502+
503+// GetHistoryWithOffset indicates an expected call of GetHistoryWithOffset.
504+func (mr *MockDatabaseQuerierMockRecorder) GetHistoryWithOffset(ctx, limit, offset any) *gomock.Call {
505+ mr.mock.ctrl.T.Helper()
506+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryWithOffset", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistoryWithOffset), ctx, limit, offset)
266507 }
267508
268509 // GetZoomLevel mocks base method.
@@ -275,11 +516,41 @@ func (m *MockDatabaseQuerier) GetZoomLevel(ctx context.Context, domain string) (
275516 }
276517
277518 // GetZoomLevel indicates an expected call of GetZoomLevel.
278-func (mr *MockDatabaseQuerierMockRecorder) GetZoomLevel(ctx, domain interface{}) *gomock.Call {
519+func (mr *MockDatabaseQuerierMockRecorder) GetZoomLevel(ctx, domain any) *gomock.Call {
279520 mr.mock.ctrl.T.Helper()
280521 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetZoomLevel), ctx, domain)
281522 }
282523
524+// ListCertificateValidations mocks base method.
525+func (m *MockDatabaseQuerier) ListCertificateValidations(ctx context.Context) ([]db.CertificateValidation, error) {
526+ m.ctrl.T.Helper()
527+ ret := m.ctrl.Call(m, "ListCertificateValidations", ctx)
528+ ret0, _ := ret[0].([]db.CertificateValidation)
529+ ret1, _ := ret[1].(error)
530+ return ret0, ret1
531+}
532+
533+// ListCertificateValidations indicates an expected call of ListCertificateValidations.
534+func (mr *MockDatabaseQuerierMockRecorder) ListCertificateValidations(ctx any) *gomock.Call {
535+ mr.mock.ctrl.T.Helper()
536+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCertificateValidations", reflect.TypeOf((*MockDatabaseQuerier)(nil).ListCertificateValidations), ctx)
537+}
538+
539+// ListZoomLevels mocks base method.
540+func (m *MockDatabaseQuerier) ListZoomLevels(ctx context.Context) ([]db.ZoomLevel, error) {
541+ m.ctrl.T.Helper()
542+ ret := m.ctrl.Call(m, "ListZoomLevels", ctx)
543+ ret0, _ := ret[0].([]db.ZoomLevel)
544+ ret1, _ := ret[1].(error)
545+ return ret0, ret1
546+}
547+
548+// ListZoomLevels indicates an expected call of ListZoomLevels.
549+func (mr *MockDatabaseQuerierMockRecorder) ListZoomLevels(ctx any) *gomock.Call {
550+ mr.mock.ctrl.T.Helper()
551+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListZoomLevels", reflect.TypeOf((*MockDatabaseQuerier)(nil).ListZoomLevels), ctx)
552+}
553+
283554 // SearchHistory mocks base method.
284555 func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) {
285556 m.ctrl.T.Helper()
@@ -290,7 +561,7 @@ func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column
290561 }
291562
292563 // SearchHistory indicates an expected call of SearchHistory.
293-func (mr *MockDatabaseQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit interface{}) *gomock.Call {
564+func (mr *MockDatabaseQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit any) *gomock.Call {
294565 mr.mock.ctrl.T.Helper()
295566 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).SearchHistory), ctx, column1, column2, limit)
296567 }
@@ -304,7 +575,35 @@ func (m *MockDatabaseQuerier) SetZoomLevel(ctx context.Context, domain string, z
304575 }
305576
306577 // SetZoomLevel indicates an expected call of SetZoomLevel.
307-func (mr *MockDatabaseQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel interface{}) *gomock.Call {
578+func (mr *MockDatabaseQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel any) *gomock.Call {
308579 mr.mock.ctrl.T.Helper()
309580 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel)
310581 }
582+
583+// StoreCertificateValidation mocks base method.
584+func (m *MockDatabaseQuerier) StoreCertificateValidation(ctx context.Context, hostname, certificateHash, userDecision string, expiresAt sql.NullTime) error {
585+ m.ctrl.T.Helper()
586+ ret := m.ctrl.Call(m, "StoreCertificateValidation", ctx, hostname, certificateHash, userDecision, expiresAt)
587+ ret0, _ := ret[0].(error)
588+ return ret0
589+}
590+
591+// StoreCertificateValidation indicates an expected call of StoreCertificateValidation.
592+func (mr *MockDatabaseQuerierMockRecorder) StoreCertificateValidation(ctx, hostname, certificateHash, userDecision, expiresAt any) *gomock.Call {
593+ mr.mock.ctrl.T.Helper()
594+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreCertificateValidation", reflect.TypeOf((*MockDatabaseQuerier)(nil).StoreCertificateValidation), ctx, hostname, certificateHash, userDecision, expiresAt)
595+}
596+
597+// UpdateHistoryFavicon mocks base method.
598+func (m *MockDatabaseQuerier) UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error {
599+ m.ctrl.T.Helper()
600+ ret := m.ctrl.Call(m, "UpdateHistoryFavicon", ctx, faviconUrl, url)
601+ ret0, _ := ret[0].(error)
602+ return ret0
603+}
604+
605+// UpdateHistoryFavicon indicates an expected call of UpdateHistoryFavicon.
606+func (mr *MockDatabaseQuerierMockRecorder) UpdateHistoryFavicon(ctx, faviconUrl, url any) *gomock.Call {
607+ mr.mock.ctrl.T.Helper()
608+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateHistoryFavicon", reflect.TypeOf((*MockDatabaseQuerier)(nil).UpdateHistoryFavicon), ctx, faviconUrl, url)
609+}
@@ -1,5 +1,10 @@
1 // Code generated by MockGen. DO NOT EDIT.1 // Code generated by MockGen. DO NOT EDIT.
2 // Source: interfaces.go2 // Source: interfaces.go
3+//
4+// Generated by this command:
5+//
6+// mockgen -source=interfaces.go -destination=mocks/mock_db.go
7+//
3 8
4 // Package mock_db is a generated GoMock package.9 // Package mock_db is a generated GoMock package.
5 package mock_db10 package mock_db
@@ -10,13 +15,14 @@ import (
10 reflect "reflect"15 reflect "reflect"
11 16
12 db "github.com/bnema/dumber/internal/db"17 db "github.com/bnema/dumber/internal/db"
13- gomock "github.com/golang/mock/gomock"18+ gomock "go.uber.org/mock/gomock"
14 )19 )
15 20
16 // MockZoomQuerier is a mock of ZoomQuerier interface.21 // MockZoomQuerier is a mock of ZoomQuerier interface.
17 type MockZoomQuerier struct {22 type MockZoomQuerier struct {
18 ctrl *gomock.Controller23 ctrl *gomock.Controller
19 recorder *MockZoomQuerierMockRecorder24 recorder *MockZoomQuerierMockRecorder
25+ isgomock struct{}
20 }26 }
21 27
22 // MockZoomQuerierMockRecorder is the mock recorder for MockZoomQuerier.28 // MockZoomQuerierMockRecorder is the mock recorder for MockZoomQuerier.
@@ -45,7 +51,7 @@ func (m *MockZoomQuerier) DeleteZoomLevel(ctx context.Context, domain string) er
45 }51 }
46 52
47 // DeleteZoomLevel indicates an expected call of DeleteZoomLevel.53 // DeleteZoomLevel indicates an expected call of DeleteZoomLevel.
48-func (mr *MockZoomQuerierMockRecorder) DeleteZoomLevel(ctx, domain interface{}) *gomock.Call {54+func (mr *MockZoomQuerierMockRecorder) DeleteZoomLevel(ctx, domain any) *gomock.Call {
49 mr.mock.ctrl.T.Helper()55 mr.mock.ctrl.T.Helper()
50 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).DeleteZoomLevel), ctx, domain)56 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).DeleteZoomLevel), ctx, domain)
51 }57 }
@@ -60,11 +66,26 @@ func (m *MockZoomQuerier) GetZoomLevel(ctx context.Context, domain string) (floa
60 }66 }
61 67
62 // GetZoomLevel indicates an expected call of GetZoomLevel.68 // GetZoomLevel indicates an expected call of GetZoomLevel.
63-func (mr *MockZoomQuerierMockRecorder) GetZoomLevel(ctx, domain interface{}) *gomock.Call {69+func (mr *MockZoomQuerierMockRecorder) GetZoomLevel(ctx, domain any) *gomock.Call {
64 mr.mock.ctrl.T.Helper()70 mr.mock.ctrl.T.Helper()
65 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).GetZoomLevel), ctx, domain)71 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).GetZoomLevel), ctx, domain)
66 }72 }
67 73
74+// ListZoomLevels mocks base method.
75+func (m *MockZoomQuerier) ListZoomLevels(ctx context.Context) ([]db.ZoomLevel, error) {
76+ m.ctrl.T.Helper()
77+ ret := m.ctrl.Call(m, "ListZoomLevels", ctx)
78+ ret0, _ := ret[0].([]db.ZoomLevel)
79+ ret1, _ := ret[1].(error)
80+ return ret0, ret1
81+}
82+
83+// ListZoomLevels indicates an expected call of ListZoomLevels.
84+func (mr *MockZoomQuerierMockRecorder) ListZoomLevels(ctx any) *gomock.Call {
85+ mr.mock.ctrl.T.Helper()
86+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListZoomLevels", reflect.TypeOf((*MockZoomQuerier)(nil).ListZoomLevels), ctx)
87+}
88+
68 // SetZoomLevel mocks base method.89 // SetZoomLevel mocks base method.
69 func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error {90 func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error {
70 m.ctrl.T.Helper()91 m.ctrl.T.Helper()
@@ -74,7 +95,7 @@ func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomL
74 }95 }
75 96
76 // SetZoomLevel indicates an expected call of SetZoomLevel.97 // SetZoomLevel indicates an expected call of SetZoomLevel.
77-func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel interface{}) *gomock.Call {98+func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel any) *gomock.Call {
78 mr.mock.ctrl.T.Helper()99 mr.mock.ctrl.T.Helper()
79 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel)100 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel)
80 }101 }
@@ -83,6 +104,7 @@ func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel inter
83 type MockHistoryQuerier struct {104 type MockHistoryQuerier struct {
84 ctrl *gomock.Controller105 ctrl *gomock.Controller
85 recorder *MockHistoryQuerierMockRecorder106 recorder *MockHistoryQuerierMockRecorder
107+ isgomock struct{}
86 }108 }
87 109
88 // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier.110 // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier.
@@ -111,11 +133,25 @@ func (m *MockHistoryQuerier) AddOrUpdateHistory(ctx context.Context, url string,
111 }133 }
112 134
113 // AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory.135 // AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory.
114-func (mr *MockHistoryQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title interface{}) *gomock.Call {136+func (mr *MockHistoryQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title any) *gomock.Call {
115 mr.mock.ctrl.T.Helper()137 mr.mock.ctrl.T.Helper()
116 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).AddOrUpdateHistory), ctx, url, title)138 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).AddOrUpdateHistory), ctx, url, title)
117 }139 }
118 140
141+// DeleteHistory mocks base method.
142+func (m *MockHistoryQuerier) DeleteHistory(ctx context.Context, id int64) error {
143+ m.ctrl.T.Helper()
144+ ret := m.ctrl.Call(m, "DeleteHistory", ctx, id)
145+ ret0, _ := ret[0].(error)
146+ return ret0
147+}
148+
149+// DeleteHistory indicates an expected call of DeleteHistory.
150+func (mr *MockHistoryQuerierMockRecorder) DeleteHistory(ctx, id any) *gomock.Call {
151+ mr.mock.ctrl.T.Helper()
152+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).DeleteHistory), ctx, id)
153+}
154+
119 // GetHistory mocks base method.155 // GetHistory mocks base method.
120 func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {156 func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {
121 m.ctrl.T.Helper()157 m.ctrl.T.Helper()
@@ -126,11 +162,41 @@ func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.
126 }162 }
127 163
128 // GetHistory indicates an expected call of GetHistory.164 // GetHistory indicates an expected call of GetHistory.
129-func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call {165+func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call {
130 mr.mock.ctrl.T.Helper()166 mr.mock.ctrl.T.Helper()
131 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit)167 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit)
132 }168 }
133 169
170+// GetHistoryEntry mocks base method.
171+func (m *MockHistoryQuerier) GetHistoryEntry(ctx context.Context, url string) (db.History, error) {
172+ m.ctrl.T.Helper()
173+ ret := m.ctrl.Call(m, "GetHistoryEntry", ctx, url)
174+ ret0, _ := ret[0].(db.History)
175+ ret1, _ := ret[1].(error)
176+ return ret0, ret1
177+}
178+
179+// GetHistoryEntry indicates an expected call of GetHistoryEntry.
180+func (mr *MockHistoryQuerierMockRecorder) GetHistoryEntry(ctx, url any) *gomock.Call {
181+ mr.mock.ctrl.T.Helper()
182+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryEntry", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistoryEntry), ctx, url)
183+}
184+
185+// GetHistoryWithOffset mocks base method.
186+func (m *MockHistoryQuerier) GetHistoryWithOffset(ctx context.Context, limit, offset int64) ([]db.History, error) {
187+ m.ctrl.T.Helper()
188+ ret := m.ctrl.Call(m, "GetHistoryWithOffset", ctx, limit, offset)
189+ ret0, _ := ret[0].([]db.History)
190+ ret1, _ := ret[1].(error)
191+ return ret0, ret1
192+}
193+
194+// GetHistoryWithOffset indicates an expected call of GetHistoryWithOffset.
195+func (mr *MockHistoryQuerierMockRecorder) GetHistoryWithOffset(ctx, limit, offset any) *gomock.Call {
196+ mr.mock.ctrl.T.Helper()
197+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryWithOffset", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistoryWithOffset), ctx, limit, offset)
198+}
199+
134 // SearchHistory mocks base method.200 // SearchHistory mocks base method.
135 func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) {201 func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) {
136 m.ctrl.T.Helper()202 m.ctrl.T.Helper()
@@ -141,53 +207,141 @@ func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2
141 }207 }
142 208
143 // SearchHistory indicates an expected call of SearchHistory.209 // SearchHistory indicates an expected call of SearchHistory.
144-func (mr *MockHistoryQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit interface{}) *gomock.Call {210+func (mr *MockHistoryQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit any) *gomock.Call {
145 mr.mock.ctrl.T.Helper()211 mr.mock.ctrl.T.Helper()
146 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).SearchHistory), ctx, column1, column2, limit)212 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).SearchHistory), ctx, column1, column2, limit)
147 }213 }
148 214
149-// MockShortcutQuerier is a mock of ShortcutQuerier interface.215+// UpdateHistoryFavicon mocks base method.
150-type MockShortcutQuerier struct {216+func (m *MockHistoryQuerier) UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error {
217+ m.ctrl.T.Helper()
218+ ret := m.ctrl.Call(m, "UpdateHistoryFavicon", ctx, faviconUrl, url)
219+ ret0, _ := ret[0].(error)
220+ return ret0
221+}
222+
223+// UpdateHistoryFavicon indicates an expected call of UpdateHistoryFavicon.
224+func (mr *MockHistoryQuerierMockRecorder) UpdateHistoryFavicon(ctx, faviconUrl, url any) *gomock.Call {
225+ mr.mock.ctrl.T.Helper()
226+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateHistoryFavicon", reflect.TypeOf((*MockHistoryQuerier)(nil).UpdateHistoryFavicon), ctx, faviconUrl, url)
227+}
228+
229+// MockCertificateQuerier is a mock of CertificateQuerier interface.
230+type MockCertificateQuerier struct {
151 ctrl *gomock.Controller231 ctrl *gomock.Controller
152- recorder *MockShortcutQuerierMockRecorder232+ recorder *MockCertificateQuerierMockRecorder
233+ isgomock struct{}
153 }234 }
154 235
155-// MockShortcutQuerierMockRecorder is the mock recorder for MockShortcutQuerier.236+// MockCertificateQuerierMockRecorder is the mock recorder for MockCertificateQuerier.
156-type MockShortcutQuerierMockRecorder struct {237+type MockCertificateQuerierMockRecorder struct {
157- mock *MockShortcutQuerier238+ mock *MockCertificateQuerier
158 }239 }
159 240
160-// NewMockShortcutQuerier creates a new mock instance.241+// NewMockCertificateQuerier creates a new mock instance.
161-func NewMockShortcutQuerier(ctrl *gomock.Controller) *MockShortcutQuerier {242+func NewMockCertificateQuerier(ctrl *gomock.Controller) *MockCertificateQuerier {
162- mock := &MockShortcutQuerier{ctrl: ctrl}243+ mock := &MockCertificateQuerier{ctrl: ctrl}
163- mock.recorder = &MockShortcutQuerierMockRecorder{mock}244+ mock.recorder = &MockCertificateQuerierMockRecorder{mock}
164 return mock245 return mock
165 }246 }
166 247
167 // EXPECT returns an object that allows the caller to indicate expected use.248 // EXPECT returns an object that allows the caller to indicate expected use.
168-func (m *MockShortcutQuerier) EXPECT() *MockShortcutQuerierMockRecorder {249+func (m *MockCertificateQuerier) EXPECT() *MockCertificateQuerierMockRecorder {
169 return m.recorder250 return m.recorder
170 }251 }
171 252
172-// GetShortcuts mocks base method.253+// DeleteCertificateValidation mocks base method.
173-func (m *MockShortcutQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) {254+func (m *MockCertificateQuerier) DeleteCertificateValidation(ctx context.Context, hostname, certificateHash string) error {
174 m.ctrl.T.Helper()255 m.ctrl.T.Helper()
175- ret := m.ctrl.Call(m, "GetShortcuts", ctx)256+ ret := m.ctrl.Call(m, "DeleteCertificateValidation", ctx, hostname, certificateHash)
176- ret0, _ := ret[0].([]db.Shortcut)257+ ret0, _ := ret[0].(error)
258+ return ret0
259+}
260+
261+// DeleteCertificateValidation indicates an expected call of DeleteCertificateValidation.
262+func (mr *MockCertificateQuerierMockRecorder) DeleteCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call {
263+ mr.mock.ctrl.T.Helper()
264+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCertificateValidation", reflect.TypeOf((*MockCertificateQuerier)(nil).DeleteCertificateValidation), ctx, hostname, certificateHash)
265+}
266+
267+// DeleteExpiredCertificateValidations mocks base method.
268+func (m *MockCertificateQuerier) DeleteExpiredCertificateValidations(ctx context.Context) error {
269+ m.ctrl.T.Helper()
270+ ret := m.ctrl.Call(m, "DeleteExpiredCertificateValidations", ctx)
271+ ret0, _ := ret[0].(error)
272+ return ret0
273+}
274+
275+// DeleteExpiredCertificateValidations indicates an expected call of DeleteExpiredCertificateValidations.
276+func (mr *MockCertificateQuerierMockRecorder) DeleteExpiredCertificateValidations(ctx any) *gomock.Call {
277+ mr.mock.ctrl.T.Helper()
278+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExpiredCertificateValidations", reflect.TypeOf((*MockCertificateQuerier)(nil).DeleteExpiredCertificateValidations), ctx)
279+}
280+
281+// GetCertificateValidation mocks base method.
282+func (m *MockCertificateQuerier) GetCertificateValidation(ctx context.Context, hostname, certificateHash string) (db.CertificateValidation, error) {
283+ m.ctrl.T.Helper()
284+ ret := m.ctrl.Call(m, "GetCertificateValidation", ctx, hostname, certificateHash)
285+ ret0, _ := ret[0].(db.CertificateValidation)
286+ ret1, _ := ret[1].(error)
287+ return ret0, ret1
288+}
289+
290+// GetCertificateValidation indicates an expected call of GetCertificateValidation.
291+func (mr *MockCertificateQuerierMockRecorder) GetCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call {
292+ mr.mock.ctrl.T.Helper()
293+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidation", reflect.TypeOf((*MockCertificateQuerier)(nil).GetCertificateValidation), ctx, hostname, certificateHash)
294+}
295+
296+// GetCertificateValidationByHostname mocks base method.
297+func (m *MockCertificateQuerier) GetCertificateValidationByHostname(ctx context.Context, hostname string) (db.CertificateValidation, error) {
298+ m.ctrl.T.Helper()
299+ ret := m.ctrl.Call(m, "GetCertificateValidationByHostname", ctx, hostname)
300+ ret0, _ := ret[0].(db.CertificateValidation)
301+ ret1, _ := ret[1].(error)
302+ return ret0, ret1
303+}
304+
305+// GetCertificateValidationByHostname indicates an expected call of GetCertificateValidationByHostname.
306+func (mr *MockCertificateQuerierMockRecorder) GetCertificateValidationByHostname(ctx, hostname any) *gomock.Call {
307+ mr.mock.ctrl.T.Helper()
308+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidationByHostname", reflect.TypeOf((*MockCertificateQuerier)(nil).GetCertificateValidationByHostname), ctx, hostname)
309+}
310+
311+// ListCertificateValidations mocks base method.
312+func (m *MockCertificateQuerier) ListCertificateValidations(ctx context.Context) ([]db.CertificateValidation, error) {
313+ m.ctrl.T.Helper()
314+ ret := m.ctrl.Call(m, "ListCertificateValidations", ctx)
315+ ret0, _ := ret[0].([]db.CertificateValidation)
177 ret1, _ := ret[1].(error)316 ret1, _ := ret[1].(error)
178 return ret0, ret1317 return ret0, ret1
179 }318 }
180 319
181-// GetShortcuts indicates an expected call of GetShortcuts.320+// ListCertificateValidations indicates an expected call of ListCertificateValidations.
182-func (mr *MockShortcutQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call {321+func (mr *MockCertificateQuerierMockRecorder) ListCertificateValidations(ctx any) *gomock.Call {
322+ mr.mock.ctrl.T.Helper()
323+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCertificateValidations", reflect.TypeOf((*MockCertificateQuerier)(nil).ListCertificateValidations), ctx)
324+}
325+
326+// StoreCertificateValidation mocks base method.
327+func (m *MockCertificateQuerier) StoreCertificateValidation(ctx context.Context, hostname, certificateHash, userDecision string, expiresAt sql.NullTime) error {
328+ m.ctrl.T.Helper()
329+ ret := m.ctrl.Call(m, "StoreCertificateValidation", ctx, hostname, certificateHash, userDecision, expiresAt)
330+ ret0, _ := ret[0].(error)
331+ return ret0
332+}
333+
334+// StoreCertificateValidation indicates an expected call of StoreCertificateValidation.
335+func (mr *MockCertificateQuerierMockRecorder) StoreCertificateValidation(ctx, hostname, certificateHash, userDecision, expiresAt any) *gomock.Call {
183 mr.mock.ctrl.T.Helper()336 mr.mock.ctrl.T.Helper()
184- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockShortcutQuerier)(nil).GetShortcuts), ctx)337+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreCertificateValidation", reflect.TypeOf((*MockCertificateQuerier)(nil).StoreCertificateValidation), ctx, hostname, certificateHash, userDecision, expiresAt)
185 }338 }
186 339
187 // MockDatabaseQuerier is a mock of DatabaseQuerier interface.340 // MockDatabaseQuerier is a mock of DatabaseQuerier interface.
188 type MockDatabaseQuerier struct {341 type MockDatabaseQuerier struct {
189 ctrl *gomock.Controller342 ctrl *gomock.Controller
190 recorder *MockDatabaseQuerierMockRecorder343 recorder *MockDatabaseQuerierMockRecorder
344+ isgomock struct{}
191 }345 }
192 346
193 // MockDatabaseQuerierMockRecorder is the mock recorder for MockDatabaseQuerier.347 // MockDatabaseQuerierMockRecorder is the mock recorder for MockDatabaseQuerier.
@@ -216,11 +370,53 @@ func (m *MockDatabaseQuerier) AddOrUpdateHistory(ctx context.Context, url string
216 }370 }
217 371
218 // AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory.372 // AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory.
219-func (mr *MockDatabaseQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title interface{}) *gomock.Call {373+func (mr *MockDatabaseQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title any) *gomock.Call {
220 mr.mock.ctrl.T.Helper()374 mr.mock.ctrl.T.Helper()
221 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).AddOrUpdateHistory), ctx, url, title)375 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).AddOrUpdateHistory), ctx, url, title)
222 }376 }
223 377
378+// DeleteCertificateValidation mocks base method.
379+func (m *MockDatabaseQuerier) DeleteCertificateValidation(ctx context.Context, hostname, certificateHash string) error {
380+ m.ctrl.T.Helper()
381+ ret := m.ctrl.Call(m, "DeleteCertificateValidation", ctx, hostname, certificateHash)
382+ ret0, _ := ret[0].(error)
383+ return ret0
384+}
385+
386+// DeleteCertificateValidation indicates an expected call of DeleteCertificateValidation.
387+func (mr *MockDatabaseQuerierMockRecorder) DeleteCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call {
388+ mr.mock.ctrl.T.Helper()
389+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCertificateValidation", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteCertificateValidation), ctx, hostname, certificateHash)
390+}
391+
392+// DeleteExpiredCertificateValidations mocks base method.
393+func (m *MockDatabaseQuerier) DeleteExpiredCertificateValidations(ctx context.Context) error {
394+ m.ctrl.T.Helper()
395+ ret := m.ctrl.Call(m, "DeleteExpiredCertificateValidations", ctx)
396+ ret0, _ := ret[0].(error)
397+ return ret0
398+}
399+
400+// DeleteExpiredCertificateValidations indicates an expected call of DeleteExpiredCertificateValidations.
401+func (mr *MockDatabaseQuerierMockRecorder) DeleteExpiredCertificateValidations(ctx any) *gomock.Call {
402+ mr.mock.ctrl.T.Helper()
403+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExpiredCertificateValidations", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteExpiredCertificateValidations), ctx)
404+}
405+
406+// DeleteHistory mocks base method.
407+func (m *MockDatabaseQuerier) DeleteHistory(ctx context.Context, id int64) error {
408+ m.ctrl.T.Helper()
409+ ret := m.ctrl.Call(m, "DeleteHistory", ctx, id)
410+ ret0, _ := ret[0].(error)
411+ return ret0
412+}
413+
414+// DeleteHistory indicates an expected call of DeleteHistory.
415+func (mr *MockDatabaseQuerierMockRecorder) DeleteHistory(ctx, id any) *gomock.Call {
416+ mr.mock.ctrl.T.Helper()
417+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteHistory), ctx, id)
418+}
419+
224 // DeleteZoomLevel mocks base method.420 // DeleteZoomLevel mocks base method.
225 func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string) error {421 func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string) error {
226 m.ctrl.T.Helper()422 m.ctrl.T.Helper()
@@ -230,11 +426,41 @@ func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string
230 }426 }
231 427
232 // DeleteZoomLevel indicates an expected call of DeleteZoomLevel.428 // DeleteZoomLevel indicates an expected call of DeleteZoomLevel.
233-func (mr *MockDatabaseQuerierMockRecorder) DeleteZoomLevel(ctx, domain interface{}) *gomock.Call {429+func (mr *MockDatabaseQuerierMockRecorder) DeleteZoomLevel(ctx, domain any) *gomock.Call {
234 mr.mock.ctrl.T.Helper()430 mr.mock.ctrl.T.Helper()
235 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteZoomLevel), ctx, domain)431 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteZoomLevel), ctx, domain)
236 }432 }
237 433
434+// GetCertificateValidation mocks base method.
435+func (m *MockDatabaseQuerier) GetCertificateValidation(ctx context.Context, hostname, certificateHash string) (db.CertificateValidation, error) {
436+ m.ctrl.T.Helper()
437+ ret := m.ctrl.Call(m, "GetCertificateValidation", ctx, hostname, certificateHash)
438+ ret0, _ := ret[0].(db.CertificateValidation)
439+ ret1, _ := ret[1].(error)
440+ return ret0, ret1
441+}
442+
443+// GetCertificateValidation indicates an expected call of GetCertificateValidation.
444+func (mr *MockDatabaseQuerierMockRecorder) GetCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call {
445+ mr.mock.ctrl.T.Helper()
446+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidation", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetCertificateValidation), ctx, hostname, certificateHash)
447+}
448+
449+// GetCertificateValidationByHostname mocks base method.
450+func (m *MockDatabaseQuerier) GetCertificateValidationByHostname(ctx context.Context, hostname string) (db.CertificateValidation, error) {
451+ m.ctrl.T.Helper()
452+ ret := m.ctrl.Call(m, "GetCertificateValidationByHostname", ctx, hostname)
453+ ret0, _ := ret[0].(db.CertificateValidation)
454+ ret1, _ := ret[1].(error)
455+ return ret0, ret1
456+}
457+
458+// GetCertificateValidationByHostname indicates an expected call of GetCertificateValidationByHostname.
459+func (mr *MockDatabaseQuerierMockRecorder) GetCertificateValidationByHostname(ctx, hostname any) *gomock.Call {
460+ mr.mock.ctrl.T.Helper()
461+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidationByHostname", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetCertificateValidationByHostname), ctx, hostname)
462+}
463+
238 // GetHistory mocks base method.464 // GetHistory mocks base method.
239 func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {465 func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {
240 m.ctrl.T.Helper()466 m.ctrl.T.Helper()
@@ -245,24 +471,39 @@ func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db
245 }471 }
246 472
247 // GetHistory indicates an expected call of GetHistory.473 // GetHistory indicates an expected call of GetHistory.
248-func (mr *MockDatabaseQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call {474+func (mr *MockDatabaseQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call {
249 mr.mock.ctrl.T.Helper()475 mr.mock.ctrl.T.Helper()
250 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistory), ctx, limit)476 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistory), ctx, limit)
251 }477 }
252 478
253-// GetShortcuts mocks base method.479+// GetHistoryEntry mocks base method.
254-func (m *MockDatabaseQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) {480+func (m *MockDatabaseQuerier) GetHistoryEntry(ctx context.Context, url string) (db.History, error) {
255 m.ctrl.T.Helper()481 m.ctrl.T.Helper()
256- ret := m.ctrl.Call(m, "GetShortcuts", ctx)482+ ret := m.ctrl.Call(m, "GetHistoryEntry", ctx, url)
257- ret0, _ := ret[0].([]db.Shortcut)483+ ret0, _ := ret[0].(db.History)
258 ret1, _ := ret[1].(error)484 ret1, _ := ret[1].(error)
259 return ret0, ret1485 return ret0, ret1
260 }486 }
261 487
262-// GetShortcuts indicates an expected call of GetShortcuts.488+// GetHistoryEntry indicates an expected call of GetHistoryEntry.
263-func (mr *MockDatabaseQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call {489+func (mr *MockDatabaseQuerierMockRecorder) GetHistoryEntry(ctx, url any) *gomock.Call {
264 mr.mock.ctrl.T.Helper()490 mr.mock.ctrl.T.Helper()
265- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetShortcuts), ctx)491+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryEntry", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistoryEntry), ctx, url)
492+}
493+
494+// GetHistoryWithOffset mocks base method.
495+func (m *MockDatabaseQuerier) GetHistoryWithOffset(ctx context.Context, limit, offset int64) ([]db.History, error) {
496+ m.ctrl.T.Helper()
497+ ret := m.ctrl.Call(m, "GetHistoryWithOffset", ctx, limit, offset)
498+ ret0, _ := ret[0].([]db.History)
499+ ret1, _ := ret[1].(error)
500+ return ret0, ret1
501+}
502+
503+// GetHistoryWithOffset indicates an expected call of GetHistoryWithOffset.
504+func (mr *MockDatabaseQuerierMockRecorder) GetHistoryWithOffset(ctx, limit, offset any) *gomock.Call {
505+ mr.mock.ctrl.T.Helper()
506+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryWithOffset", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistoryWithOffset), ctx, limit, offset)
266 }507 }
267 508
268 // GetZoomLevel mocks base method.509 // GetZoomLevel mocks base method.
@@ -275,11 +516,41 @@ func (m *MockDatabaseQuerier) GetZoomLevel(ctx context.Context, domain string) (
275 }516 }
276 517
277 // GetZoomLevel indicates an expected call of GetZoomLevel.518 // GetZoomLevel indicates an expected call of GetZoomLevel.
278-func (mr *MockDatabaseQuerierMockRecorder) GetZoomLevel(ctx, domain interface{}) *gomock.Call {519+func (mr *MockDatabaseQuerierMockRecorder) GetZoomLevel(ctx, domain any) *gomock.Call {
279 mr.mock.ctrl.T.Helper()520 mr.mock.ctrl.T.Helper()
280 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetZoomLevel), ctx, domain)521 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetZoomLevel), ctx, domain)
281 }522 }
282 523
524+// ListCertificateValidations mocks base method.
525+func (m *MockDatabaseQuerier) ListCertificateValidations(ctx context.Context) ([]db.CertificateValidation, error) {
526+ m.ctrl.T.Helper()
527+ ret := m.ctrl.Call(m, "ListCertificateValidations", ctx)
528+ ret0, _ := ret[0].([]db.CertificateValidation)
529+ ret1, _ := ret[1].(error)
530+ return ret0, ret1
531+}
532+
533+// ListCertificateValidations indicates an expected call of ListCertificateValidations.
534+func (mr *MockDatabaseQuerierMockRecorder) ListCertificateValidations(ctx any) *gomock.Call {
535+ mr.mock.ctrl.T.Helper()
536+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCertificateValidations", reflect.TypeOf((*MockDatabaseQuerier)(nil).ListCertificateValidations), ctx)
537+}
538+
539+// ListZoomLevels mocks base method.
540+func (m *MockDatabaseQuerier) ListZoomLevels(ctx context.Context) ([]db.ZoomLevel, error) {
541+ m.ctrl.T.Helper()
542+ ret := m.ctrl.Call(m, "ListZoomLevels", ctx)
543+ ret0, _ := ret[0].([]db.ZoomLevel)
544+ ret1, _ := ret[1].(error)
545+ return ret0, ret1
546+}
547+
548+// ListZoomLevels indicates an expected call of ListZoomLevels.
549+func (mr *MockDatabaseQuerierMockRecorder) ListZoomLevels(ctx any) *gomock.Call {
550+ mr.mock.ctrl.T.Helper()
551+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListZoomLevels", reflect.TypeOf((*MockDatabaseQuerier)(nil).ListZoomLevels), ctx)
552+}
553+
283 // SearchHistory mocks base method.554 // SearchHistory mocks base method.
284 func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) {555 func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) {
285 m.ctrl.T.Helper()556 m.ctrl.T.Helper()
@@ -290,7 +561,7 @@ func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column
290 }561 }
291 562
292 // SearchHistory indicates an expected call of SearchHistory.563 // SearchHistory indicates an expected call of SearchHistory.
293-func (mr *MockDatabaseQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit interface{}) *gomock.Call {564+func (mr *MockDatabaseQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit any) *gomock.Call {
294 mr.mock.ctrl.T.Helper()565 mr.mock.ctrl.T.Helper()
295 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).SearchHistory), ctx, column1, column2, limit)566 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).SearchHistory), ctx, column1, column2, limit)
296 }567 }
@@ -304,7 +575,35 @@ func (m *MockDatabaseQuerier) SetZoomLevel(ctx context.Context, domain string, z
304 }575 }
305 576
306 // SetZoomLevel indicates an expected call of SetZoomLevel.577 // SetZoomLevel indicates an expected call of SetZoomLevel.
307-func (mr *MockDatabaseQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel interface{}) *gomock.Call {578+func (mr *MockDatabaseQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel any) *gomock.Call {
308 mr.mock.ctrl.T.Helper()579 mr.mock.ctrl.T.Helper()
309 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel)580 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel)
310 }581 }
582+
583+// StoreCertificateValidation mocks base method.
584+func (m *MockDatabaseQuerier) StoreCertificateValidation(ctx context.Context, hostname, certificateHash, userDecision string, expiresAt sql.NullTime) error {
585+ m.ctrl.T.Helper()
586+ ret := m.ctrl.Call(m, "StoreCertificateValidation", ctx, hostname, certificateHash, userDecision, expiresAt)
587+ ret0, _ := ret[0].(error)
588+ return ret0
589+}
590+
591+// StoreCertificateValidation indicates an expected call of StoreCertificateValidation.
592+func (mr *MockDatabaseQuerierMockRecorder) StoreCertificateValidation(ctx, hostname, certificateHash, userDecision, expiresAt any) *gomock.Call {
593+ mr.mock.ctrl.T.Helper()
594+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreCertificateValidation", reflect.TypeOf((*MockDatabaseQuerier)(nil).StoreCertificateValidation), ctx, hostname, certificateHash, userDecision, expiresAt)
595+}
596+
597+// UpdateHistoryFavicon mocks base method.
598+func (m *MockDatabaseQuerier) UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error {
599+ m.ctrl.T.Helper()
600+ ret := m.ctrl.Call(m, "UpdateHistoryFavicon", ctx, faviconUrl, url)
601+ ret0, _ := ret[0].(error)
602+ return ret0
603+}
604+
605+// UpdateHistoryFavicon indicates an expected call of UpdateHistoryFavicon.
606+func (mr *MockDatabaseQuerierMockRecorder) UpdateHistoryFavicon(ctx, faviconUrl, url any) *gomock.Call {
607+ mr.mock.ctrl.T.Helper()
608+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateHistoryFavicon", reflect.TypeOf((*MockDatabaseQuerier)(nil).UpdateHistoryFavicon), ctx, faviconUrl, url)
609+}
M internal/db/models.go +15 -8
@@ -17,6 +17,21 @@ type CertificateValidation struct {
1717 ExpiresAt sql.NullTime `json:"expires_at"`
1818 }
1919
20+type FuzzyCacheMetadatum struct {
21+ ID int64 `json:"id"`
22+ Version int64 `json:"version"`
23+ EntryCount int64 `json:"entry_count"`
24+ LastModified int64 `json:"last_modified"`
25+ EntriesHash string `json:"entries_hash"`
26+}
27+
28+type FuzzyCacheStructure struct {
29+ ID int64 `json:"id"`
30+ TrigramIndex []byte `json:"trigram_index"`
31+ PrefixTrie []byte `json:"prefix_trie"`
32+ SortedIndex []byte `json:"sorted_index"`
33+}
34+
2035 type History struct {
2136 ID int64 `json:"id"`
2237 Url string `json:"url"`
@@ -27,14 +42,6 @@ type History struct {
2742 FaviconUrl sql.NullString `json:"favicon_url"`
2843 }
2944
30-type Shortcut struct {
31- ID int64 `json:"id"`
32- Shortcut string `json:"shortcut"`
33- UrlTemplate string `json:"url_template"`
34- Description sql.NullString `json:"description"`
35- CreatedAt sql.NullTime `json:"created_at"`
36-}
37-
3845 type ZoomLevel struct {
3946 Domain string `json:"domain"`
4047 ZoomFactor float64 `json:"zoom_factor"`
@@ -17,6 +17,21 @@ type CertificateValidation struct {
17 ExpiresAt sql.NullTime `json:"expires_at"`17 ExpiresAt sql.NullTime `json:"expires_at"`
18 }18 }
19 19
20+type FuzzyCacheMetadatum struct {
21+ ID int64 `json:"id"`
22+ Version int64 `json:"version"`
23+ EntryCount int64 `json:"entry_count"`
24+ LastModified int64 `json:"last_modified"`
25+ EntriesHash string `json:"entries_hash"`
26+}
27+
28+type FuzzyCacheStructure struct {
29+ ID int64 `json:"id"`
30+ TrigramIndex []byte `json:"trigram_index"`
31+ PrefixTrie []byte `json:"prefix_trie"`
32+ SortedIndex []byte `json:"sorted_index"`
33+}
34+
20 type History struct {35 type History struct {
21 ID int64 `json:"id"`36 ID int64 `json:"id"`
22 Url string `json:"url"`37 Url string `json:"url"`
@@ -27,14 +42,6 @@ type History struct {
27 FaviconUrl sql.NullString `json:"favicon_url"`42 FaviconUrl sql.NullString `json:"favicon_url"`
28 }43 }
29 44
30-type Shortcut struct {
31- ID int64 `json:"id"`
32- Shortcut string `json:"shortcut"`
33- UrlTemplate string `json:"url_template"`
34- Description sql.NullString `json:"description"`
35- CreatedAt sql.NullTime `json:"created_at"`
36-}
37-
38 type ZoomLevel struct {45 type ZoomLevel struct {
39 Domain string `json:"domain"`46 Domain string `json:"domain"`
40 ZoomFactor float64 `json:"zoom_factor"`47 ZoomFactor float64 `json:"zoom_factor"`
M internal/db/querier.go +1 -1
@@ -23,11 +23,11 @@ type Querier interface {
2323 GetHistory(ctx context.Context, limit int64) ([]History, error)
2424 GetHistoryEntry(ctx context.Context, url string) (History, error)
2525 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
26- GetShortcuts(ctx context.Context) ([]Shortcut, error)
2726 // Get zoom level for a specific domain
2827 GetZoomLevel(ctx context.Context, domain string) (float64, error)
2928 // Get zoom level for domain with default fallback
3029 GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error)
30+ ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error)
3131 // List all zoom level settings ordered by most recently updated
3232 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
3333 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
@@ -23,11 +23,11 @@ type Querier interface {
23 GetHistory(ctx context.Context, limit int64) ([]History, error)23 GetHistory(ctx context.Context, limit int64) ([]History, error)
24 GetHistoryEntry(ctx context.Context, url string) (History, error)24 GetHistoryEntry(ctx context.Context, url string) (History, error)
25 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)25 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
26- GetShortcuts(ctx context.Context) ([]Shortcut, error)
27 // Get zoom level for a specific domain26 // Get zoom level for a specific domain
28 GetZoomLevel(ctx context.Context, domain string) (float64, error)27 GetZoomLevel(ctx context.Context, domain string) (float64, error)
29 // Get zoom level for domain with default fallback28 // Get zoom level for domain with default fallback
30 GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error)29 GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error)
30+ ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error)
31 // List all zoom level settings ordered by most recently updated31 // List all zoom level settings ordered by most recently updated
32 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)32 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
33 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)33 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
M internal/db/queries/certificate_validations.sql +7 -2
@@ -20,5 +20,10 @@ DELETE FROM certificate_validations
2020 WHERE expires_at IS NOT NULL AND expires_at <= datetime('now');
2121
2222 -- name: DeleteCertificateValidation :exec
23-DELETE FROM certificate_validations
24-WHERE hostname = ? AND certificate_hash = ?;⏎̸
23+DELETE FROM certificate_validations
24+WHERE hostname = ? AND certificate_hash = ?;
25+
26+-- name: ListCertificateValidations :many
27+SELECT * FROM certificate_validations
28+WHERE (expires_at IS NULL OR expires_at > datetime('now'))
29+ORDER BY hostname;⏎̸
@@ -20,5 +20,10 @@ DELETE FROM certificate_validations
20 WHERE expires_at IS NOT NULL AND expires_at <= datetime('now');20 WHERE expires_at IS NOT NULL AND expires_at <= datetime('now');
21 21
22 -- name: DeleteCertificateValidation :exec22 -- name: DeleteCertificateValidation :exec
23-DELETE FROM certificate_validations 23+DELETE FROM certificate_validations
24-WHERE hostname = ? AND certificate_hash = ?;⏎̸24+WHERE hostname = ? AND certificate_hash = ?;
25+
26+-- name: ListCertificateValidations :many
27+SELECT * FROM certificate_validations
28+WHERE (expires_at IS NULL OR expires_at > datetime('now'))
29+ORDER BY hostname;⏎̸
M internal/db/queries/history.sql +1 -6
@@ -39,9 +39,4 @@ LIMIT 1;
3939
4040 -- name: DeleteHistory :exec
4141 DELETE FROM history
42-WHERE id = ?;
43-
44--- name: GetShortcuts :many
45-SELECT id, shortcut, url_template, description, created_at
46-FROM shortcuts
47-ORDER BY shortcut;⏎̸
42+WHERE id = ?;⏎̸
@@ -39,9 +39,4 @@ LIMIT 1;
39 39
40 -- name: DeleteHistory :exec40 -- name: DeleteHistory :exec
41 DELETE FROM history41 DELETE FROM history
42-WHERE id = ?;42+WHERE id = ?;⏎̸
43-
44--- name: GetShortcuts :many
45-SELECT id, shortcut, url_template, description, created_at
46-FROM shortcuts
47-ORDER BY shortcut;⏎̸
M internal/filtering/updater.go +11 -2
@@ -4,6 +4,7 @@ import (
44 "context"
55 "crypto/sha256"
66 "fmt"
7+ "log"
78 "net/http"
89 "strings"
910 "sync"
@@ -138,7 +139,11 @@ func (fu *FilterUpdater) checkSingleUpdate(ctx context.Context, url string) (*Fi
138139 if err != nil {
139140 return nil, fmt.Errorf("HEAD request failed: %w", err)
140141 }
141- defer resp.Body.Close()
142+ defer func() {
143+ if err := resp.Body.Close(); err != nil {
144+ log.Printf("warning: failed to close response body: %v", err)
145+ }
146+ }()
142147
143148 if resp.StatusCode != http.StatusOK {
144149 return nil, fmt.Errorf("HEAD request returned %d", resp.StatusCode)
@@ -183,7 +188,11 @@ func (fu *FilterUpdater) downloadUpdate(ctx context.Context, url, lastModified,
183188 if err != nil {
184189 return nil, fmt.Errorf("GET request failed: %w", err)
185190 }
186- defer resp.Body.Close()
191+ defer func() {
192+ if err := resp.Body.Close(); err != nil {
193+ log.Printf("warning: failed to close response body: %v", err)
194+ }
195+ }()
187196
188197 if resp.StatusCode != http.StatusOK {
189198 return nil, fmt.Errorf("GET request returned %d", resp.StatusCode)
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "crypto/sha256"5 "crypto/sha256"
6 "fmt"6 "fmt"
7+ "log"
7 "net/http"8 "net/http"
8 "strings"9 "strings"
9 "sync"10 "sync"
@@ -138,7 +139,11 @@ func (fu *FilterUpdater) checkSingleUpdate(ctx context.Context, url string) (*Fi
138 if err != nil {139 if err != nil {
139 return nil, fmt.Errorf("HEAD request failed: %w", err)140 return nil, fmt.Errorf("HEAD request failed: %w", err)
140 }141 }
141- defer resp.Body.Close()142+ defer func() {
143+ if err := resp.Body.Close(); err != nil {
144+ log.Printf("warning: failed to close response body: %v", err)
145+ }
146+ }()
142 147
143 if resp.StatusCode != http.StatusOK {148 if resp.StatusCode != http.StatusOK {
144 return nil, fmt.Errorf("HEAD request returned %d", resp.StatusCode)149 return nil, fmt.Errorf("HEAD request returned %d", resp.StatusCode)
@@ -183,7 +188,11 @@ func (fu *FilterUpdater) downloadUpdate(ctx context.Context, url, lastModified,
183 if err != nil {188 if err != nil {
184 return nil, fmt.Errorf("GET request failed: %w", err)189 return nil, fmt.Errorf("GET request failed: %w", err)
185 }190 }
186- defer resp.Body.Close()191+ defer func() {
192+ if err := resp.Body.Close(); err != nil {
193+ log.Printf("warning: failed to close response body: %v", err)
194+ }
195+ }()
187 196
188 if resp.StatusCode != http.StatusOK {197 if resp.StatusCode != http.StatusOK {
189 return nil, fmt.Errorf("GET request returned %d", resp.StatusCode)198 return nil, fmt.Errorf("GET request returned %d", resp.StatusCode)
M internal/logging/capture.go +37 -12
@@ -4,6 +4,7 @@ import (
44 "bufio"
55 "fmt"
66 "io"
7+ "log"
78 "os"
89 "strings"
910 "time"
@@ -46,8 +47,12 @@ func (c *OutputCapture) Start() error {
4647 // Create pipes for stderr
4748 stderrR, stderrW, err := os.Pipe()
4849 if err != nil {
49- stdoutR.Close()
50- stdoutW.Close()
50+ if closeErr := stdoutR.Close(); closeErr != nil {
51+ log.Printf("failed to close stdout read pipe: %v", closeErr)
52+ }
53+ if closeErr := stdoutW.Close(); closeErr != nil {
54+ log.Printf("failed to close stdout write pipe: %v", closeErr)
55+ }
5156 return err
5257 }
5358
@@ -62,8 +67,12 @@ func (c *OutputCapture) Start() error {
6267 os.Stderr = stderrW
6368
6469 // Also redirect file descriptors at syscall level for C code
65- unix.Dup3(int(stdoutW.Fd()), 1, 0)
66- unix.Dup3(int(stderrW.Fd()), 2, 0)
70+ if err := unix.Dup3(int(stdoutW.Fd()), 1, 0); err != nil {
71+ log.Printf("failed to redirect stdout: %v", err)
72+ }
73+ if err := unix.Dup3(int(stderrW.Fd()), 2, 0); err != nil {
74+ log.Printf("failed to redirect stderr: %v", err)
75+ }
6776
6877 // Start goroutines to read and log
6978 go c.pipeToLogger(stdoutR, "STDOUT")
@@ -85,21 +94,33 @@ func (c *OutputCapture) Stop() {
8594 os.Stderr = c.originalStderr
8695
8796 // Restore file descriptors
88- unix.Dup3(int(c.originalStdout.Fd()), 1, 0)
89- unix.Dup3(int(c.originalStderr.Fd()), 2, 0)
97+ if err := unix.Dup3(int(c.originalStdout.Fd()), 1, 0); err != nil {
98+ log.Printf("failed to restore stdout: %v", err)
99+ }
100+ if err := unix.Dup3(int(c.originalStderr.Fd()), 2, 0); err != nil {
101+ log.Printf("failed to restore stderr: %v", err)
102+ }
90103
91104 // Close pipes
92105 if c.stdoutWrite != nil {
93- c.stdoutWrite.Close()
106+ if err := c.stdoutWrite.Close(); err != nil {
107+ log.Printf("failed to close stdout write pipe: %v", err)
108+ }
94109 }
95110 if c.stderrWrite != nil {
96- c.stderrWrite.Close()
111+ if err := c.stderrWrite.Close(); err != nil {
112+ log.Printf("failed to close stderr write pipe: %v", err)
113+ }
97114 }
98115 if c.stdoutRead != nil {
99- c.stdoutRead.Close()
116+ if err := c.stdoutRead.Close(); err != nil {
117+ log.Printf("failed to close stdout read pipe: %v", err)
118+ }
100119 }
101120 if c.stderrRead != nil {
102- c.stderrRead.Close()
121+ if err := c.stderrRead.Close(); err != nil {
122+ log.Printf("failed to close stderr read pipe: %v", err)
123+ }
103124 }
104125
105126 c.started = false
@@ -121,9 +142,13 @@ func (c *OutputCapture) pipeToLogger(r io.Reader, prefix string) {
121142 timestamp := time.Now().Format("2006-01-02 15:04:05")
122143 logLine := fmt.Sprintf("[%s] INFO [%s] %s\n", timestamp, prefix, line)
123144 if prefix == "STDERR" {
124- c.originalStderr.WriteString(logLine)
145+ if _, err := c.originalStderr.WriteString(logLine); err != nil {
146+ log.Printf("failed to write to stderr: %v", err)
147+ }
125148 } else {
126- c.originalStdout.WriteString(logLine)
149+ if _, err := c.originalStdout.WriteString(logLine); err != nil {
150+ log.Printf("failed to write to stdout: %v", err)
151+ }
127152 }
128153 }
129154 }
@@ -4,6 +4,7 @@ import (
4 "bufio"4 "bufio"
5 "fmt"5 "fmt"
6 "io"6 "io"
7+ "log"
7 "os"8 "os"
8 "strings"9 "strings"
9 "time"10 "time"
@@ -46,8 +47,12 @@ func (c *OutputCapture) Start() error {
46 // Create pipes for stderr47 // Create pipes for stderr
47 stderrR, stderrW, err := os.Pipe()48 stderrR, stderrW, err := os.Pipe()
48 if err != nil {49 if err != nil {
49- stdoutR.Close()50+ if closeErr := stdoutR.Close(); closeErr != nil {
50- stdoutW.Close()51+ log.Printf("failed to close stdout read pipe: %v", closeErr)
52+ }
53+ if closeErr := stdoutW.Close(); closeErr != nil {
54+ log.Printf("failed to close stdout write pipe: %v", closeErr)
55+ }
51 return err56 return err
52 }57 }
53 58
@@ -62,8 +67,12 @@ func (c *OutputCapture) Start() error {
62 os.Stderr = stderrW67 os.Stderr = stderrW
63 68
64 // Also redirect file descriptors at syscall level for C code69 // Also redirect file descriptors at syscall level for C code
65- unix.Dup3(int(stdoutW.Fd()), 1, 0)70+ if err := unix.Dup3(int(stdoutW.Fd()), 1, 0); err != nil {
66- unix.Dup3(int(stderrW.Fd()), 2, 0)71+ log.Printf("failed to redirect stdout: %v", err)
72+ }
73+ if err := unix.Dup3(int(stderrW.Fd()), 2, 0); err != nil {
74+ log.Printf("failed to redirect stderr: %v", err)
75+ }
67 76
68 // Start goroutines to read and log77 // Start goroutines to read and log
69 go c.pipeToLogger(stdoutR, "STDOUT")78 go c.pipeToLogger(stdoutR, "STDOUT")
@@ -85,21 +94,33 @@ func (c *OutputCapture) Stop() {
85 os.Stderr = c.originalStderr94 os.Stderr = c.originalStderr
86 95
87 // Restore file descriptors96 // Restore file descriptors
88- unix.Dup3(int(c.originalStdout.Fd()), 1, 0)97+ if err := unix.Dup3(int(c.originalStdout.Fd()), 1, 0); err != nil {
89- unix.Dup3(int(c.originalStderr.Fd()), 2, 0)98+ log.Printf("failed to restore stdout: %v", err)
99+ }
100+ if err := unix.Dup3(int(c.originalStderr.Fd()), 2, 0); err != nil {
101+ log.Printf("failed to restore stderr: %v", err)
102+ }
90 103
91 // Close pipes104 // Close pipes
92 if c.stdoutWrite != nil {105 if c.stdoutWrite != nil {
93- c.stdoutWrite.Close()106+ if err := c.stdoutWrite.Close(); err != nil {
107+ log.Printf("failed to close stdout write pipe: %v", err)
108+ }
94 }109 }
95 if c.stderrWrite != nil {110 if c.stderrWrite != nil {
96- c.stderrWrite.Close()111+ if err := c.stderrWrite.Close(); err != nil {
112+ log.Printf("failed to close stderr write pipe: %v", err)
113+ }
97 }114 }
98 if c.stdoutRead != nil {115 if c.stdoutRead != nil {
99- c.stdoutRead.Close()116+ if err := c.stdoutRead.Close(); err != nil {
117+ log.Printf("failed to close stdout read pipe: %v", err)
118+ }
100 }119 }
101 if c.stderrRead != nil {120 if c.stderrRead != nil {
102- c.stderrRead.Close()121+ if err := c.stderrRead.Close(); err != nil {
122+ log.Printf("failed to close stderr read pipe: %v", err)
123+ }
103 }124 }
104 125
105 c.started = false126 c.started = false
@@ -121,9 +142,13 @@ func (c *OutputCapture) pipeToLogger(r io.Reader, prefix string) {
121 timestamp := time.Now().Format("2006-01-02 15:04:05")142 timestamp := time.Now().Format("2006-01-02 15:04:05")
122 logLine := fmt.Sprintf("[%s] INFO [%s] %s\n", timestamp, prefix, line)143 logLine := fmt.Sprintf("[%s] INFO [%s] %s\n", timestamp, prefix, line)
123 if prefix == "STDERR" {144 if prefix == "STDERR" {
124- c.originalStderr.WriteString(logLine)145+ if _, err := c.originalStderr.WriteString(logLine); err != nil {
146+ log.Printf("failed to write to stderr: %v", err)
147+ }
125 } else {148 } else {
126- c.originalStdout.WriteString(logLine)149+ if _, err := c.originalStdout.WriteString(logLine); err != nil {
150+ log.Printf("failed to write to stdout: %v", err)
151+ }
127 }152 }
128 }153 }
129 }154 }
M internal/logging/crash.go +3 -1
@@ -67,7 +67,9 @@ func handleCrash(sig os.Signal) {
6767
6868 // Force flush logs
6969 if logger.rotator != nil {
70- logger.rotator.Close()
70+ if err := logger.rotator.Close(); err != nil {
71+ fmt.Fprintf(os.Stderr, "warning: failed to close log rotator: %v\n", err)
72+ }
7173 }
7274
7375 // Exit with error code
@@ -67,7 +67,9 @@ func handleCrash(sig os.Signal) {
67 67
68 // Force flush logs68 // Force flush logs
69 if logger.rotator != nil {69 if logger.rotator != nil {
70- logger.rotator.Close()70+ if err := logger.rotator.Close(); err != nil {
71+ fmt.Fprintf(os.Stderr, "warning: failed to close log rotator: %v\n", err)
72+ }
71 }73 }
72 74
73 // Exit with error code75 // Exit with error code
M internal/logging/logger.go +7 -3
@@ -97,7 +97,7 @@ func Init(logDir string, level string, format string, enableFileLog bool, maxSiz
9797 globalLoggerMux.Lock()
9898 defer globalLoggerMux.Unlock()
9999
100- err := os.MkdirAll(logDir, 0755)
100+ err := os.MkdirAll(logDir, 0750)
101101 if err != nil {
102102 return fmt.Errorf("failed to create log directory: %w", err)
103103 }
@@ -158,7 +158,9 @@ func (l *Logger) writeLog(level LogLevel, message string, source string) {
158158 formatted := l.formatter.Format(level, message, source)
159159
160160 for _, output := range l.outputs {
161- output.Write([]byte(formatted))
161+ if _, err := output.Write([]byte(formatted)); err != nil {
162+ fmt.Fprintf(os.Stderr, "failed to write log output: %v\n", err)
163+ }
162164 }
163165 }
164166
@@ -180,7 +182,9 @@ func (l *Logger) WriteFileOnly(level LogLevel, message string, source string) {
180182 // Write only to file outputs (skip stdout)
181183 for _, output := range l.outputs {
182184 if output != os.Stdout {
183- output.Write([]byte(formatted))
185+ if _, err := output.Write([]byte(formatted)); err != nil {
186+ fmt.Fprintf(os.Stderr, "failed to write log output: %v\n", err)
187+ }
184188 }
185189 }
186190 }
@@ -97,7 +97,7 @@ func Init(logDir string, level string, format string, enableFileLog bool, maxSiz
97 globalLoggerMux.Lock()97 globalLoggerMux.Lock()
98 defer globalLoggerMux.Unlock()98 defer globalLoggerMux.Unlock()
99 99
100- err := os.MkdirAll(logDir, 0755)100+ err := os.MkdirAll(logDir, 0750)
101 if err != nil {101 if err != nil {
102 return fmt.Errorf("failed to create log directory: %w", err)102 return fmt.Errorf("failed to create log directory: %w", err)
103 }103 }
@@ -158,7 +158,9 @@ func (l *Logger) writeLog(level LogLevel, message string, source string) {
158 formatted := l.formatter.Format(level, message, source)158 formatted := l.formatter.Format(level, message, source)
159 159
160 for _, output := range l.outputs {160 for _, output := range l.outputs {
161- output.Write([]byte(formatted))161+ if _, err := output.Write([]byte(formatted)); err != nil {
162+ fmt.Fprintf(os.Stderr, "failed to write log output: %v\n", err)
163+ }
162 }164 }
163 }165 }
164 166
@@ -180,7 +182,9 @@ func (l *Logger) WriteFileOnly(level LogLevel, message string, source string) {
180 // Write only to file outputs (skip stdout)182 // Write only to file outputs (skip stdout)
181 for _, output := range l.outputs {183 for _, output := range l.outputs {
182 if output != os.Stdout {184 if output != os.Stdout {
183- output.Write([]byte(formatted))185+ if _, err := output.Write([]byte(formatted)); err != nil {
186+ fmt.Fprintf(os.Stderr, "failed to write log output: %v\n", err)
187+ }
184 }188 }
185 }189 }
186 }190 }
M internal/logging/rotator.go +28 -8
@@ -49,7 +49,7 @@ func (r *LogRotator) openCurrentFile() error {
4949 r.currentSize = info.Size()
5050 }
5151
52- file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
52+ file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
5353 if err != nil {
5454 return fmt.Errorf("failed to open log file: %w", err)
5555 }
@@ -86,7 +86,9 @@ func (r *LogRotator) Write(p []byte) (n int, err error) {
8686
8787 func (r *LogRotator) rotate() error {
8888 if r.currentFile != nil {
89- r.currentFile.Close()
89+ if err := r.currentFile.Close(); err != nil {
90+ fmt.Fprintf(os.Stderr, "Warning: failed to close current log file: %v\n", err)
91+ }
9092 }
9193
9294 // Generate backup filename with timestamp
@@ -108,7 +110,9 @@ func (r *LogRotator) rotate() error {
108110 fmt.Fprintf(os.Stderr, "Warning: failed to compress log file %s: %v\n", backupPath, err)
109111 } else {
110112 // Remove uncompressed file after successful compression
111- os.Remove(backupPath)
113+ if err := os.Remove(backupPath); err != nil {
114+ fmt.Fprintf(os.Stderr, "Warning: failed to remove uncompressed log file %s: %v\n", backupPath, err)
115+ }
112116 }
113117 }
114118
@@ -125,17 +129,29 @@ func (r *LogRotator) compressFile(filePath string) error {
125129 if err != nil {
126130 return err
127131 }
128- defer inputFile.Close()
132+ defer func() {
133+ if err := inputFile.Close(); err != nil {
134+ fmt.Fprintf(os.Stderr, "Warning: failed to close input file during compression: %v\n", err)
135+ }
136+ }()
129137
130138 outputPath := filePath + ".gz"
131139 outputFile, err := os.Create(outputPath)
132140 if err != nil {
133141 return err
134142 }
135- defer outputFile.Close()
143+ defer func() {
144+ if err := outputFile.Close(); err != nil {
145+ fmt.Fprintf(os.Stderr, "Warning: failed to close output file during compression: %v\n", err)
146+ }
147+ }()
136148
137149 gzipWriter := gzip.NewWriter(outputFile)
138- defer gzipWriter.Close()
150+ defer func() {
151+ if err := gzipWriter.Close(); err != nil {
152+ fmt.Fprintf(os.Stderr, "Warning: failed to close gzip writer: %v\n", err)
153+ }
154+ }()
139155
140156 _, err = io.Copy(gzipWriter, inputFile)
141157 return err
@@ -167,7 +183,9 @@ func (r *LogRotator) cleanup() {
167183
168184 // Remove files older than maxAge
169185 if r.maxAge > 0 && now.Sub(info.ModTime()) > r.maxAge {
170- os.Remove(filepath.Join(r.baseDir, name))
186+ if err := os.Remove(filepath.Join(r.baseDir, name)); err != nil {
187+ fmt.Fprintf(os.Stderr, "warning: failed to remove old log file: %v\n", err)
188+ }
171189 continue
172190 }
173191
@@ -183,7 +201,9 @@ func (r *LogRotator) cleanup() {
183201
184202 // Remove oldest files
185203 for i := 0; i < len(backupFiles)-r.maxBackups; i++ {
186- os.Remove(filepath.Join(r.baseDir, backupFiles[i].Name()))
204+ if err := os.Remove(filepath.Join(r.baseDir, backupFiles[i].Name())); err != nil {
205+ fmt.Fprintf(os.Stderr, "warning: failed to remove excess backup file: %v\n", err)
206+ }
187207 }
188208 }
189209 }
@@ -49,7 +49,7 @@ func (r *LogRotator) openCurrentFile() error {
49 r.currentSize = info.Size()49 r.currentSize = info.Size()
50 }50 }
51 51
52- file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)52+ file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
53 if err != nil {53 if err != nil {
54 return fmt.Errorf("failed to open log file: %w", err)54 return fmt.Errorf("failed to open log file: %w", err)
55 }55 }
@@ -86,7 +86,9 @@ func (r *LogRotator) Write(p []byte) (n int, err error) {
86 86
87 func (r *LogRotator) rotate() error {87 func (r *LogRotator) rotate() error {
88 if r.currentFile != nil {88 if r.currentFile != nil {
89- r.currentFile.Close()89+ if err := r.currentFile.Close(); err != nil {
90+ fmt.Fprintf(os.Stderr, "Warning: failed to close current log file: %v\n", err)
91+ }
90 }92 }
91 93
92 // Generate backup filename with timestamp94 // Generate backup filename with timestamp
@@ -108,7 +110,9 @@ func (r *LogRotator) rotate() error {
108 fmt.Fprintf(os.Stderr, "Warning: failed to compress log file %s: %v\n", backupPath, err)110 fmt.Fprintf(os.Stderr, "Warning: failed to compress log file %s: %v\n", backupPath, err)
109 } else {111 } else {
110 // Remove uncompressed file after successful compression112 // Remove uncompressed file after successful compression
111- os.Remove(backupPath)113+ if err := os.Remove(backupPath); err != nil {
114+ fmt.Fprintf(os.Stderr, "Warning: failed to remove uncompressed log file %s: %v\n", backupPath, err)
115+ }
112 }116 }
113 }117 }
114 118
@@ -125,17 +129,29 @@ func (r *LogRotator) compressFile(filePath string) error {
125 if err != nil {129 if err != nil {
126 return err130 return err
127 }131 }
128- defer inputFile.Close()132+ defer func() {
133+ if err := inputFile.Close(); err != nil {
134+ fmt.Fprintf(os.Stderr, "Warning: failed to close input file during compression: %v\n", err)
135+ }
136+ }()
129 137
130 outputPath := filePath + ".gz"138 outputPath := filePath + ".gz"
131 outputFile, err := os.Create(outputPath)139 outputFile, err := os.Create(outputPath)
132 if err != nil {140 if err != nil {
133 return err141 return err
134 }142 }
135- defer outputFile.Close()143+ defer func() {
144+ if err := outputFile.Close(); err != nil {
145+ fmt.Fprintf(os.Stderr, "Warning: failed to close output file during compression: %v\n", err)
146+ }
147+ }()
136 148
137 gzipWriter := gzip.NewWriter(outputFile)149 gzipWriter := gzip.NewWriter(outputFile)
138- defer gzipWriter.Close()150+ defer func() {
151+ if err := gzipWriter.Close(); err != nil {
152+ fmt.Fprintf(os.Stderr, "Warning: failed to close gzip writer: %v\n", err)
153+ }
154+ }()
139 155
140 _, err = io.Copy(gzipWriter, inputFile)156 _, err = io.Copy(gzipWriter, inputFile)
141 return err157 return err
@@ -167,7 +183,9 @@ func (r *LogRotator) cleanup() {
167 183
168 // Remove files older than maxAge184 // Remove files older than maxAge
169 if r.maxAge > 0 && now.Sub(info.ModTime()) > r.maxAge {185 if r.maxAge > 0 && now.Sub(info.ModTime()) > r.maxAge {
170- os.Remove(filepath.Join(r.baseDir, name))186+ if err := os.Remove(filepath.Join(r.baseDir, name)); err != nil {
187+ fmt.Fprintf(os.Stderr, "warning: failed to remove old log file: %v\n", err)
188+ }
171 continue189 continue
172 }190 }
173 191
@@ -183,7 +201,9 @@ func (r *LogRotator) cleanup() {
183 201
184 // Remove oldest files202 // Remove oldest files
185 for i := 0; i < len(backupFiles)-r.maxBackups; i++ {203 for i := 0; i < len(backupFiles)-r.maxBackups; i++ {
186- os.Remove(filepath.Join(r.baseDir, backupFiles[i].Name()))204+ if err := os.Remove(filepath.Join(r.baseDir, backupFiles[i].Name())); err != nil {
205+ fmt.Fprintf(os.Stderr, "warning: failed to remove excess backup file: %v\n", err)
206+ }
187 }207 }
188 }208 }
189 }209 }
A internal/migrations/006_add_fuzzy_cache_tables.sql +22 -0
@@ -0,0 +1,22 @@
1+-- Database consolidation: Move fuzzy cache from binary file to database
2+-- Replaces ~/.local/state/dumber/dmenu_fuzzy_cache.bin with proper SQL tables
3+-- This enables single-database architecture: dumber.sqlite
4+
5+-- Metadata table: stores cache statistics and versioning
6+CREATE TABLE IF NOT EXISTS fuzzy_cache_metadata (
7+ id INTEGER PRIMARY KEY CHECK (id = 1), -- Singleton table: only one row allowed
8+ version INTEGER NOT NULL,
9+ entry_count INTEGER NOT NULL,
10+ last_modified INTEGER NOT NULL, -- Unix timestamp
11+ entries_hash TEXT NOT NULL -- MD5 hash for cache invalidation detection
12+);
13+
14+-- Structures table: stores serialized fuzzy search data structures
15+CREATE TABLE IF NOT EXISTS fuzzy_cache_structures (
16+ id INTEGER PRIMARY KEY CHECK (id = 1), -- Singleton table: only one row allowed
17+ trigram_index BLOB NOT NULL, -- Serialized trigram index: map[string][]int
18+ prefix_trie BLOB NOT NULL, -- Serialized prefix trie for fast lookups
19+ sorted_index BLOB NOT NULL -- Pre-sorted entries for ranking
20+);
21+
22+-- Note: No indexes needed - singleton tables with single row access pattern
@@ -0,0 +1,22 @@
1+-- Database consolidation: Move fuzzy cache from binary file to database
2+-- Replaces ~/.local/state/dumber/dmenu_fuzzy_cache.bin with proper SQL tables
3+-- This enables single-database architecture: dumber.sqlite
4+
5+-- Metadata table: stores cache statistics and versioning
6+CREATE TABLE IF NOT EXISTS fuzzy_cache_metadata (
7+ id INTEGER PRIMARY KEY CHECK (id = 1), -- Singleton table: only one row allowed
8+ version INTEGER NOT NULL,
9+ entry_count INTEGER NOT NULL,
10+ last_modified INTEGER NOT NULL, -- Unix timestamp
11+ entries_hash TEXT NOT NULL -- MD5 hash for cache invalidation detection
12+);
13+
14+-- Structures table: stores serialized fuzzy search data structures
15+CREATE TABLE IF NOT EXISTS fuzzy_cache_structures (
16+ id INTEGER PRIMARY KEY CHECK (id = 1), -- Singleton table: only one row allowed
17+ trigram_index BLOB NOT NULL, -- Serialized trigram index: map[string][]int
18+ prefix_trie BLOB NOT NULL, -- Serialized prefix trie for fast lookups
19+ sorted_index BLOB NOT NULL -- Pre-sorted entries for ranking
20+);
21+
22+-- Note: No indexes needed - singleton tables with single row access pattern
A internal/migrations/007_remove_shortcuts_table.sql +6 -0
@@ -0,0 +1,6 @@
1+-- Remove shortcuts table
2+-- Shortcuts are now managed exclusively through configuration files
3+-- This table was redundant as shortcuts were synced from config on startup
4+-- but never queried during runtime (parser reads from config directly)
5+
6+DROP TABLE IF EXISTS shortcuts;
@@ -0,0 +1,6 @@
1+-- Remove shortcuts table
2+-- Shortcuts are now managed exclusively through configuration files
3+-- This table was redundant as shortcuts were synced from config on startup
4+-- but never queried during runtime (parser reads from config directly)
5+
6+DROP TABLE IF EXISTS shortcuts;
M internal/migrations/migrations.go +8 -2
@@ -132,7 +132,9 @@ func applyMigration(db *sql.DB, migration Migration) error {
132132 }
133133 defer func() {
134134 if err != nil {
135- tx.Rollback()
135+ if rollbackErr := tx.Rollback(); rollbackErr != nil {
136+ log.Printf("failed to rollback migration transaction: %v", rollbackErr)
137+ }
136138 }
137139 }()
138140
@@ -175,7 +177,11 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) {
175177 if err != nil {
176178 return nil, fmt.Errorf("failed to query applied migrations: %w", err)
177179 }
178- defer rows.Close()
180+ defer func() {
181+ if err := rows.Close(); err != nil {
182+ log.Printf("failed to close migration query rows: %v", err)
183+ }
184+ }()
179185
180186 var versions []int
181187 for rows.Next() {
@@ -132,7 +132,9 @@ func applyMigration(db *sql.DB, migration Migration) error {
132 }132 }
133 defer func() {133 defer func() {
134 if err != nil {134 if err != nil {
135- tx.Rollback()135+ if rollbackErr := tx.Rollback(); rollbackErr != nil {
136+ log.Printf("failed to rollback migration transaction: %v", rollbackErr)
137+ }
136 }138 }
137 }()139 }()
138 140
@@ -175,7 +177,11 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) {
175 if err != nil {177 if err != nil {
176 return nil, fmt.Errorf("failed to query applied migrations: %w", err)178 return nil, fmt.Errorf("failed to query applied migrations: %w", err)
177 }179 }
178- defer rows.Close()180+ defer func() {
181+ if err := rows.Close(); err != nil {
182+ log.Printf("failed to close migration query rows: %v", err)
183+ }
184+ }()
179 185
180 var versions []int186 var versions []int
181 for rows.Next() {187 for rows.Next() {
M internal/migrations/migrations_test.go +13 -13
@@ -20,7 +20,7 @@ func TestMigrationsAreIdempotent(t *testing.T) {
2020 if err != nil {
2121 t.Fatalf("Failed to open test database: %v", err)
2222 }
23- defer db.Close()
23+ defer func() { _ = db.Close() }()
2424
2525 // Run migrations first time
2626 if err := RunEmbeddedMigrations(db); err != nil {
@@ -83,7 +83,7 @@ func TestMigrationTrackingPreventsReapplication(t *testing.T) {
8383 if err != nil {
8484 t.Fatalf("Failed to open test database: %v", err)
8585 }
86- defer db.Close()
86+ defer func() { _ = db.Close() }()
8787
8888 // Run migrations
8989 if err := RunEmbeddedMigrations(db); err != nil {
@@ -106,7 +106,7 @@ func TestMigrationTrackingPreventsReapplication(t *testing.T) {
106106 if err != nil {
107107 t.Fatalf("Failed to check for duplicate migration entries: %v", err)
108108 }
109- defer rows.Close()
109+ defer func() { _ = rows.Close() }()
110110
111111 duplicates := []int{}
112112 for rows.Next() {
@@ -131,7 +131,7 @@ func TestAllMigrationsApplyOnFreshDatabase(t *testing.T) {
131131 if err != nil {
132132 t.Fatalf("Failed to open test database: %v", err)
133133 }
134- defer db.Close()
134+ defer func() { _ = db.Close() }()
135135
136136 // Get expected migrations
137137 expectedMigrations, err := GetMigrations()
@@ -207,7 +207,7 @@ func TestMigrationTableCreationIsIdempotent(t *testing.T) {
207207 if err != nil {
208208 t.Fatalf("Failed to open test database: %v", err)
209209 }
210- defer db.Close()
210+ defer func() { _ = db.Close() }()
211211
212212 // Create tracking table multiple times
213213 for i := 0; i < 3; i++ {
@@ -236,7 +236,7 @@ func TestMigrationTableCreationIsIdempotent(t *testing.T) {
236236 if err != nil {
237237 t.Fatalf("Failed to get table info: %v", err)
238238 }
239- defer rows.Close()
239+ defer func() { _ = rows.Close() }()
240240
241241 expectedColumns := map[string]bool{
242242 "version": false,
@@ -275,7 +275,7 @@ func TestPartialMigrationState(t *testing.T) {
275275 if err != nil {
276276 t.Fatalf("Failed to open test database: %v", err)
277277 }
278- defer db.Close()
278+ defer func() { _ = db.Close() }()
279279
280280 // Create tracking table
281281 if err := createMigrationsTable(db); err != nil {
@@ -300,12 +300,12 @@ func TestPartialMigrationState(t *testing.T) {
300300 }
301301
302302 if _, err := tx.Exec(firstMigration.SQL); err != nil {
303- tx.Rollback()
303+ _ = tx.Rollback()
304304 t.Fatalf("Failed to execute first migration: %v", err)
305305 }
306306
307307 if _, err := tx.Exec("INSERT INTO schema_migrations (version, name) VALUES (?, ?)", firstMigration.Version, firstMigration.Name); err != nil {
308- tx.Rollback()
308+ _ = tx.Rollback()
309309 t.Fatalf("Failed to record first migration: %v", err)
310310 }
311311
@@ -353,7 +353,7 @@ func TestGetAppliedMigrationsEmptyDatabase(t *testing.T) {
353353 if err != nil {
354354 t.Fatalf("Failed to open test database: %v", err)
355355 }
356- defer db.Close()
356+ defer func() { _ = db.Close() }()
357357
358358 // Query applied migrations on empty database
359359 applied, err := GetAppliedMigrations(db)
@@ -375,7 +375,7 @@ func TestVerifyAllMigrationsApplied(t *testing.T) {
375375 if err != nil {
376376 t.Fatalf("Failed to open test database: %v", err)
377377 }
378- defer db.Close()
378+ defer func() { _ = db.Close() }()
379379
380380 // Run migrations
381381 if err := RunEmbeddedMigrations(db); err != nil {
@@ -409,7 +409,7 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) {
409409 if err != nil {
410410 t.Fatalf("Failed to open test database: %v", err)
411411 }
412- defer db.Close()
412+ defer func() { _ = db.Close() }()
413413
414414 // Run migrations
415415 if err := RunEmbeddedMigrations(db); err != nil {
@@ -420,9 +420,9 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) {
420420 expectedTables := []string{
421421 "schema_migrations", // Migration tracking
422422 "history", // 001_initial.sql
423- "shortcuts", // 001_initial.sql
424423 "zoom_levels", // 003_add_zoom_levels.sql
425424 "certificate_validations", // 005_add_certificate_validations.sql
425+ // Note: shortcuts table removed in 007_remove_shortcuts_table.sql
426426 }
427427
428428 // Check each expected table exists
@@ -20,7 +20,7 @@ func TestMigrationsAreIdempotent(t *testing.T) {
20 if err != nil {20 if err != nil {
21 t.Fatalf("Failed to open test database: %v", err)21 t.Fatalf("Failed to open test database: %v", err)
22 }22 }
23- defer db.Close()23+ defer func() { _ = db.Close() }()
24 24
25 // Run migrations first time25 // Run migrations first time
26 if err := RunEmbeddedMigrations(db); err != nil {26 if err := RunEmbeddedMigrations(db); err != nil {
@@ -83,7 +83,7 @@ func TestMigrationTrackingPreventsReapplication(t *testing.T) {
83 if err != nil {83 if err != nil {
84 t.Fatalf("Failed to open test database: %v", err)84 t.Fatalf("Failed to open test database: %v", err)
85 }85 }
86- defer db.Close()86+ defer func() { _ = db.Close() }()
87 87
88 // Run migrations88 // Run migrations
89 if err := RunEmbeddedMigrations(db); err != nil {89 if err := RunEmbeddedMigrations(db); err != nil {
@@ -106,7 +106,7 @@ func TestMigrationTrackingPreventsReapplication(t *testing.T) {
106 if err != nil {106 if err != nil {
107 t.Fatalf("Failed to check for duplicate migration entries: %v", err)107 t.Fatalf("Failed to check for duplicate migration entries: %v", err)
108 }108 }
109- defer rows.Close()109+ defer func() { _ = rows.Close() }()
110 110
111 duplicates := []int{}111 duplicates := []int{}
112 for rows.Next() {112 for rows.Next() {
@@ -131,7 +131,7 @@ func TestAllMigrationsApplyOnFreshDatabase(t *testing.T) {
131 if err != nil {131 if err != nil {
132 t.Fatalf("Failed to open test database: %v", err)132 t.Fatalf("Failed to open test database: %v", err)
133 }133 }
134- defer db.Close()134+ defer func() { _ = db.Close() }()
135 135
136 // Get expected migrations136 // Get expected migrations
137 expectedMigrations, err := GetMigrations()137 expectedMigrations, err := GetMigrations()
@@ -207,7 +207,7 @@ func TestMigrationTableCreationIsIdempotent(t *testing.T) {
207 if err != nil {207 if err != nil {
208 t.Fatalf("Failed to open test database: %v", err)208 t.Fatalf("Failed to open test database: %v", err)
209 }209 }
210- defer db.Close()210+ defer func() { _ = db.Close() }()
211 211
212 // Create tracking table multiple times212 // Create tracking table multiple times
213 for i := 0; i < 3; i++ {213 for i := 0; i < 3; i++ {
@@ -236,7 +236,7 @@ func TestMigrationTableCreationIsIdempotent(t *testing.T) {
236 if err != nil {236 if err != nil {
237 t.Fatalf("Failed to get table info: %v", err)237 t.Fatalf("Failed to get table info: %v", err)
238 }238 }
239- defer rows.Close()239+ defer func() { _ = rows.Close() }()
240 240
241 expectedColumns := map[string]bool{241 expectedColumns := map[string]bool{
242 "version": false,242 "version": false,
@@ -275,7 +275,7 @@ func TestPartialMigrationState(t *testing.T) {
275 if err != nil {275 if err != nil {
276 t.Fatalf("Failed to open test database: %v", err)276 t.Fatalf("Failed to open test database: %v", err)
277 }277 }
278- defer db.Close()278+ defer func() { _ = db.Close() }()
279 279
280 // Create tracking table280 // Create tracking table
281 if err := createMigrationsTable(db); err != nil {281 if err := createMigrationsTable(db); err != nil {
@@ -300,12 +300,12 @@ func TestPartialMigrationState(t *testing.T) {
300 }300 }
301 301
302 if _, err := tx.Exec(firstMigration.SQL); err != nil {302 if _, err := tx.Exec(firstMigration.SQL); err != nil {
303- tx.Rollback()303+ _ = tx.Rollback()
304 t.Fatalf("Failed to execute first migration: %v", err)304 t.Fatalf("Failed to execute first migration: %v", err)
305 }305 }
306 306
307 if _, err := tx.Exec("INSERT INTO schema_migrations (version, name) VALUES (?, ?)", firstMigration.Version, firstMigration.Name); err != nil {307 if _, err := tx.Exec("INSERT INTO schema_migrations (version, name) VALUES (?, ?)", firstMigration.Version, firstMigration.Name); err != nil {
308- tx.Rollback()308+ _ = tx.Rollback()
309 t.Fatalf("Failed to record first migration: %v", err)309 t.Fatalf("Failed to record first migration: %v", err)
310 }310 }
311 311
@@ -353,7 +353,7 @@ func TestGetAppliedMigrationsEmptyDatabase(t *testing.T) {
353 if err != nil {353 if err != nil {
354 t.Fatalf("Failed to open test database: %v", err)354 t.Fatalf("Failed to open test database: %v", err)
355 }355 }
356- defer db.Close()356+ defer func() { _ = db.Close() }()
357 357
358 // Query applied migrations on empty database358 // Query applied migrations on empty database
359 applied, err := GetAppliedMigrations(db)359 applied, err := GetAppliedMigrations(db)
@@ -375,7 +375,7 @@ func TestVerifyAllMigrationsApplied(t *testing.T) {
375 if err != nil {375 if err != nil {
376 t.Fatalf("Failed to open test database: %v", err)376 t.Fatalf("Failed to open test database: %v", err)
377 }377 }
378- defer db.Close()378+ defer func() { _ = db.Close() }()
379 379
380 // Run migrations380 // Run migrations
381 if err := RunEmbeddedMigrations(db); err != nil {381 if err := RunEmbeddedMigrations(db); err != nil {
@@ -409,7 +409,7 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) {
409 if err != nil {409 if err != nil {
410 t.Fatalf("Failed to open test database: %v", err)410 t.Fatalf("Failed to open test database: %v", err)
411 }411 }
412- defer db.Close()412+ defer func() { _ = db.Close() }()
413 413
414 // Run migrations414 // Run migrations
415 if err := RunEmbeddedMigrations(db); err != nil {415 if err := RunEmbeddedMigrations(db); err != nil {
@@ -420,9 +420,9 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) {
420 expectedTables := []string{420 expectedTables := []string{
421 "schema_migrations", // Migration tracking421 "schema_migrations", // Migration tracking
422 "history", // 001_initial.sql422 "history", // 001_initial.sql
423- "shortcuts", // 001_initial.sql
424 "zoom_levels", // 003_add_zoom_levels.sql423 "zoom_levels", // 003_add_zoom_levels.sql
425 "certificate_validations", // 005_add_certificate_validations.sql424 "certificate_validations", // 005_add_certificate_validations.sql
425+ // Note: shortcuts table removed in 007_remove_shortcuts_table.sql
426 }426 }
427 427
428 // Check each expected table exists428 // Check each expected table exists
M internal/services/favicon_service.go +6 -3
@@ -4,6 +4,7 @@ package services
44 import (
55 "context"
66 "database/sql"
7+ "errors"
78 "fmt"
89 "log"
910 "os"
@@ -52,7 +53,7 @@ func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQue
5253
5354 // Create export directory for CLI access
5455 exportDir := filepath.Join(dataDir, "favicons-export")
55- if err := os.MkdirAll(exportDir, 0755); err != nil {
56+ if err := os.MkdirAll(exportDir, 0750); err != nil {
5657 return nil, fmt.Errorf("failed to create favicon export directory: %w", err)
5758 }
5859
@@ -291,7 +292,7 @@ func (fs *FaviconService) lookupFaviconURI(ctx context.Context, pageURL string)
291292 if entry.FaviconUrl.Valid && entry.FaviconUrl.String != "" {
292293 return entry.FaviconUrl.String, nil
293294 }
294- } else if err != sql.ErrNoRows {
295+ } else if !errors.Is(err, sql.ErrNoRows) {
295296 log.Printf("[favicon] failed to query history entry for %s: %v", pageURL, err)
296297 }
297298
@@ -439,7 +440,9 @@ func (fs *FaviconService) CleanOldExports() error {
439440 }
440441
441442 if info.ModTime().Before(cutoff) {
442- os.Remove(filepath.Join(fs.exportDir, entry.Name()))
443+ if err := os.Remove(filepath.Join(fs.exportDir, entry.Name())); err != nil {
444+ fmt.Fprintf(os.Stderr, "warning: failed to remove old favicon: %v\n", err)
445+ }
443446 }
444447 }
445448
@@ -4,6 +4,7 @@ package services
4 import (4 import (
5 "context"5 "context"
6 "database/sql"6 "database/sql"
7+ "errors"
7 "fmt"8 "fmt"
8 "log"9 "log"
9 "os"10 "os"
@@ -52,7 +53,7 @@ func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQue
52 53
53 // Create export directory for CLI access54 // Create export directory for CLI access
54 exportDir := filepath.Join(dataDir, "favicons-export")55 exportDir := filepath.Join(dataDir, "favicons-export")
55- if err := os.MkdirAll(exportDir, 0755); err != nil {56+ if err := os.MkdirAll(exportDir, 0750); err != nil {
56 return nil, fmt.Errorf("failed to create favicon export directory: %w", err)57 return nil, fmt.Errorf("failed to create favicon export directory: %w", err)
57 }58 }
58 59
@@ -291,7 +292,7 @@ func (fs *FaviconService) lookupFaviconURI(ctx context.Context, pageURL string)
291 if entry.FaviconUrl.Valid && entry.FaviconUrl.String != "" {292 if entry.FaviconUrl.Valid && entry.FaviconUrl.String != "" {
292 return entry.FaviconUrl.String, nil293 return entry.FaviconUrl.String, nil
293 }294 }
294- } else if err != sql.ErrNoRows {295+ } else if !errors.Is(err, sql.ErrNoRows) {
295 log.Printf("[favicon] failed to query history entry for %s: %v", pageURL, err)296 log.Printf("[favicon] failed to query history entry for %s: %v", pageURL, err)
296 }297 }
297 298
@@ -439,7 +440,9 @@ func (fs *FaviconService) CleanOldExports() error {
439 }440 }
440 441
441 if info.ModTime().Before(cutoff) {442 if info.ModTime().Before(cutoff) {
442- os.Remove(filepath.Join(fs.exportDir, entry.Name()))443+ if err := os.Remove(filepath.Join(fs.exportDir, entry.Name())); err != nil {
444+ fmt.Fprintf(os.Stderr, "warning: failed to remove old favicon: %v\n", err)
445+ }
443 }446 }
444 }447 }
445 448
M pkg/webkit/keyboard_bridge.go +6 -6
@@ -11,9 +11,9 @@ import (
1111
1212 // Keyboard shortcut modifier string constants
1313 const (
14- modifierCmdOrCtrl = "cmdorctrl"
15- modifierShift = "shift"
16- modifierAlt = "alt"
14+ modifierCtrl = "ctrl"
15+ modifierShift = "shift"
16+ modifierAlt = "alt"
1717 )
1818
1919 // GDK key constants for special keys
@@ -127,9 +127,9 @@ func (w *WebView) AttachKeyboardBridge() {
127127 // Build modifier prefix using switch on modifier combination
128128 switch {
129129 case ctrl && shift:
130- parts = append(parts, modifierCmdOrCtrl, modifierShift)
130+ parts = append(parts, modifierCtrl, modifierShift)
131131 case ctrl && !shift:
132- parts = append(parts, modifierCmdOrCtrl)
132+ parts = append(parts, modifierCtrl)
133133 case alt:
134134 parts = append(parts, modifierAlt)
135135 case shift:
@@ -199,7 +199,7 @@ func (w *WebView) AttachKeyboardBridge() {
199199
200200 if shortcut != "" {
201201 // Special case: Ctrl+P enters pane mode
202- if shortcut == modifierCmdOrCtrl+"+"+keyP {
202+ if shortcut == modifierCtrl+"+"+keyP {
203203 w.mu.RLock()
204204 handler := w.onPaneModeShortcut
205205 w.mu.RUnlock()
@@ -11,9 +11,9 @@ import (
11 11
12 // Keyboard shortcut modifier string constants12 // Keyboard shortcut modifier string constants
13 const (13 const (
14- modifierCmdOrCtrl = "cmdorctrl"14+ modifierCtrl = "ctrl"
15- modifierShift = "shift"15+ modifierShift = "shift"
16- modifierAlt = "alt"16+ modifierAlt = "alt"
17 )17 )
18 18
19 // GDK key constants for special keys19 // GDK key constants for special keys
@@ -127,9 +127,9 @@ func (w *WebView) AttachKeyboardBridge() {
127 // Build modifier prefix using switch on modifier combination127 // Build modifier prefix using switch on modifier combination
128 switch {128 switch {
129 case ctrl && shift:129 case ctrl && shift:
130- parts = append(parts, modifierCmdOrCtrl, modifierShift)130+ parts = append(parts, modifierCtrl, modifierShift)
131 case ctrl && !shift:131 case ctrl && !shift:
132- parts = append(parts, modifierCmdOrCtrl)132+ parts = append(parts, modifierCtrl)
133 case alt:133 case alt:
134 parts = append(parts, modifierAlt)134 parts = append(parts, modifierAlt)
135 case shift:135 case shift:
@@ -199,7 +199,7 @@ func (w *WebView) AttachKeyboardBridge() {
199 199
200 if shortcut != "" {200 if shortcut != "" {
201 // Special case: Ctrl+P enters pane mode201 // Special case: Ctrl+P enters pane mode
202- if shortcut == modifierCmdOrCtrl+"+"+keyP {202+ if shortcut == modifierCtrl+"+"+keyP {
203 w.mu.RLock()203 w.mu.RLock()
204 handler := w.onPaneModeShortcut204 handler := w.onPaneModeShortcut
205 w.mu.RUnlock()205 w.mu.RUnlock()
M pkg/webkit/mainloop.go +0 -1
@@ -9,7 +9,6 @@ import (
99
1010 var (
1111 mainLoop *glib.MainLoop
12- mainThreadID = runtime.LockOSThread // Store the main thread
1312 isInitialized bool
1413 )
1514
@@ -9,7 +9,6 @@ import (
9 9
10 var (10 var (
11 mainLoop *glib.MainLoop11 mainLoop *glib.MainLoop
12- mainThreadID = runtime.LockOSThread // Store the main thread
13 isInitialized bool12 isInitialized bool
14 )13 )
15 14
M pkg/webkit/tls_handler.go +17 -36
@@ -2,8 +2,8 @@ package webkit
22
33 import (
44 "context"
5- "crypto/sha256"
65 "database/sql"
6+ "errors"
77 "fmt"
88 "log"
99 "net/url"
@@ -83,37 +83,6 @@ func extractHostname(uri string) string {
8383 return host
8484 }
8585
86-// formatCertificateInfo formats certificate information for display and hashing
87-func formatCertificateInfo(certificate gio.TLSCertificater) string {
88- // Convert to TLSCertificate to access methods
89- tlsCert := gio.BaseTLSCertificate(certificate)
90- if tlsCert == nil {
91- // Fallback: use the object pointer address as a weak identifier
92- return fmt.Sprintf("%p", certificate)
93- }
94-
95- // Build a unique string from certificate properties
96- // Use subject, issuer, and validity period
97- subject := tlsCert.SubjectName()
98- issuer := ""
99- if issuerCert := tlsCert.Issuer(); issuerCert != nil {
100- issuer = gio.BaseTLSCertificate(issuerCert).SubjectName()
101- }
102-
103- notBefore := tlsCert.NotValidBefore()
104- notAfter := tlsCert.NotValidAfter()
105-
106- // Combine properties into a unique string
107- return fmt.Sprintf("subject=%s,issuer=%s,notBefore=%v,notAfter=%v",
108- subject, issuer, notBefore, notAfter)
109-}
110-
111-// getCertificateHash generates a SHA256 hash of the certificate info
112-func getCertificateHash(certificateInfo string) string {
113- hash := sha256.Sum256([]byte(certificateInfo))
114- return fmt.Sprintf("%x", hash)
115-}
116-
11786 // checkStoredCertificateDecision checks if user has previously decided on this hostname
11887 func checkStoredCertificateDecision(hostname string) (string, bool) {
11988 cfg := config.Get()
@@ -133,7 +102,11 @@ func checkStoredCertificateDecision(hostname string) (string, bool) {
133102 log.Printf("[tls] Failed to open database: %v", err)
134103 return "", false
135104 }
136- defer database.Close()
105+ defer func() {
106+ if err := database.Close(); err != nil {
107+ log.Printf("[tls] warning: failed to close database: %v", err)
108+ }
109+ }()
137110
138111 queries := db.New(database)
139112 ctx := context.Background()
@@ -141,7 +114,7 @@ func checkStoredCertificateDecision(hostname string) (string, bool) {
141114 // Get any validation for this hostname (we don't verify cert hash anymore)
142115 validation, err := queries.GetCertificateValidationByHostname(ctx, hostname)
143116 if err != nil {
144- if err == sql.ErrNoRows {
117+ if errors.Is(err, sql.ErrNoRows) {
145118 return "", false
146119 }
147120 log.Printf("[tls] Error checking certificate validation: %v", err)
@@ -167,7 +140,11 @@ func storeCertificateDecision(hostname, decision string, expiresAt sql.NullTime)
167140 if err != nil {
168141 return fmt.Errorf("failed to open database: %w", err)
169142 }
170- defer database.Close()
143+ defer func() {
144+ if err := database.Close(); err != nil {
145+ log.Printf("[tls] warning: failed to close database: %v", err)
146+ }
147+ }()
171148
172149 queries := db.New(database)
173150 ctx := context.Background()
@@ -338,7 +315,11 @@ func CleanupExpiredCertificateValidations() error {
338315 if err != nil {
339316 return fmt.Errorf("failed to open database: %w", err)
340317 }
341- defer database.Close()
318+ defer func() {
319+ if err := database.Close(); err != nil {
320+ log.Printf("[tls] warning: failed to close database: %v", err)
321+ }
322+ }()
342323
343324 queries := db.New(database)
344325 ctx := context.Background()
@@ -2,8 +2,8 @@ package webkit
2 2
3 import (3 import (
4 "context"4 "context"
5- "crypto/sha256"
6 "database/sql"5 "database/sql"
6+ "errors"
7 "fmt"7 "fmt"
8 "log"8 "log"
9 "net/url"9 "net/url"
@@ -83,37 +83,6 @@ func extractHostname(uri string) string {
83 return host83 return host
84 }84 }
85 85
86-// formatCertificateInfo formats certificate information for display and hashing
87-func formatCertificateInfo(certificate gio.TLSCertificater) string {
88- // Convert to TLSCertificate to access methods
89- tlsCert := gio.BaseTLSCertificate(certificate)
90- if tlsCert == nil {
91- // Fallback: use the object pointer address as a weak identifier
92- return fmt.Sprintf("%p", certificate)
93- }
94-
95- // Build a unique string from certificate properties
96- // Use subject, issuer, and validity period
97- subject := tlsCert.SubjectName()
98- issuer := ""
99- if issuerCert := tlsCert.Issuer(); issuerCert != nil {
100- issuer = gio.BaseTLSCertificate(issuerCert).SubjectName()
101- }
102-
103- notBefore := tlsCert.NotValidBefore()
104- notAfter := tlsCert.NotValidAfter()
105-
106- // Combine properties into a unique string
107- return fmt.Sprintf("subject=%s,issuer=%s,notBefore=%v,notAfter=%v",
108- subject, issuer, notBefore, notAfter)
109-}
110-
111-// getCertificateHash generates a SHA256 hash of the certificate info
112-func getCertificateHash(certificateInfo string) string {
113- hash := sha256.Sum256([]byte(certificateInfo))
114- return fmt.Sprintf("%x", hash)
115-}
116-
117 // checkStoredCertificateDecision checks if user has previously decided on this hostname86 // checkStoredCertificateDecision checks if user has previously decided on this hostname
118 func checkStoredCertificateDecision(hostname string) (string, bool) {87 func checkStoredCertificateDecision(hostname string) (string, bool) {
119 cfg := config.Get()88 cfg := config.Get()
@@ -133,7 +102,11 @@ func checkStoredCertificateDecision(hostname string) (string, bool) {
133 log.Printf("[tls] Failed to open database: %v", err)102 log.Printf("[tls] Failed to open database: %v", err)
134 return "", false103 return "", false
135 }104 }
136- defer database.Close()105+ defer func() {
106+ if err := database.Close(); err != nil {
107+ log.Printf("[tls] warning: failed to close database: %v", err)
108+ }
109+ }()
137 110
138 queries := db.New(database)111 queries := db.New(database)
139 ctx := context.Background()112 ctx := context.Background()
@@ -141,7 +114,7 @@ func checkStoredCertificateDecision(hostname string) (string, bool) {
141 // Get any validation for this hostname (we don't verify cert hash anymore)114 // Get any validation for this hostname (we don't verify cert hash anymore)
142 validation, err := queries.GetCertificateValidationByHostname(ctx, hostname)115 validation, err := queries.GetCertificateValidationByHostname(ctx, hostname)
143 if err != nil {116 if err != nil {
144- if err == sql.ErrNoRows {117+ if errors.Is(err, sql.ErrNoRows) {
145 return "", false118 return "", false
146 }119 }
147 log.Printf("[tls] Error checking certificate validation: %v", err)120 log.Printf("[tls] Error checking certificate validation: %v", err)
@@ -167,7 +140,11 @@ func storeCertificateDecision(hostname, decision string, expiresAt sql.NullTime)
167 if err != nil {140 if err != nil {
168 return fmt.Errorf("failed to open database: %w", err)141 return fmt.Errorf("failed to open database: %w", err)
169 }142 }
170- defer database.Close()143+ defer func() {
144+ if err := database.Close(); err != nil {
145+ log.Printf("[tls] warning: failed to close database: %v", err)
146+ }
147+ }()
171 148
172 queries := db.New(database)149 queries := db.New(database)
173 ctx := context.Background()150 ctx := context.Background()
@@ -338,7 +315,11 @@ func CleanupExpiredCertificateValidations() error {
338 if err != nil {315 if err != nil {
339 return fmt.Errorf("failed to open database: %w", err)316 return fmt.Errorf("failed to open database: %w", err)
340 }317 }
341- defer database.Close()318+ defer func() {
319+ if err := database.Close(); err != nil {
320+ log.Printf("[tls] warning: failed to close database: %v", err)
321+ }
322+ }()
342 323
343 queries := db.New(database)324 queries := db.New(database)
344 ctx := context.Background()325 ctx := context.Background()
M tests/integration/browser_controls_integration_test.go +8 -1
@@ -93,10 +93,17 @@ func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) {
9393 func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) {
9494 _, queries, cleanup := setupTestDB(t)
9595
96- cfg := &config.Config{}
96+ cfg := &config.Config{
97+ DefaultZoom: 1.0, // Set default zoom level
98+ }
9799
98100 service := services.NewBrowserService(cfg, queries)
99101
102+ // Load zoom cache (simulates startup behavior)
103+ if err := service.LoadZoomCacheFromDB(context.Background()); err != nil {
104+ t.Fatalf("Failed to load zoom cache: %v", err)
105+ }
106+
100107 return service, cleanup
101108 }
102109
@@ -93,10 +93,17 @@ func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) {
93 func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) {93 func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) {
94 _, queries, cleanup := setupTestDB(t)94 _, queries, cleanup := setupTestDB(t)
95 95
96- cfg := &config.Config{}96+ cfg := &config.Config{
97+ DefaultZoom: 1.0, // Set default zoom level
98+ }
97 99
98 service := services.NewBrowserService(cfg, queries)100 service := services.NewBrowserService(cfg, queries)
99 101
102+ // Load zoom cache (simulates startup behavior)
103+ if err := service.LoadZoomCacheFromDB(context.Background()); err != nil {
104+ t.Fatalf("Failed to load zoom cache: %v", err)
105+ }
106+
100 return service, cleanup107 return service, cleanup
101 }108 }
102 109

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close