66 "embed"
77 "errors"
88 "fmt"
9+ "io/fs"
910 "os"
1011 "path/filepath"
1112 "time"
@@ -41,7 +42,9 @@ type dao struct {
4142var migrations embed.FS
4243
4344type options struct {
44- dbFile string
45+ dbFile string
46+ migrationsFS fs.FS
47+ migrationsPath string
4548}
4649
4750type Option func (o * options ) error
@@ -53,6 +56,14 @@ func WithDatabaseFile(dbFile string) Option {
5356 }
5457}
5558
59+ func WithMigrations (filesystem fs.FS , path string ) Option {
60+ return func (o * options ) error {
61+ o .migrationsFS = filesystem
62+ o .migrationsPath = path
63+ return nil
64+ }
65+ }
66+
5667func New (opts ... Option ) (DAO , error ) {
5768 var o options
5869 for _ , opt := range opts {
@@ -80,19 +91,70 @@ func New(opts ...Option) (DAO, error) {
8091 db .SetMaxIdleConns (1 )
8192 db .SetConnMaxLifetime (0 )
8293
83- migDriver , err := iofs .New (migrations , "migrations" )
94+ migrationsFS := o .migrationsFS
95+ if migrationsFS == nil {
96+ migrationsFS = & migrations
97+ }
98+
99+ migrationsPath := o .migrationsPath
100+ if migrationsPath == "" {
101+ migrationsPath = "migrations"
102+ }
103+
104+ err = runMigrations (o .dbFile , db , migrationsFS , migrationsPath )
84105 if err != nil {
85106 return nil , err
86107 }
87108
109+ sqlxDb := sqlx .NewDb (db , "sqlite" )
110+
111+ return & dao {db : sqlxDb }, nil
112+ }
113+
114+ func (d * dao ) Close () error {
115+ return d .db .Close ()
116+ }
117+
118+ func DefaultDatabaseFilename () (string , error ) {
119+ homeDir , err := user .HomeDir ()
120+ if err != nil {
121+ return "" , err
122+ }
123+ return filepath .Join (homeDir , ".docker" , "mcp" , "mcp-toolkit.db" ), nil
124+ }
125+
126+ func ensureDirectoryExists (path string ) {
127+ dir := filepath .Dir (path )
128+ if _ , err := os .Stat (dir ); os .IsNotExist (err ) {
129+ _ = os .MkdirAll (dir , 0o755 )
130+ }
131+ }
132+
133+ func txClose (tx * sqlx.Tx , err * error ) {
134+ if err == nil || * err == nil {
135+ return
136+ }
137+
138+ if txerr := tx .Rollback (); txerr != nil {
139+ log .Logf ("failed to rollback transaction: %v" , txerr )
140+ }
141+ }
142+
143+ func runMigrations (dbFile string , db * sql.DB , migrationsFS fs.FS , migrationsPath string ) error {
144+ migDriver , err := iofs .New (migrationsFS , migrationsPath )
145+ if err != nil {
146+ return err
147+ }
148+ defer migDriver .Close ()
149+
88150 driver , err := msqlite .WithInstance (db , & msqlite.Config {})
89151 if err != nil {
90- return nil , err
152+ return err
91153 }
92154
93155 mig , err := migrate .NewWithInstance ("iofs" , migDriver , "sqlite" , driver )
94156 if err != nil {
95- return nil , err
157+ return err
96158 }
97159
98160 // Use file locking to prevent concurrent migrations across processes
@@ -102,7 +164,7 @@ func New(opts ...Option) (DAO, error) {
102164 //
103165 // Note: The lock file persists on disk after Unlock() - this is intentional.
104166 // flock.Unlock() only releases the lock and closes the file descriptor
105- lockFile := filepath .Join (filepath .Dir (o . dbFile ), ".mcp-toolkit-migration.lock" )
167+ lockFile := filepath .Join (filepath .Dir (dbFile ), ".mcp-toolkit-migration.lock" )
106168 fileLock := flock .New (lockFile )
107169
108170 // Try to acquire the lock with a 5 second timeout
@@ -111,53 +173,51 @@ func New(opts ...Option) (DAO, error) {
111173
112174 locked , err := fileLock .TryLockContext (ctx , 100 * time .Millisecond )
113175 if err != nil {
114- return nil , fmt .Errorf ("failed to acquire migration lock: %w" , err )
176+ return fmt .Errorf ("failed to acquire migration lock: %w" , err )
115177 }
116178 if ! locked {
117- return nil , fmt .Errorf ("timeout waiting for migration lock" )
179+ return fmt .Errorf ("timeout waiting for migration lock" )
118180 }
119181 defer func () {
120182 if err := fileLock .Unlock (); err != nil {
121183 log .Logf ("failed to unlock migration lock: %v" , err )
122184 }
123185 }()
124186
125- // Now safely run migrations with the lock held
126- err = mig .Up ()
127- if err != nil && ! errors .Is (err , migrate .ErrNoChange ) {
128- return nil , fmt .Errorf ("failed to run migrations: %w" , err )
129- }
187+ // Now that we have the lock, check the current migration state
188+ version , dirty , err := mig .Version ()
130189
131- sqlxDb := sqlx .NewDb (db , "sqlite" )
190+ // If ErrNilVersion, the database is fresh (no migrations run yet)
191+ // In this case, proceed with running migrations
192+ isFreshDatabase := errors .Is (err , migrate .ErrNilVersion )
132193
133- return & dao {db : sqlxDb }, nil
134- }
135-
136- func (d * dao ) Close () error {
137- return d .db .Close ()
138- }
139-
140- func DefaultDatabaseFilename () (string , error ) {
141- homeDir , err := user .HomeDir ()
142- if err != nil {
143- return "" , err
194+ if err != nil && ! isFreshDatabase {
195+ return fmt .Errorf ("failed to get migration version: %w" , err )
144196 }
145- return filepath .Join (homeDir , ".docker" , "mcp" , "mcp-toolkit.db" ), nil
146- }
147197
148- func ensureDirectoryExists (path string ) {
149- dir := filepath .Dir (path )
150- if _ , err := os .Stat (dir ); os .IsNotExist (err ) {
151- _ = os .MkdirAll (dir , 0o755 )
198+ // Check if database is in dirty state (migration was interrupted)
199+ if dirty {
200+ return fmt .Errorf ("database is in dirty state at version %d, manual intervention required" , version )
152201 }
153- }
154202
155- func txClose (tx * sqlx.Tx , err * error ) {
156- if err == nil || * err == nil {
157- return
203+ // For fresh databases, always run migrations
204+ if ! isFreshDatabase {
205+ // Check if database version is ahead of available migrations
206+ // This happens when running older code against a database that was upgraded by newer code
207+ _ , _ , err = migDriver .ReadUp (version )
208+ if errors .Is (err , os .ErrNotExist ) {
209+ return fmt .Errorf ("database version %d (%s) is ahead of the current application version. Please upgrade to the latest version" , version , dbFile )
210+ }
211+ if err != nil {
212+ return fmt .Errorf ("failed to read migration file for version %d: %w" , version , err )
213+ }
158214 }
159215
160- if txerr := tx .Rollback (); txerr != nil {
161- log .Logf ("failed to rollback transaction: %v" , txerr )
216+ // Now safely run migrations with the lock held
217+ err = mig .Up ()
218+ if err != nil && ! errors .Is (err , migrate .ErrNoChange ) {
219+ return fmt .Errorf ("failed to run migrations: %w" , err )
162220 }
221+
222+ return nil
163223}
0 commit comments