detached a59164b

perf(sqlite): optimize startup with WASM precompile and parallel DB/WebKit init

commit
a59164b49878b8b4be664cd979dce96436701682
author
bnema · · 2025-12-27 21:10 UTC
parent
7ab7181
signature
GPG Unverified2B82207D370127A0 The key is unknown
6 files · +882 -26
M cmd/dumber/main.go +16 -12
@@ -83,7 +83,7 @@ func runGUI() int {
8383 ctx := logging.WithContext(context.Background(), bootstrapLogger)
8484 timer.Mark("logger")
8585
86- // Parallel phase: directories, runtime/media checks, theme
86+ // Parallel phase: directories, runtime/media checks, theme, WASM precompile
8787 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
8888 Ctx: ctx,
8989 Config: cfg,
@@ -94,15 +94,23 @@ func runGUI() int {
9494 }
9595 timer.MarkDuration("parallel_phase", initResult.Duration)
9696
97- // Database and session
98- db, dbCleanup, dbErr := bootstrap.OpenDatabase(ctx, initResult.DataDir)
99- if dbErr != nil {
100- bootstrapLogger.Fatal().Err(dbErr).Msg("failed to initialize database")
97+ // Parallel phase 2: Database + WebKit stack initialize concurrently
98+ dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
99+ Ctx: ctx,
100+ Config: cfg,
101+ DataDir: initResult.DataDir,
102+ CacheDir: initResult.CacheDir,
103+ ThemeManager: initResult.ThemeManager,
104+ })
105+ if err != nil {
106+ bootstrapLogger.Fatal().Err(err).Msg("failed to initialize database/webkit")
101107 }
102- if dbCleanup != nil {
103- defer dbCleanup()
108+ if dbWebKit.DBCleanup != nil {
109+ defer dbWebKit.DBCleanup()
104110 }
105- timer.Mark("database")
111+ db := dbWebKit.DB
112+ stack := dbWebKit.Stack
113+ timer.Mark("db_webkit_parallel")
106114
107115 browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)
108116 if err != nil {
@@ -117,10 +125,6 @@ func runGUI() int {
117125 ctx = sessionCtx
118126 log := logging.FromContext(ctx)
119127
120- // WebKit stack
121- stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)
122- timer.Mark("webkit_stack")
123-
124128 // Repositories and use cases
125129 repos := createRepositories(db)
126130 useCases := createUseCases(repos, cfg)
@@ -83,7 +83,7 @@ func runGUI() int {
83 ctx := logging.WithContext(context.Background(), bootstrapLogger)83 ctx := logging.WithContext(context.Background(), bootstrapLogger)
84 timer.Mark("logger")84 timer.Mark("logger")
85 85
86- // Parallel phase: directories, runtime/media checks, theme86+ // Parallel phase: directories, runtime/media checks, theme, WASM precompile
87 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{87 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
88 Ctx: ctx,88 Ctx: ctx,
89 Config: cfg,89 Config: cfg,
@@ -94,15 +94,23 @@ func runGUI() int {
94 }94 }
95 timer.MarkDuration("parallel_phase", initResult.Duration)95 timer.MarkDuration("parallel_phase", initResult.Duration)
96 96
97- // Database and session97+ // Parallel phase 2: Database + WebKit stack initialize concurrently
98- db, dbCleanup, dbErr := bootstrap.OpenDatabase(ctx, initResult.DataDir)98+ dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
99- if dbErr != nil {99+ Ctx: ctx,
100- bootstrapLogger.Fatal().Err(dbErr).Msg("failed to initialize database")100+ Config: cfg,
101+ DataDir: initResult.DataDir,
102+ CacheDir: initResult.CacheDir,
103+ ThemeManager: initResult.ThemeManager,
104+ })
105+ if err != nil {
106+ bootstrapLogger.Fatal().Err(err).Msg("failed to initialize database/webkit")
101 }107 }
102- if dbCleanup != nil {108+ if dbWebKit.DBCleanup != nil {
103- defer dbCleanup()109+ defer dbWebKit.DBCleanup()
104 }110 }
105- timer.Mark("database")111+ db := dbWebKit.DB
112+ stack := dbWebKit.Stack
113+ timer.Mark("db_webkit_parallel")
106 114
107 browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)115 browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)
108 if err != nil {116 if err != nil {
@@ -117,10 +125,6 @@ func runGUI() int {
117 ctx = sessionCtx125 ctx = sessionCtx
118 log := logging.FromContext(ctx)126 log := logging.FromContext(ctx)
119 127
120- // WebKit stack
121- stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)
122- timer.Mark("webkit_stack")
123-
124 // Repositories and use cases128 // Repositories and use cases
125 repos := createRepositories(db)129 repos := createRepositories(db)
126 useCases := createUseCases(repos, cfg)130 useCases := createUseCases(repos, cfg)
A internal/application/port/database.go +21 -0
@@ -0,0 +1,21 @@
1+// Package port defines interfaces for infrastructure adapters.
2+package port
3+
4+import (
5+ "context"
6+ "database/sql"
7+)
8+
9+// DatabaseProvider provides access to the database connection.
10+// Implementations may initialize the database lazily on first access.
11+type DatabaseProvider interface {
12+ // DB returns the database connection, initializing it if necessary.
13+ // Returns an error if initialization fails.
14+ DB(ctx context.Context) (*sql.DB, error)
15+
16+ // Close closes the database connection if it was initialized.
17+ Close() error
18+
19+ // IsInitialized returns true if the database has been initialized.
20+ IsInitialized() bool
21+}
@@ -0,0 +1,21 @@
1+// Package port defines interfaces for infrastructure adapters.
2+package port
3+
4+import (
5+ "context"
6+ "database/sql"
7+)
8+
9+// DatabaseProvider provides access to the database connection.
10+// Implementations may initialize the database lazily on first access.
11+type DatabaseProvider interface {
12+ // DB returns the database connection, initializing it if necessary.
13+ // Returns an error if initialization fails.
14+ DB(ctx context.Context) (*sql.DB, error)
15+
16+ // Close closes the database connection if it was initialized.
17+ Close() error
18+
19+ // IsInitialized returns true if the database has been initialized.
20+ IsInitialized() bool
21+}
M internal/bootstrap/gui.go +97 -8
@@ -17,8 +17,15 @@ import (
1717 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
1818 "github.com/bnema/dumber/internal/logging"
1919 "github.com/bnema/dumber/internal/ui/theme"
20+ sqlite3 "github.com/ncruces/go-sqlite3"
2021 )
2122
23+// DatabaseResult holds database connection and cleanup function.
24+type DatabaseResult struct {
25+ DB *sql.DB
26+ Cleanup func()
27+}
28+
2229 // ParallelInitResult holds the results of parallel initialization phase.
2330 type ParallelInitResult struct {
2431 DataDir string
@@ -76,7 +83,14 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
7683 )
7784
7885 start := time.Now()
79- wg.Add(4)
86+ wg.Add(5)
87+
88+ // Pre-initialize SQLite WASM runtime (expensive compilation)
89+ // This runs concurrently with other checks so it's hidden from critical path.
90+ go func() {
91+ defer wg.Done()
92+ _ = sqlite3.Initialize()
93+ }()
8094
8195 // Resolve directories
8296 go func() {
@@ -171,16 +185,91 @@ func resolveWebKitDirs() (dataDir, cacheDir string, err error) {
171185 return dataDir, cacheDir, nil
172186 }
173187
174-// OpenDatabase opens and initializes the SQLite database.
188+// OpenDatabase opens and initializes the SQLite database using XDG paths.
175189 // Returns error if database cannot be opened or migrations fail.
176-func OpenDatabase(ctx context.Context, dataDir string) (*sql.DB, func(), error) {
177- dbPath := filepath.Join(dataDir, "dumber.db")
190+func OpenDatabase(ctx context.Context) (*DatabaseResult, error) {
191+ dbPath, err := config.GetDatabaseFile()
192+ if err != nil {
193+ return nil, fmt.Errorf("resolve database path: %w", err)
194+ }
178195 db, err := sqlite.NewConnection(ctx, dbPath)
179196 if err != nil {
180- return nil, nil, fmt.Errorf("initialize database at %s: %w", dbPath, err)
197+ return nil, fmt.Errorf("initialize database at %s: %w", dbPath, err)
181198 }
182- cleanup := func() {
183- _ = sqlite.Close(db)
199+ return &DatabaseResult{
200+ DB: db,
201+ Cleanup: func() {
202+ _ = sqlite.Close(db)
203+ },
204+ }, nil
205+}
206+
207+// CreateLazyDatabase creates a lazy database provider that defers initialization.
208+// The database is initialized on first access, allowing the UI to render faster.
209+func CreateLazyDatabase() (*sqlite.LazyDB, error) {
210+ dbPath, err := config.GetDatabaseFile()
211+ if err != nil {
212+ return nil, fmt.Errorf("resolve database path: %w", err)
184213 }
185- return db, cleanup, nil
214+ return sqlite.NewLazyDB(dbPath), nil
215+}
216+
217+// ParallelDBWebKitResult holds results from parallel DB and WebKit initialization.
218+type ParallelDBWebKitResult struct {
219+ DB *sql.DB
220+ DBCleanup func()
221+ Stack WebKitStack
222+}
223+
224+// ParallelDBWebKitInput holds inputs for parallel DB and WebKit initialization.
225+type ParallelDBWebKitInput struct {
226+ Ctx context.Context
227+ Config *config.Config
228+ DataDir string // For WebKit context
229+ CacheDir string // For WebKit cache
230+ ThemeManager *theme.Manager
231+}
232+
233+// Note: Database path is resolved via config.GetDatabaseFile() internally.
234+
235+// RunParallelDBWebKit initializes database in background while WebKit runs on main thread.
236+// Database init happens in goroutine (pure Go/WASM), WebKit must stay on main thread (GTK).
237+// This saves ~150ms by overlapping DB migrations with WebKit context creation.
238+func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult, error) {
239+ log := logging.FromContext(input.Ctx)
240+
241+ var (
242+ db *sql.DB
243+ dbCleanup func()
244+ dbErr error
245+ dbDone = make(chan struct{})
246+ )
247+
248+ // Database initialization in background (WASM is thread-safe)
249+ go func() {
250+ defer close(dbDone)
251+ dbResult, err := OpenDatabase(input.Ctx)
252+ if err != nil {
253+ dbErr = err
254+ return
255+ }
256+ db = dbResult.DB
257+ dbCleanup = dbResult.Cleanup
258+ }()
259+
260+ // WebKit stack on main thread (GTK requirement)
261+ stack := BuildWebKitStack(input.Ctx, input.Config, input.DataDir, input.CacheDir, input.ThemeManager, *log)
262+
263+ // Wait for database
264+ <-dbDone
265+
266+ if dbErr != nil {
267+ return nil, fmt.Errorf("database initialization: %w", dbErr)
268+ }
269+
270+ return &ParallelDBWebKitResult{
271+ DB: db,
272+ DBCleanup: dbCleanup,
273+ Stack: stack,
274+ }, nil
186275 }
@@ -17,8 +17,15 @@ import (
17 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"17 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
18 "github.com/bnema/dumber/internal/logging"18 "github.com/bnema/dumber/internal/logging"
19 "github.com/bnema/dumber/internal/ui/theme"19 "github.com/bnema/dumber/internal/ui/theme"
20+ sqlite3 "github.com/ncruces/go-sqlite3"
20 )21 )
21 22
23+// DatabaseResult holds database connection and cleanup function.
24+type DatabaseResult struct {
25+ DB *sql.DB
26+ Cleanup func()
27+}
28+
22 // ParallelInitResult holds the results of parallel initialization phase.29 // ParallelInitResult holds the results of parallel initialization phase.
23 type ParallelInitResult struct {30 type ParallelInitResult struct {
24 DataDir string31 DataDir string
@@ -76,7 +83,14 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
76 )83 )
77 84
78 start := time.Now()85 start := time.Now()
79- wg.Add(4)86+ wg.Add(5)
87+
88+ // Pre-initialize SQLite WASM runtime (expensive compilation)
89+ // This runs concurrently with other checks so it's hidden from critical path.
90+ go func() {
91+ defer wg.Done()
92+ _ = sqlite3.Initialize()
93+ }()
80 94
81 // Resolve directories95 // Resolve directories
82 go func() {96 go func() {
@@ -171,16 +185,91 @@ func resolveWebKitDirs() (dataDir, cacheDir string, err error) {
171 return dataDir, cacheDir, nil185 return dataDir, cacheDir, nil
172 }186 }
173 187
174-// OpenDatabase opens and initializes the SQLite database.188+// OpenDatabase opens and initializes the SQLite database using XDG paths.
175 // Returns error if database cannot be opened or migrations fail.189 // Returns error if database cannot be opened or migrations fail.
176-func OpenDatabase(ctx context.Context, dataDir string) (*sql.DB, func(), error) {190+func OpenDatabase(ctx context.Context) (*DatabaseResult, error) {
177- dbPath := filepath.Join(dataDir, "dumber.db")191+ dbPath, err := config.GetDatabaseFile()
192+ if err != nil {
193+ return nil, fmt.Errorf("resolve database path: %w", err)
194+ }
178 db, err := sqlite.NewConnection(ctx, dbPath)195 db, err := sqlite.NewConnection(ctx, dbPath)
179 if err != nil {196 if err != nil {
180- return nil, nil, fmt.Errorf("initialize database at %s: %w", dbPath, err)197+ return nil, fmt.Errorf("initialize database at %s: %w", dbPath, err)
181 }198 }
182- cleanup := func() {199+ return &DatabaseResult{
183- _ = sqlite.Close(db)200+ DB: db,
201+ Cleanup: func() {
202+ _ = sqlite.Close(db)
203+ },
204+ }, nil
205+}
206+
207+// CreateLazyDatabase creates a lazy database provider that defers initialization.
208+// The database is initialized on first access, allowing the UI to render faster.
209+func CreateLazyDatabase() (*sqlite.LazyDB, error) {
210+ dbPath, err := config.GetDatabaseFile()
211+ if err != nil {
212+ return nil, fmt.Errorf("resolve database path: %w", err)
184 }213 }
185- return db, cleanup, nil214+ return sqlite.NewLazyDB(dbPath), nil
215+}
216+
217+// ParallelDBWebKitResult holds results from parallel DB and WebKit initialization.
218+type ParallelDBWebKitResult struct {
219+ DB *sql.DB
220+ DBCleanup func()
221+ Stack WebKitStack
222+}
223+
224+// ParallelDBWebKitInput holds inputs for parallel DB and WebKit initialization.
225+type ParallelDBWebKitInput struct {
226+ Ctx context.Context
227+ Config *config.Config
228+ DataDir string // For WebKit context
229+ CacheDir string // For WebKit cache
230+ ThemeManager *theme.Manager
231+}
232+
233+// Note: Database path is resolved via config.GetDatabaseFile() internally.
234+
235+// RunParallelDBWebKit initializes database in background while WebKit runs on main thread.
236+// Database init happens in goroutine (pure Go/WASM), WebKit must stay on main thread (GTK).
237+// This saves ~150ms by overlapping DB migrations with WebKit context creation.
238+func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult, error) {
239+ log := logging.FromContext(input.Ctx)
240+
241+ var (
242+ db *sql.DB
243+ dbCleanup func()
244+ dbErr error
245+ dbDone = make(chan struct{})
246+ )
247+
248+ // Database initialization in background (WASM is thread-safe)
249+ go func() {
250+ defer close(dbDone)
251+ dbResult, err := OpenDatabase(input.Ctx)
252+ if err != nil {
253+ dbErr = err
254+ return
255+ }
256+ db = dbResult.DB
257+ dbCleanup = dbResult.Cleanup
258+ }()
259+
260+ // WebKit stack on main thread (GTK requirement)
261+ stack := BuildWebKitStack(input.Ctx, input.Config, input.DataDir, input.CacheDir, input.ThemeManager, *log)
262+
263+ // Wait for database
264+ <-dbDone
265+
266+ if dbErr != nil {
267+ return nil, fmt.Errorf("database initialization: %w", dbErr)
268+ }
269+
270+ return &ParallelDBWebKitResult{
271+ DB: db,
272+ DBCleanup: dbCleanup,
273+ Stack: stack,
274+ }, nil
186 }275 }
M internal/infrastructure/persistence/sqlite/connection.go +19 -6
@@ -33,6 +33,9 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
3333 return nil, fmt.Errorf("failed to open database: %w", err)
3434 }
3535
36+ // Configure connection pool (must be done before any queries)
37+ configurePool(db)
38+
3639 // Test the connection
3740 if err := db.Ping(); err != nil {
3841 _ = db.Close()
@@ -59,12 +62,13 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
5962 // applyPragmas configures SQLite for optimal performance.
6063 func applyPragmas(db *sql.DB) error {
6164 pragmas := []string{
62- "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access
63- "PRAGMA synchronous = NORMAL", // Safe in WAL mode
64- "PRAGMA cache_size = -64000", // 64MB cache
65- "PRAGMA temp_store = MEMORY", // Temporary tables in RAM
66- "PRAGMA mmap_size = 30000000000", // 30GB memory-mapped I/O
67- "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention
65+ "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access
66+ "PRAGMA synchronous = NORMAL", // Safe in WAL mode
67+ "PRAGMA cache_size = -64000", // 64MB cache
68+ "PRAGMA temp_store = MEMORY", // Temporary tables in RAM
69+ "PRAGMA mmap_size = 268435456", // 256MB memory-mapped I/O (reasonable for browser history)
70+ "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention
71+ "PRAGMA foreign_keys = ON", // Enable referential integrity
6872 }
6973
7074 for _, pragma := range pragmas {
@@ -76,6 +80,15 @@ func applyPragmas(db *sql.DB) error {
7680 return nil
7781 }
7882
83+// configurePool sets connection pool parameters optimized for SQLite.
84+// SQLite only supports one writer at a time, so we limit connections.
85+func configurePool(db *sql.DB) {
86+ db.SetMaxOpenConns(1) // SQLite is single-writer
87+ db.SetMaxIdleConns(1) // Keep one connection alive
88+ db.SetConnMaxLifetime(0) // Never expire connections
89+ db.SetConnMaxIdleTime(0) // Never close idle connections
90+}
91+
7992 // Close closes the database connection gracefully.
8093 func Close(db *sql.DB) error {
8194 if db == nil {
@@ -33,6 +33,9 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
33 return nil, fmt.Errorf("failed to open database: %w", err)33 return nil, fmt.Errorf("failed to open database: %w", err)
34 }34 }
35 35
36+ // Configure connection pool (must be done before any queries)
37+ configurePool(db)
38+
36 // Test the connection39 // Test the connection
37 if err := db.Ping(); err != nil {40 if err := db.Ping(); err != nil {
38 _ = db.Close()41 _ = db.Close()
@@ -59,12 +62,13 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
59 // applyPragmas configures SQLite for optimal performance.62 // applyPragmas configures SQLite for optimal performance.
60 func applyPragmas(db *sql.DB) error {63 func applyPragmas(db *sql.DB) error {
61 pragmas := []string{64 pragmas := []string{
62- "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access65+ "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access
63- "PRAGMA synchronous = NORMAL", // Safe in WAL mode66+ "PRAGMA synchronous = NORMAL", // Safe in WAL mode
64- "PRAGMA cache_size = -64000", // 64MB cache67+ "PRAGMA cache_size = -64000", // 64MB cache
65- "PRAGMA temp_store = MEMORY", // Temporary tables in RAM68+ "PRAGMA temp_store = MEMORY", // Temporary tables in RAM
66- "PRAGMA mmap_size = 30000000000", // 30GB memory-mapped I/O69+ "PRAGMA mmap_size = 268435456", // 256MB memory-mapped I/O (reasonable for browser history)
67- "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention70+ "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention
71+ "PRAGMA foreign_keys = ON", // Enable referential integrity
68 }72 }
69 73
70 for _, pragma := range pragmas {74 for _, pragma := range pragmas {
@@ -76,6 +80,15 @@ func applyPragmas(db *sql.DB) error {
76 return nil80 return nil
77 }81 }
78 82
83+// configurePool sets connection pool parameters optimized for SQLite.
84+// SQLite only supports one writer at a time, so we limit connections.
85+func configurePool(db *sql.DB) {
86+ db.SetMaxOpenConns(1) // SQLite is single-writer
87+ db.SetMaxIdleConns(1) // Keep one connection alive
88+ db.SetConnMaxLifetime(0) // Never expire connections
89+ db.SetConnMaxIdleTime(0) // Never close idle connections
90+}
91+
79 // Close closes the database connection gracefully.92 // Close closes the database connection gracefully.
80 func Close(db *sql.DB) error {93 func Close(db *sql.DB) error {
81 if db == nil {94 if db == nil {
A internal/infrastructure/persistence/sqlite/lazy_db.go +75 -0
@@ -0,0 +1,75 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// LazyDB implements port.DatabaseProvider with lazy initialization.
14+// The database connection is created on first access, deferring the ~300-400ms
15+// WASM compilation and migration overhead until actually needed.
16+type LazyDB struct {
17+ dbPath string
18+ db *sql.DB
19+ err error
20+ once sync.Once
21+ mu sync.RWMutex
22+}
23+
24+// Compile-time interface check.
25+var _ port.DatabaseProvider = (*LazyDB)(nil)
26+
27+// NewLazyDB creates a new lazy database provider.
28+// The actual connection is not established until DB() is called.
29+func NewLazyDB(dbPath string) *LazyDB {
30+ return &LazyDB{dbPath: dbPath}
31+}
32+
33+// DB returns the database connection, initializing it if necessary.
34+// This method is thread-safe and will only initialize once.
35+func (l *LazyDB) DB(ctx context.Context) (*sql.DB, error) {
36+ l.once.Do(func() {
37+ log := logging.FromContext(ctx)
38+ log.Debug().Str("path", l.dbPath).Msg("lazy database initialization starting")
39+
40+ l.db, l.err = NewConnection(ctx, l.dbPath)
41+ if l.err != nil {
42+ log.Error().Err(l.err).Msg("lazy database initialization failed")
43+ } else {
44+ log.Debug().Msg("lazy database initialized successfully")
45+ }
46+ })
47+
48+ if l.err != nil {
49+ return nil, fmt.Errorf("database initialization failed: %w", l.err)
50+ }
51+ return l.db, nil
52+}
53+
54+// Close closes the database connection if it was initialized.
55+func (l *LazyDB) Close() error {
56+ l.mu.Lock()
57+ defer l.mu.Unlock()
58+
59+ if l.db != nil {
60+ return l.db.Close()
61+ }
62+ return nil
63+}
64+
65+// IsInitialized returns true if the database has been initialized.
66+func (l *LazyDB) IsInitialized() bool {
67+ l.mu.RLock()
68+ defer l.mu.RUnlock()
69+ return l.db != nil
70+}
71+
72+// Path returns the database path.
73+func (l *LazyDB) Path() string {
74+ return l.dbPath
75+}
@@ -0,0 +1,75 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// LazyDB implements port.DatabaseProvider with lazy initialization.
14+// The database connection is created on first access, deferring the ~300-400ms
15+// WASM compilation and migration overhead until actually needed.
16+type LazyDB struct {
17+ dbPath string
18+ db *sql.DB
19+ err error
20+ once sync.Once
21+ mu sync.RWMutex
22+}
23+
24+// Compile-time interface check.
25+var _ port.DatabaseProvider = (*LazyDB)(nil)
26+
27+// NewLazyDB creates a new lazy database provider.
28+// The actual connection is not established until DB() is called.
29+func NewLazyDB(dbPath string) *LazyDB {
30+ return &LazyDB{dbPath: dbPath}
31+}
32+
33+// DB returns the database connection, initializing it if necessary.
34+// This method is thread-safe and will only initialize once.
35+func (l *LazyDB) DB(ctx context.Context) (*sql.DB, error) {
36+ l.once.Do(func() {
37+ log := logging.FromContext(ctx)
38+ log.Debug().Str("path", l.dbPath).Msg("lazy database initialization starting")
39+
40+ l.db, l.err = NewConnection(ctx, l.dbPath)
41+ if l.err != nil {
42+ log.Error().Err(l.err).Msg("lazy database initialization failed")
43+ } else {
44+ log.Debug().Msg("lazy database initialized successfully")
45+ }
46+ })
47+
48+ if l.err != nil {
49+ return nil, fmt.Errorf("database initialization failed: %w", l.err)
50+ }
51+ return l.db, nil
52+}
53+
54+// Close closes the database connection if it was initialized.
55+func (l *LazyDB) Close() error {
56+ l.mu.Lock()
57+ defer l.mu.Unlock()
58+
59+ if l.db != nil {
60+ return l.db.Close()
61+ }
62+ return nil
63+}
64+
65+// IsInitialized returns true if the database has been initialized.
66+func (l *LazyDB) IsInitialized() bool {
67+ l.mu.RLock()
68+ defer l.mu.RUnlock()
69+ return l.db != nil
70+}
71+
72+// Path returns the database path.
73+func (l *LazyDB) Path() string {
74+ return l.dbPath
75+}
A internal/infrastructure/persistence/sqlite/lazy_repos.go +654 -0
@@ -0,0 +1,654 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/domain/repository"
12+)
13+
14+// LazyHistoryRepository wraps a history repository with lazy database initialization.
15+type LazyHistoryRepository struct {
16+ provider port.DatabaseProvider
17+ repo repository.HistoryRepository
18+ once sync.Once
19+ initErr error
20+}
21+
22+// NewLazyHistoryRepository creates a lazy-loading history repository.
23+func NewLazyHistoryRepository(provider port.DatabaseProvider) repository.HistoryRepository {
24+ return &LazyHistoryRepository{provider: provider}
25+}
26+
27+func (r *LazyHistoryRepository) init(ctx context.Context) error {
28+ r.once.Do(func() {
29+ db, err := r.provider.DB(ctx)
30+ if err != nil {
31+ r.initErr = err
32+ return
33+ }
34+ r.repo = NewHistoryRepository(db)
35+ })
36+ return r.initErr
37+}
38+
39+func (r *LazyHistoryRepository) Save(ctx context.Context, entry *entity.HistoryEntry) error {
40+ if err := r.init(ctx); err != nil {
41+ return err
42+ }
43+ return r.repo.Save(ctx, entry)
44+}
45+
46+func (r *LazyHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
47+ if err := r.init(ctx); err != nil {
48+ return nil, err
49+ }
50+ return r.repo.FindByURL(ctx, url)
51+}
52+
53+func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
54+ if err := r.init(ctx); err != nil {
55+ return nil, err
56+ }
57+ return r.repo.Search(ctx, query, limit)
58+}
59+
60+func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
61+ if err := r.init(ctx); err != nil {
62+ return nil, err
63+ }
64+ return r.repo.GetRecent(ctx, limit, offset)
65+}
66+
67+func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
68+ if err := r.init(ctx); err != nil {
69+ return err
70+ }
71+ return r.repo.IncrementVisitCount(ctx, url)
72+}
73+
74+func (r *LazyHistoryRepository) Delete(ctx context.Context, id int64) error {
75+ if err := r.init(ctx); err != nil {
76+ return err
77+ }
78+ return r.repo.Delete(ctx, id)
79+}
80+
81+func (r *LazyHistoryRepository) DeleteOlderThan(ctx context.Context, before time.Time) error {
82+ if err := r.init(ctx); err != nil {
83+ return err
84+ }
85+ return r.repo.DeleteOlderThan(ctx, before)
86+}
87+
88+func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error {
89+ if err := r.init(ctx); err != nil {
90+ return err
91+ }
92+ return r.repo.DeleteAll(ctx)
93+}
94+
95+func (r *LazyHistoryRepository) DeleteByDomain(ctx context.Context, domain string) error {
96+ if err := r.init(ctx); err != nil {
97+ return err
98+ }
99+ return r.repo.DeleteByDomain(ctx, domain)
100+}
101+
102+func (r *LazyHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
103+ if err := r.init(ctx); err != nil {
104+ return nil, err
105+ }
106+ return r.repo.GetStats(ctx)
107+}
108+
109+func (r *LazyHistoryRepository) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
110+ if err := r.init(ctx); err != nil {
111+ return nil, err
112+ }
113+ return r.repo.GetDomainStats(ctx, limit)
114+}
115+
116+func (r *LazyHistoryRepository) GetHourlyDistribution(ctx context.Context) ([]*entity.HourlyDistribution, error) {
117+ if err := r.init(ctx); err != nil {
118+ return nil, err
119+ }
120+ return r.repo.GetHourlyDistribution(ctx)
121+}
122+
123+func (r *LazyHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
124+ if err := r.init(ctx); err != nil {
125+ return nil, err
126+ }
127+ return r.repo.GetDailyVisitCount(ctx, daysAgo)
128+}
129+
130+// LazyFavoriteRepository wraps a favorite repository with lazy database initialization.
131+type LazyFavoriteRepository struct {
132+ provider port.DatabaseProvider
133+ repo repository.FavoriteRepository
134+ once sync.Once
135+ initErr error
136+}
137+
138+// NewLazyFavoriteRepository creates a lazy-loading favorite repository.
139+func NewLazyFavoriteRepository(provider port.DatabaseProvider) repository.FavoriteRepository {
140+ return &LazyFavoriteRepository{provider: provider}
141+}
142+
143+func (r *LazyFavoriteRepository) init(ctx context.Context) error {
144+ r.once.Do(func() {
145+ db, err := r.provider.DB(ctx)
146+ if err != nil {
147+ r.initErr = err
148+ return
149+ }
150+ r.repo = NewFavoriteRepository(db)
151+ })
152+ return r.initErr
153+}
154+
155+func (r *LazyFavoriteRepository) Save(ctx context.Context, fav *entity.Favorite) error {
156+ if err := r.init(ctx); err != nil {
157+ return err
158+ }
159+ return r.repo.Save(ctx, fav)
160+}
161+
162+func (r *LazyFavoriteRepository) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
163+ if err := r.init(ctx); err != nil {
164+ return nil, err
165+ }
166+ return r.repo.FindByID(ctx, id)
167+}
168+
169+func (r *LazyFavoriteRepository) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) {
170+ if err := r.init(ctx); err != nil {
171+ return nil, err
172+ }
173+ return r.repo.FindByURL(ctx, url)
174+}
175+
176+func (r *LazyFavoriteRepository) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
177+ if err := r.init(ctx); err != nil {
178+ return nil, err
179+ }
180+ return r.repo.GetAll(ctx)
181+}
182+
183+func (r *LazyFavoriteRepository) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) {
184+ if err := r.init(ctx); err != nil {
185+ return nil, err
186+ }
187+ return r.repo.GetByFolder(ctx, folderID)
188+}
189+
190+func (r *LazyFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
191+ if err := r.init(ctx); err != nil {
192+ return nil, err
193+ }
194+ return r.repo.GetByShortcut(ctx, key)
195+}
196+
197+func (r *LazyFavoriteRepository) UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error {
198+ if err := r.init(ctx); err != nil {
199+ return err
200+ }
201+ return r.repo.UpdatePosition(ctx, id, position)
202+}
203+
204+func (r *LazyFavoriteRepository) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
205+ if err := r.init(ctx); err != nil {
206+ return err
207+ }
208+ return r.repo.SetFolder(ctx, id, folderID)
209+}
210+
211+func (r *LazyFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
212+ if err := r.init(ctx); err != nil {
213+ return err
214+ }
215+ return r.repo.SetShortcut(ctx, id, key)
216+}
217+
218+func (r *LazyFavoriteRepository) Delete(ctx context.Context, id entity.FavoriteID) error {
219+ if err := r.init(ctx); err != nil {
220+ return err
221+ }
222+ return r.repo.Delete(ctx, id)
223+}
224+
225+// LazyFolderRepository wraps a folder repository with lazy database initialization.
226+type LazyFolderRepository struct {
227+ provider port.DatabaseProvider
228+ repo repository.FolderRepository
229+ once sync.Once
230+ initErr error
231+}
232+
233+// NewLazyFolderRepository creates a lazy-loading folder repository.
234+func NewLazyFolderRepository(provider port.DatabaseProvider) repository.FolderRepository {
235+ return &LazyFolderRepository{provider: provider}
236+}
237+
238+func (r *LazyFolderRepository) init(ctx context.Context) error {
239+ r.once.Do(func() {
240+ db, err := r.provider.DB(ctx)
241+ if err != nil {
242+ r.initErr = err
243+ return
244+ }
245+ r.repo = NewFolderRepository(db)
246+ })
247+ return r.initErr
248+}
249+
250+func (r *LazyFolderRepository) Save(ctx context.Context, folder *entity.Folder) error {
251+ if err := r.init(ctx); err != nil {
252+ return err
253+ }
254+ return r.repo.Save(ctx, folder)
255+}
256+
257+func (r *LazyFolderRepository) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) {
258+ if err := r.init(ctx); err != nil {
259+ return nil, err
260+ }
261+ return r.repo.FindByID(ctx, id)
262+}
263+
264+func (r *LazyFolderRepository) GetAll(ctx context.Context) ([]*entity.Folder, error) {
265+ if err := r.init(ctx); err != nil {
266+ return nil, err
267+ }
268+ return r.repo.GetAll(ctx)
269+}
270+
271+func (r *LazyFolderRepository) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) {
272+ if err := r.init(ctx); err != nil {
273+ return nil, err
274+ }
275+ return r.repo.GetChildren(ctx, parentID)
276+}
277+
278+func (r *LazyFolderRepository) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error {
279+ if err := r.init(ctx); err != nil {
280+ return err
281+ }
282+ return r.repo.UpdatePosition(ctx, id, position)
283+}
284+
285+func (r *LazyFolderRepository) Delete(ctx context.Context, id entity.FolderID) error {
286+ if err := r.init(ctx); err != nil {
287+ return err
288+ }
289+ return r.repo.Delete(ctx, id)
290+}
291+
292+// LazyTagRepository wraps a tag repository with lazy database initialization.
293+type LazyTagRepository struct {
294+ provider port.DatabaseProvider
295+ repo repository.TagRepository
296+ once sync.Once
297+ initErr error
298+}
299+
300+// NewLazyTagRepository creates a lazy-loading tag repository.
301+func NewLazyTagRepository(provider port.DatabaseProvider) repository.TagRepository {
302+ return &LazyTagRepository{provider: provider}
303+}
304+
305+func (r *LazyTagRepository) init(ctx context.Context) error {
306+ r.once.Do(func() {
307+ db, err := r.provider.DB(ctx)
308+ if err != nil {
309+ r.initErr = err
310+ return
311+ }
312+ r.repo = NewTagRepository(db)
313+ })
314+ return r.initErr
315+}
316+
317+func (r *LazyTagRepository) Save(ctx context.Context, tag *entity.Tag) error {
318+ if err := r.init(ctx); err != nil {
319+ return err
320+ }
321+ return r.repo.Save(ctx, tag)
322+}
323+
324+func (r *LazyTagRepository) FindByID(ctx context.Context, id entity.TagID) (*entity.Tag, error) {
325+ if err := r.init(ctx); err != nil {
326+ return nil, err
327+ }
328+ return r.repo.FindByID(ctx, id)
329+}
330+
331+func (r *LazyTagRepository) FindByName(ctx context.Context, name string) (*entity.Tag, error) {
332+ if err := r.init(ctx); err != nil {
333+ return nil, err
334+ }
335+ return r.repo.FindByName(ctx, name)
336+}
337+
338+func (r *LazyTagRepository) GetAll(ctx context.Context) ([]*entity.Tag, error) {
339+ if err := r.init(ctx); err != nil {
340+ return nil, err
341+ }
342+ return r.repo.GetAll(ctx)
343+}
344+
345+func (r *LazyTagRepository) AssignToFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
346+ if err := r.init(ctx); err != nil {
347+ return err
348+ }
349+ return r.repo.AssignToFavorite(ctx, tagID, favID)
350+}
351+
352+func (r *LazyTagRepository) RemoveFromFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
353+ if err := r.init(ctx); err != nil {
354+ return err
355+ }
356+ return r.repo.RemoveFromFavorite(ctx, tagID, favID)
357+}
358+
359+func (r *LazyTagRepository) GetForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
360+ if err := r.init(ctx); err != nil {
361+ return nil, err
362+ }
363+ return r.repo.GetForFavorite(ctx, favID)
364+}
365+
366+func (r *LazyTagRepository) Delete(ctx context.Context, id entity.TagID) error {
367+ if err := r.init(ctx); err != nil {
368+ return err
369+ }
370+ return r.repo.Delete(ctx, id)
371+}
372+
373+// LazyZoomRepository wraps a zoom repository with lazy database initialization.
374+type LazyZoomRepository struct {
375+ provider port.DatabaseProvider
376+ repo repository.ZoomRepository
377+ once sync.Once
378+ initErr error
379+}
380+
381+// NewLazyZoomRepository creates a lazy-loading zoom repository.
382+func NewLazyZoomRepository(provider port.DatabaseProvider) repository.ZoomRepository {
383+ return &LazyZoomRepository{provider: provider}
384+}
385+
386+func (r *LazyZoomRepository) init(ctx context.Context) error {
387+ r.once.Do(func() {
388+ db, err := r.provider.DB(ctx)
389+ if err != nil {
390+ r.initErr = err
391+ return
392+ }
393+ r.repo = NewZoomRepository(db)
394+ })
395+ return r.initErr
396+}
397+
398+func (r *LazyZoomRepository) Get(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
399+ if err := r.init(ctx); err != nil {
400+ return nil, err
401+ }
402+ return r.repo.Get(ctx, domain)
403+}
404+
405+func (r *LazyZoomRepository) Set(ctx context.Context, zoom *entity.ZoomLevel) error {
406+ if err := r.init(ctx); err != nil {
407+ return err
408+ }
409+ return r.repo.Set(ctx, zoom)
410+}
411+
412+func (r *LazyZoomRepository) Delete(ctx context.Context, domain string) error {
413+ if err := r.init(ctx); err != nil {
414+ return err
415+ }
416+ return r.repo.Delete(ctx, domain)
417+}
418+
419+func (r *LazyZoomRepository) GetAll(ctx context.Context) ([]*entity.ZoomLevel, error) {
420+ if err := r.init(ctx); err != nil {
421+ return nil, err
422+ }
423+ return r.repo.GetAll(ctx)
424+}
425+
426+// LazySessionRepository wraps a session repository with lazy database initialization.
427+type LazySessionRepository struct {
428+ provider port.DatabaseProvider
429+ repo repository.SessionRepository
430+ once sync.Once
431+ initErr error
432+}
433+
434+// NewLazySessionRepository creates a lazy-loading session repository.
435+func NewLazySessionRepository(provider port.DatabaseProvider) repository.SessionRepository {
436+ return &LazySessionRepository{provider: provider}
437+}
438+
439+func (r *LazySessionRepository) init(ctx context.Context) error {
440+ r.once.Do(func() {
441+ db, err := r.provider.DB(ctx)
442+ if err != nil {
443+ r.initErr = err
444+ return
445+ }
446+ r.repo = NewSessionRepository(db)
447+ })
448+ return r.initErr
449+}
450+
451+func (r *LazySessionRepository) Save(ctx context.Context, session *entity.Session) error {
452+ if err := r.init(ctx); err != nil {
453+ return err
454+ }
455+ return r.repo.Save(ctx, session)
456+}
457+
458+func (r *LazySessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
459+ if err := r.init(ctx); err != nil {
460+ return nil, err
461+ }
462+ return r.repo.FindByID(ctx, id)
463+}
464+
465+func (r *LazySessionRepository) GetActive(ctx context.Context) (*entity.Session, error) {
466+ if err := r.init(ctx); err != nil {
467+ return nil, err
468+ }
469+ return r.repo.GetActive(ctx)
470+}
471+
472+func (r *LazySessionRepository) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
473+ if err := r.init(ctx); err != nil {
474+ return nil, err
475+ }
476+ return r.repo.GetRecent(ctx, limit)
477+}
478+
479+func (r *LazySessionRepository) MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error {
480+ if err := r.init(ctx); err != nil {
481+ return err
482+ }
483+ return r.repo.MarkEnded(ctx, id, endedAt)
484+}
485+
486+func (r *LazySessionRepository) Delete(ctx context.Context, id entity.SessionID) error {
487+ if err := r.init(ctx); err != nil {
488+ return err
489+ }
490+ return r.repo.Delete(ctx, id)
491+}
492+
493+func (r *LazySessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) {
494+ if err := r.init(ctx); err != nil {
495+ return 0, err
496+ }
497+ return r.repo.DeleteOldestExited(ctx, keepCount)
498+}
499+
500+func (r *LazySessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) {
501+ if err := r.init(ctx); err != nil {
502+ return 0, err
503+ }
504+ return r.repo.DeleteExitedBefore(ctx, cutoff)
505+}
506+
507+// LazySessionStateRepository wraps a session state repository with lazy database initialization.
508+type LazySessionStateRepository struct {
509+ provider port.DatabaseProvider
510+ repo repository.SessionStateRepository
511+ once sync.Once
512+ initErr error
513+}
514+
515+// NewLazySessionStateRepository creates a lazy-loading session state repository.
516+func NewLazySessionStateRepository(provider port.DatabaseProvider) repository.SessionStateRepository {
517+ return &LazySessionStateRepository{provider: provider}
518+}
519+
520+func (r *LazySessionStateRepository) init(ctx context.Context) error {
521+ r.once.Do(func() {
522+ db, err := r.provider.DB(ctx)
523+ if err != nil {
524+ r.initErr = err
525+ return
526+ }
527+ r.repo = NewSessionStateRepository(db)
528+ })
529+ return r.initErr
530+}
531+
532+func (r *LazySessionStateRepository) SaveSnapshot(ctx context.Context, state *entity.SessionState) error {
533+ if err := r.init(ctx); err != nil {
534+ return err
535+ }
536+ return r.repo.SaveSnapshot(ctx, state)
537+}
538+
539+func (r *LazySessionStateRepository) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) {
540+ if err := r.init(ctx); err != nil {
541+ return nil, err
542+ }
543+ return r.repo.GetSnapshot(ctx, sessionID)
544+}
545+
546+func (r *LazySessionStateRepository) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
547+ if err := r.init(ctx); err != nil {
548+ return err
549+ }
550+ return r.repo.DeleteSnapshot(ctx, sessionID)
551+}
552+
553+func (r *LazySessionStateRepository) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) {
554+ if err := r.init(ctx); err != nil {
555+ return nil, err
556+ }
557+ return r.repo.GetAllSnapshots(ctx)
558+}
559+
560+func (r *LazySessionStateRepository) GetTotalSnapshotsSize(ctx context.Context) (int64, error) {
561+ if err := r.init(ctx); err != nil {
562+ return 0, err
563+ }
564+ return r.repo.GetTotalSnapshotsSize(ctx)
565+}
566+
567+// LazyContentWhitelistRepository wraps a content whitelist repository with lazy database initialization.
568+type LazyContentWhitelistRepository struct {
569+ provider port.DatabaseProvider
570+ repo repository.ContentWhitelistRepository
571+ once sync.Once
572+ initErr error
573+}
574+
575+// NewLazyContentWhitelistRepository creates a lazy-loading content whitelist repository.
576+func NewLazyContentWhitelistRepository(provider port.DatabaseProvider) repository.ContentWhitelistRepository {
577+ return &LazyContentWhitelistRepository{provider: provider}
578+}
579+
580+func (r *LazyContentWhitelistRepository) init(ctx context.Context) error {
581+ r.once.Do(func() {
582+ db, err := r.provider.DB(ctx)
583+ if err != nil {
584+ r.initErr = err
585+ return
586+ }
587+ r.repo = NewContentWhitelistRepository(db)
588+ })
589+ return r.initErr
590+}
591+
592+func (r *LazyContentWhitelistRepository) Add(ctx context.Context, domain string) error {
593+ if err := r.init(ctx); err != nil {
594+ return err
595+ }
596+ return r.repo.Add(ctx, domain)
597+}
598+
599+func (r *LazyContentWhitelistRepository) Remove(ctx context.Context, domain string) error {
600+ if err := r.init(ctx); err != nil {
601+ return err
602+ }
603+ return r.repo.Remove(ctx, domain)
604+}
605+
606+func (r *LazyContentWhitelistRepository) Contains(ctx context.Context, domain string) (bool, error) {
607+ if err := r.init(ctx); err != nil {
608+ return false, err
609+ }
610+ return r.repo.Contains(ctx, domain)
611+}
612+
613+func (r *LazyContentWhitelistRepository) GetAll(ctx context.Context) ([]string, error) {
614+ if err := r.init(ctx); err != nil {
615+ return nil, err
616+ }
617+ return r.repo.GetAll(ctx)
618+}
619+
620+// LazyRepositories holds all lazy-loaded repositories.
621+type LazyRepositories struct {
622+ History repository.HistoryRepository
623+ Favorite repository.FavoriteRepository
624+ Folder repository.FolderRepository
625+ Tag repository.TagRepository
626+ Zoom repository.ZoomRepository
627+ Session repository.SessionRepository
628+ SessionState repository.SessionStateRepository
629+ Filter repository.ContentWhitelistRepository
630+}
631+
632+// NewLazyRepositories creates all lazy repositories from a database provider.
633+func NewLazyRepositories(provider port.DatabaseProvider) *LazyRepositories {
634+ return &LazyRepositories{
635+ History: NewLazyHistoryRepository(provider),
636+ Favorite: NewLazyFavoriteRepository(provider),
637+ Folder: NewLazyFolderRepository(provider),
638+ Tag: NewLazyTagRepository(provider),
639+ Zoom: NewLazyZoomRepository(provider),
640+ Session: NewLazySessionRepository(provider),
641+ SessionState: NewLazySessionStateRepository(provider),
642+ Filter: NewLazyContentWhitelistRepository(provider),
643+ }
644+}
645+
646+// GetDB returns the underlying *sql.DB for operations that need it directly.
647+// Returns nil if the database hasn't been initialized yet.
648+func GetDB(provider port.DatabaseProvider, ctx context.Context) *sql.DB {
649+ db, err := provider.DB(ctx)
650+ if err != nil {
651+ return nil
652+ }
653+ return db
654+}
@@ -0,0 +1,654 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/domain/repository"
12+)
13+
14+// LazyHistoryRepository wraps a history repository with lazy database initialization.
15+type LazyHistoryRepository struct {
16+ provider port.DatabaseProvider
17+ repo repository.HistoryRepository
18+ once sync.Once
19+ initErr error
20+}
21+
22+// NewLazyHistoryRepository creates a lazy-loading history repository.
23+func NewLazyHistoryRepository(provider port.DatabaseProvider) repository.HistoryRepository {
24+ return &LazyHistoryRepository{provider: provider}
25+}
26+
27+func (r *LazyHistoryRepository) init(ctx context.Context) error {
28+ r.once.Do(func() {
29+ db, err := r.provider.DB(ctx)
30+ if err != nil {
31+ r.initErr = err
32+ return
33+ }
34+ r.repo = NewHistoryRepository(db)
35+ })
36+ return r.initErr
37+}
38+
39+func (r *LazyHistoryRepository) Save(ctx context.Context, entry *entity.HistoryEntry) error {
40+ if err := r.init(ctx); err != nil {
41+ return err
42+ }
43+ return r.repo.Save(ctx, entry)
44+}
45+
46+func (r *LazyHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
47+ if err := r.init(ctx); err != nil {
48+ return nil, err
49+ }
50+ return r.repo.FindByURL(ctx, url)
51+}
52+
53+func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
54+ if err := r.init(ctx); err != nil {
55+ return nil, err
56+ }
57+ return r.repo.Search(ctx, query, limit)
58+}
59+
60+func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
61+ if err := r.init(ctx); err != nil {
62+ return nil, err
63+ }
64+ return r.repo.GetRecent(ctx, limit, offset)
65+}
66+
67+func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
68+ if err := r.init(ctx); err != nil {
69+ return err
70+ }
71+ return r.repo.IncrementVisitCount(ctx, url)
72+}
73+
74+func (r *LazyHistoryRepository) Delete(ctx context.Context, id int64) error {
75+ if err := r.init(ctx); err != nil {
76+ return err
77+ }
78+ return r.repo.Delete(ctx, id)
79+}
80+
81+func (r *LazyHistoryRepository) DeleteOlderThan(ctx context.Context, before time.Time) error {
82+ if err := r.init(ctx); err != nil {
83+ return err
84+ }
85+ return r.repo.DeleteOlderThan(ctx, before)
86+}
87+
88+func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error {
89+ if err := r.init(ctx); err != nil {
90+ return err
91+ }
92+ return r.repo.DeleteAll(ctx)
93+}
94+
95+func (r *LazyHistoryRepository) DeleteByDomain(ctx context.Context, domain string) error {
96+ if err := r.init(ctx); err != nil {
97+ return err
98+ }
99+ return r.repo.DeleteByDomain(ctx, domain)
100+}
101+
102+func (r *LazyHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
103+ if err := r.init(ctx); err != nil {
104+ return nil, err
105+ }
106+ return r.repo.GetStats(ctx)
107+}
108+
109+func (r *LazyHistoryRepository) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
110+ if err := r.init(ctx); err != nil {
111+ return nil, err
112+ }
113+ return r.repo.GetDomainStats(ctx, limit)
114+}
115+
116+func (r *LazyHistoryRepository) GetHourlyDistribution(ctx context.Context) ([]*entity.HourlyDistribution, error) {
117+ if err := r.init(ctx); err != nil {
118+ return nil, err
119+ }
120+ return r.repo.GetHourlyDistribution(ctx)
121+}
122+
123+func (r *LazyHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
124+ if err := r.init(ctx); err != nil {
125+ return nil, err
126+ }
127+ return r.repo.GetDailyVisitCount(ctx, daysAgo)
128+}
129+
130+// LazyFavoriteRepository wraps a favorite repository with lazy database initialization.
131+type LazyFavoriteRepository struct {
132+ provider port.DatabaseProvider
133+ repo repository.FavoriteRepository
134+ once sync.Once
135+ initErr error
136+}
137+
138+// NewLazyFavoriteRepository creates a lazy-loading favorite repository.
139+func NewLazyFavoriteRepository(provider port.DatabaseProvider) repository.FavoriteRepository {
140+ return &LazyFavoriteRepository{provider: provider}
141+}
142+
143+func (r *LazyFavoriteRepository) init(ctx context.Context) error {
144+ r.once.Do(func() {
145+ db, err := r.provider.DB(ctx)
146+ if err != nil {
147+ r.initErr = err
148+ return
149+ }
150+ r.repo = NewFavoriteRepository(db)
151+ })
152+ return r.initErr
153+}
154+
155+func (r *LazyFavoriteRepository) Save(ctx context.Context, fav *entity.Favorite) error {
156+ if err := r.init(ctx); err != nil {
157+ return err
158+ }
159+ return r.repo.Save(ctx, fav)
160+}
161+
162+func (r *LazyFavoriteRepository) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
163+ if err := r.init(ctx); err != nil {
164+ return nil, err
165+ }
166+ return r.repo.FindByID(ctx, id)
167+}
168+
169+func (r *LazyFavoriteRepository) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) {
170+ if err := r.init(ctx); err != nil {
171+ return nil, err
172+ }
173+ return r.repo.FindByURL(ctx, url)
174+}
175+
176+func (r *LazyFavoriteRepository) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
177+ if err := r.init(ctx); err != nil {
178+ return nil, err
179+ }
180+ return r.repo.GetAll(ctx)
181+}
182+
183+func (r *LazyFavoriteRepository) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) {
184+ if err := r.init(ctx); err != nil {
185+ return nil, err
186+ }
187+ return r.repo.GetByFolder(ctx, folderID)
188+}
189+
190+func (r *LazyFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
191+ if err := r.init(ctx); err != nil {
192+ return nil, err
193+ }
194+ return r.repo.GetByShortcut(ctx, key)
195+}
196+
197+func (r *LazyFavoriteRepository) UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error {
198+ if err := r.init(ctx); err != nil {
199+ return err
200+ }
201+ return r.repo.UpdatePosition(ctx, id, position)
202+}
203+
204+func (r *LazyFavoriteRepository) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
205+ if err := r.init(ctx); err != nil {
206+ return err
207+ }
208+ return r.repo.SetFolder(ctx, id, folderID)
209+}
210+
211+func (r *LazyFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
212+ if err := r.init(ctx); err != nil {
213+ return err
214+ }
215+ return r.repo.SetShortcut(ctx, id, key)
216+}
217+
218+func (r *LazyFavoriteRepository) Delete(ctx context.Context, id entity.FavoriteID) error {
219+ if err := r.init(ctx); err != nil {
220+ return err
221+ }
222+ return r.repo.Delete(ctx, id)
223+}
224+
225+// LazyFolderRepository wraps a folder repository with lazy database initialization.
226+type LazyFolderRepository struct {
227+ provider port.DatabaseProvider
228+ repo repository.FolderRepository
229+ once sync.Once
230+ initErr error
231+}
232+
233+// NewLazyFolderRepository creates a lazy-loading folder repository.
234+func NewLazyFolderRepository(provider port.DatabaseProvider) repository.FolderRepository {
235+ return &LazyFolderRepository{provider: provider}
236+}
237+
238+func (r *LazyFolderRepository) init(ctx context.Context) error {
239+ r.once.Do(func() {
240+ db, err := r.provider.DB(ctx)
241+ if err != nil {
242+ r.initErr = err
243+ return
244+ }
245+ r.repo = NewFolderRepository(db)
246+ })
247+ return r.initErr
248+}
249+
250+func (r *LazyFolderRepository) Save(ctx context.Context, folder *entity.Folder) error {
251+ if err := r.init(ctx); err != nil {
252+ return err
253+ }
254+ return r.repo.Save(ctx, folder)
255+}
256+
257+func (r *LazyFolderRepository) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) {
258+ if err := r.init(ctx); err != nil {
259+ return nil, err
260+ }
261+ return r.repo.FindByID(ctx, id)
262+}
263+
264+func (r *LazyFolderRepository) GetAll(ctx context.Context) ([]*entity.Folder, error) {
265+ if err := r.init(ctx); err != nil {
266+ return nil, err
267+ }
268+ return r.repo.GetAll(ctx)
269+}
270+
271+func (r *LazyFolderRepository) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) {
272+ if err := r.init(ctx); err != nil {
273+ return nil, err
274+ }
275+ return r.repo.GetChildren(ctx, parentID)
276+}
277+
278+func (r *LazyFolderRepository) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error {
279+ if err := r.init(ctx); err != nil {
280+ return err
281+ }
282+ return r.repo.UpdatePosition(ctx, id, position)
283+}
284+
285+func (r *LazyFolderRepository) Delete(ctx context.Context, id entity.FolderID) error {
286+ if err := r.init(ctx); err != nil {
287+ return err
288+ }
289+ return r.repo.Delete(ctx, id)
290+}
291+
292+// LazyTagRepository wraps a tag repository with lazy database initialization.
293+type LazyTagRepository struct {
294+ provider port.DatabaseProvider
295+ repo repository.TagRepository
296+ once sync.Once
297+ initErr error
298+}
299+
300+// NewLazyTagRepository creates a lazy-loading tag repository.
301+func NewLazyTagRepository(provider port.DatabaseProvider) repository.TagRepository {
302+ return &LazyTagRepository{provider: provider}
303+}
304+
305+func (r *LazyTagRepository) init(ctx context.Context) error {
306+ r.once.Do(func() {
307+ db, err := r.provider.DB(ctx)
308+ if err != nil {
309+ r.initErr = err
310+ return
311+ }
312+ r.repo = NewTagRepository(db)
313+ })
314+ return r.initErr
315+}
316+
317+func (r *LazyTagRepository) Save(ctx context.Context, tag *entity.Tag) error {
318+ if err := r.init(ctx); err != nil {
319+ return err
320+ }
321+ return r.repo.Save(ctx, tag)
322+}
323+
324+func (r *LazyTagRepository) FindByID(ctx context.Context, id entity.TagID) (*entity.Tag, error) {
325+ if err := r.init(ctx); err != nil {
326+ return nil, err
327+ }
328+ return r.repo.FindByID(ctx, id)
329+}
330+
331+func (r *LazyTagRepository) FindByName(ctx context.Context, name string) (*entity.Tag, error) {
332+ if err := r.init(ctx); err != nil {
333+ return nil, err
334+ }
335+ return r.repo.FindByName(ctx, name)
336+}
337+
338+func (r *LazyTagRepository) GetAll(ctx context.Context) ([]*entity.Tag, error) {
339+ if err := r.init(ctx); err != nil {
340+ return nil, err
341+ }
342+ return r.repo.GetAll(ctx)
343+}
344+
345+func (r *LazyTagRepository) AssignToFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
346+ if err := r.init(ctx); err != nil {
347+ return err
348+ }
349+ return r.repo.AssignToFavorite(ctx, tagID, favID)
350+}
351+
352+func (r *LazyTagRepository) RemoveFromFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
353+ if err := r.init(ctx); err != nil {
354+ return err
355+ }
356+ return r.repo.RemoveFromFavorite(ctx, tagID, favID)
357+}
358+
359+func (r *LazyTagRepository) GetForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
360+ if err := r.init(ctx); err != nil {
361+ return nil, err
362+ }
363+ return r.repo.GetForFavorite(ctx, favID)
364+}
365+
366+func (r *LazyTagRepository) Delete(ctx context.Context, id entity.TagID) error {
367+ if err := r.init(ctx); err != nil {
368+ return err
369+ }
370+ return r.repo.Delete(ctx, id)
371+}
372+
373+// LazyZoomRepository wraps a zoom repository with lazy database initialization.
374+type LazyZoomRepository struct {
375+ provider port.DatabaseProvider
376+ repo repository.ZoomRepository
377+ once sync.Once
378+ initErr error
379+}
380+
381+// NewLazyZoomRepository creates a lazy-loading zoom repository.
382+func NewLazyZoomRepository(provider port.DatabaseProvider) repository.ZoomRepository {
383+ return &LazyZoomRepository{provider: provider}
384+}
385+
386+func (r *LazyZoomRepository) init(ctx context.Context) error {
387+ r.once.Do(func() {
388+ db, err := r.provider.DB(ctx)
389+ if err != nil {
390+ r.initErr = err
391+ return
392+ }
393+ r.repo = NewZoomRepository(db)
394+ })
395+ return r.initErr
396+}
397+
398+func (r *LazyZoomRepository) Get(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
399+ if err := r.init(ctx); err != nil {
400+ return nil, err
401+ }
402+ return r.repo.Get(ctx, domain)
403+}
404+
405+func (r *LazyZoomRepository) Set(ctx context.Context, zoom *entity.ZoomLevel) error {
406+ if err := r.init(ctx); err != nil {
407+ return err
408+ }
409+ return r.repo.Set(ctx, zoom)
410+}
411+
412+func (r *LazyZoomRepository) Delete(ctx context.Context, domain string) error {
413+ if err := r.init(ctx); err != nil {
414+ return err
415+ }
416+ return r.repo.Delete(ctx, domain)
417+}
418+
419+func (r *LazyZoomRepository) GetAll(ctx context.Context) ([]*entity.ZoomLevel, error) {
420+ if err := r.init(ctx); err != nil {
421+ return nil, err
422+ }
423+ return r.repo.GetAll(ctx)
424+}
425+
426+// LazySessionRepository wraps a session repository with lazy database initialization.
427+type LazySessionRepository struct {
428+ provider port.DatabaseProvider
429+ repo repository.SessionRepository
430+ once sync.Once
431+ initErr error
432+}
433+
434+// NewLazySessionRepository creates a lazy-loading session repository.
435+func NewLazySessionRepository(provider port.DatabaseProvider) repository.SessionRepository {
436+ return &LazySessionRepository{provider: provider}
437+}
438+
439+func (r *LazySessionRepository) init(ctx context.Context) error {
440+ r.once.Do(func() {
441+ db, err := r.provider.DB(ctx)
442+ if err != nil {
443+ r.initErr = err
444+ return
445+ }
446+ r.repo = NewSessionRepository(db)
447+ })
448+ return r.initErr
449+}
450+
451+func (r *LazySessionRepository) Save(ctx context.Context, session *entity.Session) error {
452+ if err := r.init(ctx); err != nil {
453+ return err
454+ }
455+ return r.repo.Save(ctx, session)
456+}
457+
458+func (r *LazySessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
459+ if err := r.init(ctx); err != nil {
460+ return nil, err
461+ }
462+ return r.repo.FindByID(ctx, id)
463+}
464+
465+func (r *LazySessionRepository) GetActive(ctx context.Context) (*entity.Session, error) {
466+ if err := r.init(ctx); err != nil {
467+ return nil, err
468+ }
469+ return r.repo.GetActive(ctx)
470+}
471+
472+func (r *LazySessionRepository) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
473+ if err := r.init(ctx); err != nil {
474+ return nil, err
475+ }
476+ return r.repo.GetRecent(ctx, limit)
477+}
478+
479+func (r *LazySessionRepository) MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error {
480+ if err := r.init(ctx); err != nil {
481+ return err
482+ }
483+ return r.repo.MarkEnded(ctx, id, endedAt)
484+}
485+
486+func (r *LazySessionRepository) Delete(ctx context.Context, id entity.SessionID) error {
487+ if err := r.init(ctx); err != nil {
488+ return err
489+ }
490+ return r.repo.Delete(ctx, id)
491+}
492+
493+func (r *LazySessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) {
494+ if err := r.init(ctx); err != nil {
495+ return 0, err
496+ }
497+ return r.repo.DeleteOldestExited(ctx, keepCount)
498+}
499+
500+func (r *LazySessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) {
501+ if err := r.init(ctx); err != nil {
502+ return 0, err
503+ }
504+ return r.repo.DeleteExitedBefore(ctx, cutoff)
505+}
506+
507+// LazySessionStateRepository wraps a session state repository with lazy database initialization.
508+type LazySessionStateRepository struct {
509+ provider port.DatabaseProvider
510+ repo repository.SessionStateRepository
511+ once sync.Once
512+ initErr error
513+}
514+
515+// NewLazySessionStateRepository creates a lazy-loading session state repository.
516+func NewLazySessionStateRepository(provider port.DatabaseProvider) repository.SessionStateRepository {
517+ return &LazySessionStateRepository{provider: provider}
518+}
519+
520+func (r *LazySessionStateRepository) init(ctx context.Context) error {
521+ r.once.Do(func() {
522+ db, err := r.provider.DB(ctx)
523+ if err != nil {
524+ r.initErr = err
525+ return
526+ }
527+ r.repo = NewSessionStateRepository(db)
528+ })
529+ return r.initErr
530+}
531+
532+func (r *LazySessionStateRepository) SaveSnapshot(ctx context.Context, state *entity.SessionState) error {
533+ if err := r.init(ctx); err != nil {
534+ return err
535+ }
536+ return r.repo.SaveSnapshot(ctx, state)
537+}
538+
539+func (r *LazySessionStateRepository) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) {
540+ if err := r.init(ctx); err != nil {
541+ return nil, err
542+ }
543+ return r.repo.GetSnapshot(ctx, sessionID)
544+}
545+
546+func (r *LazySessionStateRepository) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
547+ if err := r.init(ctx); err != nil {
548+ return err
549+ }
550+ return r.repo.DeleteSnapshot(ctx, sessionID)
551+}
552+
553+func (r *LazySessionStateRepository) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) {
554+ if err := r.init(ctx); err != nil {
555+ return nil, err
556+ }
557+ return r.repo.GetAllSnapshots(ctx)
558+}
559+
560+func (r *LazySessionStateRepository) GetTotalSnapshotsSize(ctx context.Context) (int64, error) {
561+ if err := r.init(ctx); err != nil {
562+ return 0, err
563+ }
564+ return r.repo.GetTotalSnapshotsSize(ctx)
565+}
566+
567+// LazyContentWhitelistRepository wraps a content whitelist repository with lazy database initialization.
568+type LazyContentWhitelistRepository struct {
569+ provider port.DatabaseProvider
570+ repo repository.ContentWhitelistRepository
571+ once sync.Once
572+ initErr error
573+}
574+
575+// NewLazyContentWhitelistRepository creates a lazy-loading content whitelist repository.
576+func NewLazyContentWhitelistRepository(provider port.DatabaseProvider) repository.ContentWhitelistRepository {
577+ return &LazyContentWhitelistRepository{provider: provider}
578+}
579+
580+func (r *LazyContentWhitelistRepository) init(ctx context.Context) error {
581+ r.once.Do(func() {
582+ db, err := r.provider.DB(ctx)
583+ if err != nil {
584+ r.initErr = err
585+ return
586+ }
587+ r.repo = NewContentWhitelistRepository(db)
588+ })
589+ return r.initErr
590+}
591+
592+func (r *LazyContentWhitelistRepository) Add(ctx context.Context, domain string) error {
593+ if err := r.init(ctx); err != nil {
594+ return err
595+ }
596+ return r.repo.Add(ctx, domain)
597+}
598+
599+func (r *LazyContentWhitelistRepository) Remove(ctx context.Context, domain string) error {
600+ if err := r.init(ctx); err != nil {
601+ return err
602+ }
603+ return r.repo.Remove(ctx, domain)
604+}
605+
606+func (r *LazyContentWhitelistRepository) Contains(ctx context.Context, domain string) (bool, error) {
607+ if err := r.init(ctx); err != nil {
608+ return false, err
609+ }
610+ return r.repo.Contains(ctx, domain)
611+}
612+
613+func (r *LazyContentWhitelistRepository) GetAll(ctx context.Context) ([]string, error) {
614+ if err := r.init(ctx); err != nil {
615+ return nil, err
616+ }
617+ return r.repo.GetAll(ctx)
618+}
619+
620+// LazyRepositories holds all lazy-loaded repositories.
621+type LazyRepositories struct {
622+ History repository.HistoryRepository
623+ Favorite repository.FavoriteRepository
624+ Folder repository.FolderRepository
625+ Tag repository.TagRepository
626+ Zoom repository.ZoomRepository
627+ Session repository.SessionRepository
628+ SessionState repository.SessionStateRepository
629+ Filter repository.ContentWhitelistRepository
630+}
631+
632+// NewLazyRepositories creates all lazy repositories from a database provider.
633+func NewLazyRepositories(provider port.DatabaseProvider) *LazyRepositories {
634+ return &LazyRepositories{
635+ History: NewLazyHistoryRepository(provider),
636+ Favorite: NewLazyFavoriteRepository(provider),
637+ Folder: NewLazyFolderRepository(provider),
638+ Tag: NewLazyTagRepository(provider),
639+ Zoom: NewLazyZoomRepository(provider),
640+ Session: NewLazySessionRepository(provider),
641+ SessionState: NewLazySessionStateRepository(provider),
642+ Filter: NewLazyContentWhitelistRepository(provider),
643+ }
644+}
645+
646+// GetDB returns the underlying *sql.DB for operations that need it directly.
647+// Returns nil if the database hasn't been initialized yet.
648+func GetDB(provider port.DatabaseProvider, ctx context.Context) *sql.DB {
649+ db, err := provider.DB(ctx)
650+ if err != nil {
651+ return nil
652+ }
653+ return db
654+}

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