diff --git a/Music/Models/SmartPlaylist.swift b/Music/Models/SmartPlaylist.swift index 62f54e6..c507f40 100644 --- a/Music/Models/SmartPlaylist.swift +++ b/Music/Models/SmartPlaylist.swift @@ -6,11 +6,27 @@ nonisolated struct SmartPlaylist: Codable, Identifiable, Equatable, Hashable, Se var name: String var searchQuery: String var createdAt: Date + var conditions: [SmartPlaylistCondition]? } nonisolated extension SmartPlaylist: FetchableRecord, MutablePersistableRecord { static let databaseTableName = "smart_playlists" + // Explicit persistence container so GRDB only writes columns that exist in + // the current schema. The `conditions` column is added by migration v5 + // (Task 3); until then this override keeps inserts/updates compatible. + // Once migration v5 lands, delete this method and let GRDB's Codable + // synthesis handle both directions consistently. + func encode(to container: inout PersistenceContainer) { + container["id"] = id + container["name"] = name + container["searchQuery"] = searchQuery + container["createdAt"] = createdAt + // `conditions` intentionally omitted: the column doesn't exist yet. + // Migration v5 adds it; removing this override at that point lets + // GRDB's Codable synthesis encode/decode the JSON column automatically. + } + mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID } @@ -26,9 +42,13 @@ extension SmartPlaylist { id: Int64? = nil, name: String = "Test Smart Playlist", searchQuery: String = "test query", - createdAt: Date = Date() + createdAt: Date = Date(), + conditions: [SmartPlaylistCondition]? = nil ) -> SmartPlaylist { - SmartPlaylist(id: id, name: name, searchQuery: searchQuery, createdAt: createdAt) + SmartPlaylist( + id: id, name: name, searchQuery: searchQuery, + createdAt: createdAt, conditions: conditions + ) } } #endif diff --git a/Music/Models/SmartPlaylistCondition.swift b/Music/Models/SmartPlaylistCondition.swift index 215b1f0..a5626be 100644 --- a/Music/Models/SmartPlaylistCondition.swift +++ b/Music/Models/SmartPlaylistCondition.swift @@ -90,7 +90,7 @@ enum ConditionOperator: String, Codable, Identifiable, Sendable { // Tagged union storing the actual filter value with its type. // Uses custom Codable to survive JSON round-trips cleanly. -enum ConditionValue: Equatable, Sendable { +enum ConditionValue: Equatable, Hashable, Sendable { case string(String) case int(Int) case double(Double) @@ -143,7 +143,7 @@ extension ConditionValue: Codable { } } -nonisolated struct SmartPlaylistCondition: Codable, Equatable, Sendable { +nonisolated struct SmartPlaylistCondition: Codable, Equatable, Hashable, Sendable { var field: TrackField var op: ConditionOperator var value: ConditionValue diff --git a/Music/Services/DatabaseService.swift b/Music/Services/DatabaseService.swift index df26eb4..7a18344 100644 --- a/Music/Services/DatabaseService.swift +++ b/Music/Services/DatabaseService.swift @@ -121,11 +121,24 @@ nonisolated final class DatabaseService: Sendable { // MARK: - Maintenance - /// Create a self-contained copy of the database at the given path using - /// SQLite's online backup API. The copy includes all WAL data and is safe - /// to serve or transfer without additional files. + /// Create a self-contained copy of the database at the given path. + /// + /// Uses SQLite's `VACUUM INTO` rather than the online backup API. The online + /// backup (`dbPool.backup(to:)`) produced copies whose FTS5 `tracks_ft` shadow + /// tables were not transferred, so any `ValueObservation` opened on the copy + /// failed with "no such table: tracks_ft" — which left the remote client's + /// track list empty even though the row data copied fine. `VACUUM INTO` writes + /// a fresh, fully-rebuilt, self-contained database that includes a functional + /// FTS5 index and all committed data, with no WAL/SHM side files. + /// + /// `VACUUM INTO` requires the destination file to not already exist, so any + /// stale file at `destinationPath` is removed first. func backup(to destinationPath: String) throws { - try dbPool.backup(to: DatabaseQueue(path: destinationPath)) + try? FileManager.default.removeItem(atPath: destinationPath) + let escaped = destinationPath.replacingOccurrences(of: "'", with: "''") + try dbPool.writeWithoutTransaction { db in + try db.execute(sql: "VACUUM INTO '\(escaped)'") + } } // MARK: - Write @@ -447,7 +460,7 @@ nonisolated final class DatabaseService: Sendable { func createSmartPlaylist(name: String, searchQuery: String) throws -> SmartPlaylist { try dbPool.write { db in var smartPlaylist = SmartPlaylist( - id: nil, name: name, searchQuery: searchQuery, createdAt: Date() + id: nil, name: name, searchQuery: searchQuery, createdAt: Date(), conditions: nil ) try smartPlaylist.insert(db) return smartPlaylist diff --git a/MusicTests/SmartPlaylistTests.swift b/MusicTests/SmartPlaylistTests.swift index f89f135..5d33112 100644 --- a/MusicTests/SmartPlaylistTests.swift +++ b/MusicTests/SmartPlaylistTests.swift @@ -9,7 +9,8 @@ struct SmartPlaylistTests { id: nil, name: "Miles Davis", searchQuery: "miles davis", - createdAt: Date() + createdAt: Date(), + conditions: nil ) #expect(sp.name == "Miles Davis") #expect(sp.searchQuery == "miles davis") @@ -77,6 +78,16 @@ struct SmartPlaylistTests { #expect(results[1].title == "Kind of Blue") } + // Creates a SmartPlaylist fixture with conditions and verifies the conditions + // field is preserved and the isSmartPlaylist flag is true. + @Test func smartPlaylistWithConditions() throws { + let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))] + let sp = SmartPlaylist.fixture(conditions: conditions) + #expect(sp.conditions?.count == 1) + #expect(sp.conditions?[0].field == .artist) + #expect(sp.isSmartPlaylist == true) + } + // Encodes and decodes a SmartPlaylistCondition to/from JSON, // verifying that all fields survive the round-trip. @Test func conditionCodableRoundTrip() throws {