@@ -44,7 +44,7 @@ internal final class SQLFavoriteStorage {
4444
4545 deinit {
4646 if let db = db {
47- sqlite3_close ( db)
47+ sqlite3_close_v2 ( db)
4848 }
4949 if Self . isRunningTests, let dbPath = dbPath {
5050 try ? FileManager . default. removeItem ( atPath: dbPath)
@@ -119,14 +119,75 @@ internal final class SQLFavoriteStorage {
119119 private func migrateIfNeeded( ) {
120120 let currentVersion = getUserVersion ( )
121121
122- // Future migrations go here:
123- // if currentVersion < 2 {
124- // execute("ALTER TABLE favorites ADD COLUMN new_col TEXT;")
125- // }
122+ if currentVersion < 1 {
123+ // Fresh database — tables already created without is_synced, jump to latest version
124+ setUserVersion ( 2 )
125+ return
126+ }
126127
127- let targetVersion : Int32 = 1
128- if currentVersion < targetVersion {
129- setUserVersion ( targetVersion)
128+ if currentVersion < 2 {
129+ // Remove is_synced column using rename-recreate-copy pattern
130+ // (SQLite < 3.35.0 doesn't support ALTER TABLE DROP COLUMN)
131+ execute ( " ALTER TABLE favorites RENAME TO favorites_old " )
132+ execute ( """
133+ CREATE TABLE IF NOT EXISTS favorites (
134+ id TEXT PRIMARY KEY, name TEXT NOT NULL, query TEXT NOT NULL,
135+ keyword TEXT, folder_id TEXT, connection_id TEXT,
136+ sort_order INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, updated_at REAL NOT NULL
137+ )
138+ """ )
139+ execute ( """
140+ INSERT INTO favorites SELECT id, name, query, keyword, folder_id, connection_id,
141+ sort_order, created_at, updated_at FROM favorites_old
142+ """ )
143+ execute ( " DROP TABLE favorites_old " )
144+
145+ execute ( " ALTER TABLE folders RENAME TO folders_old " )
146+ execute ( """
147+ CREATE TABLE IF NOT EXISTS folders (
148+ id TEXT PRIMARY KEY, name TEXT NOT NULL, parent_id TEXT, connection_id TEXT,
149+ sort_order INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, updated_at REAL NOT NULL
150+ )
151+ """ )
152+ execute ( """
153+ INSERT INTO folders SELECT id, name, parent_id, connection_id,
154+ sort_order, created_at, updated_at FROM folders_old
155+ """ )
156+ execute ( " DROP TABLE folders_old " )
157+
158+ // Recreate indexes dropped with the old tables
159+ execute ( " CREATE INDEX IF NOT EXISTS idx_favorites_connection ON favorites(connection_id); " )
160+ execute ( " CREATE INDEX IF NOT EXISTS idx_favorites_folder ON favorites(folder_id); " )
161+ execute ( " CREATE INDEX IF NOT EXISTS idx_favorites_keyword ON favorites(keyword); " )
162+ execute ( " CREATE UNIQUE INDEX IF NOT EXISTS idx_favorites_keyword_scope ON favorites(keyword, connection_id) WHERE keyword IS NOT NULL; " )
163+ execute ( " CREATE INDEX IF NOT EXISTS idx_folders_connection ON folders(connection_id); " )
164+ execute ( " CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id); " )
165+
166+ // Recreate FTS5 triggers referencing the new table
167+ execute ( " DROP TRIGGER IF EXISTS favorites_ai; " )
168+ execute ( " DROP TRIGGER IF EXISTS favorites_ad; " )
169+ execute ( " DROP TRIGGER IF EXISTS favorites_au; " )
170+ execute ( """
171+ CREATE TRIGGER IF NOT EXISTS favorites_ai AFTER INSERT ON favorites BEGIN
172+ INSERT INTO favorites_fts(rowid, name, query, keyword) VALUES (new.rowid, new.name, new.query, new.keyword);
173+ END;
174+ """ )
175+ execute ( """
176+ CREATE TRIGGER IF NOT EXISTS favorites_ad AFTER DELETE ON favorites BEGIN
177+ INSERT INTO favorites_fts(favorites_fts, rowid, name, query, keyword) VALUES('delete', old.rowid, old.name, old.query, old.keyword);
178+ END;
179+ """ )
180+ execute ( """
181+ CREATE TRIGGER IF NOT EXISTS favorites_au AFTER UPDATE ON favorites BEGIN
182+ INSERT INTO favorites_fts(favorites_fts, rowid, name, query, keyword) VALUES('delete', old.rowid, old.name, old.query, old.keyword);
183+ INSERT INTO favorites_fts(rowid, name, query, keyword) VALUES (new.rowid, new.name, new.query, new.keyword);
184+ END;
185+ """ )
186+
187+ // Rebuild FTS5 index to match new table rowids
188+ execute ( " INSERT INTO favorites_fts(favorites_fts) VALUES('rebuild'); " )
189+
190+ setUserVersion ( 2 )
130191 }
131192 }
132193
@@ -158,8 +219,7 @@ internal final class SQLFavoriteStorage {
158219 connection_id TEXT,
159220 sort_order INTEGER NOT NULL DEFAULT 0,
160221 created_at REAL NOT NULL,
161- updated_at REAL NOT NULL,
162- is_synced INTEGER DEFAULT 0
222+ updated_at REAL NOT NULL
163223 );
164224 """
165225
@@ -171,8 +231,7 @@ internal final class SQLFavoriteStorage {
171231 connection_id TEXT,
172232 sort_order INTEGER NOT NULL DEFAULT 0,
173233 created_at REAL NOT NULL,
174- updated_at REAL NOT NULL,
175- is_synced INTEGER DEFAULT 0
234+ updated_at REAL NOT NULL
176235 );
177236 """
178237
@@ -225,8 +284,14 @@ internal final class SQLFavoriteStorage {
225284
226285 private func execute( _ sql: String ) {
227286 var statement : OpaquePointer ?
228- if sqlite3_prepare_v2 ( db, sql, - 1 , & statement, nil ) == SQLITE_OK {
229- sqlite3_step ( statement)
287+ let prepareResult = sqlite3_prepare_v2 ( db, sql, - 1 , & statement, nil )
288+ if prepareResult == SQLITE_OK {
289+ let stepResult = sqlite3_step ( statement)
290+ if stepResult != SQLITE_DONE && stepResult != SQLITE_ROW {
291+ Self . logger. error ( " sqlite3_step failed ( \( stepResult) ): \( String ( cString: sqlite3_errmsg ( self . db) ) ) " )
292+ }
293+ } else {
294+ Self . logger. error ( " sqlite3_prepare_v2 failed ( \( prepareResult) ): \( String ( cString: sqlite3_errmsg ( self . db) ) ) " )
230295 }
231296 sqlite3_finalize ( statement)
232297 }
@@ -371,6 +436,58 @@ internal final class SQLFavoriteStorage {
371436 }
372437 }
373438
439+ func deleteFavorites( ids: [ UUID ] ) async -> Bool {
440+ guard !ids. isEmpty else { return true }
441+ let idStrings = ids. map { $0. uuidString }
442+ return await performDatabaseWork { [ weak self] in
443+ guard let self = self else { return false }
444+
445+ let placeholders = ids. map { _ in " ? " } . joined ( separator: " , " )
446+ let sql = " DELETE FROM favorites WHERE id IN ( \( placeholders) ); "
447+
448+ var statement : OpaquePointer ?
449+ guard sqlite3_prepare_v2 ( self . db, sql, - 1 , & statement, nil ) == SQLITE_OK else {
450+ return false
451+ }
452+
453+ defer { sqlite3_finalize ( statement) }
454+
455+ let SQLITE_TRANSIENT = unsafeBitCast ( - 1 , to: sqlite3_destructor_type. self)
456+ for (index, idString) in idStrings. enumerated ( ) {
457+ sqlite3_bind_text ( statement, Int32 ( index + 1 ) , idString, - 1 , SQLITE_TRANSIENT)
458+ }
459+
460+ let result = sqlite3_step ( statement)
461+ if result != SQLITE_DONE {
462+ Self . logger. error ( " Failed to batch delete favorites: \( String ( cString: sqlite3_errmsg ( self . db) ) ) " )
463+ }
464+ return result == SQLITE_DONE
465+ }
466+ }
467+
468+ func fetchFavorite( id: UUID ) async -> SQLFavorite ? {
469+ let idString = id. uuidString
470+ return await performDatabaseWork { [ weak self] in
471+ guard let self = self else { return nil }
472+
473+ let sql = " SELECT id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at FROM favorites WHERE id = ? LIMIT 1; "
474+ var statement : OpaquePointer ?
475+ guard sqlite3_prepare_v2 ( self . db, sql, - 1 , & statement, nil ) == SQLITE_OK else {
476+ return nil
477+ }
478+
479+ defer { sqlite3_finalize ( statement) }
480+
481+ let SQLITE_TRANSIENT = unsafeBitCast ( - 1 , to: sqlite3_destructor_type. self)
482+ sqlite3_bind_text ( statement, 1 , idString, - 1 , SQLITE_TRANSIENT)
483+
484+ if sqlite3_step ( statement) == SQLITE_ROW {
485+ return self . parseFavorite ( from: statement)
486+ }
487+ return nil
488+ }
489+ }
490+
374491 func fetchFavorites(
375492 connectionId: UUID ? = nil ,
376493 folderId: UUID ? = nil ,
0 commit comments