From 173471163096cae57b2c82d00499184ca23877ff Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 20 Oct 2025 20:58:22 -0700 Subject: [PATCH 1/6] Support `attachMetadatabase` in previews Previews can currently crash if an app database is provisioned that attaches a metadatabase. Despite trying to ensure all databases are in-memory to work around an Xcode previews quirk, SQLite still throws a file system error when connecting to an in-memory database with a file URL. This PR attempts to work around the issue by sharing the same database connection in previews. This does require losing the internal `DatabaseMigrator` we currently use for the metadatabase and instead depend on `IF NOT EXISTS` because GRDB only supports a single set of migrations per database (we could maybe scope this change just to previews), but this is probably OK. It also means that the migrator will always detect schema changes for previews since the metadatabase tables will conflict, but this is also probably OK since previews are not long-living. --- .../CloudKit/Internal/Metadatabase.swift | 38 +++++++------------ Sources/SQLiteData/CloudKit/SyncEngine.swift | 5 ++- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift index 8ea33c42..6223c596 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -4,6 +4,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func defaultMetadatabase( + database: any DatabaseWriter, logger: Logger, url: URL ) throws -> any DatabaseWriter { @@ -26,7 +27,9 @@ } let metadatabase: any DatabaseWriter = - if url.isInMemory { + if context == .preview { + database + } else if url.isInMemory { try DatabaseQueue(path: url.absoluteString) } else { try DatabasePool(path: url.path(percentEncoded: false)) @@ -36,11 +39,10 @@ } func migrate(metadatabase: some DatabaseWriter) throws { - var migrator = DatabaseMigrator() - migrator.registerMigration("Create Metadata Tables") { db in + try metadatabase.write { db in try #sql( """ - CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( "recordPrimaryKey" TEXT NOT NULL, "recordType" TEXT NOT NULL, "recordName" TEXT NOT NULL AS ("recordPrimaryKey" || ':' || "recordType"), @@ -65,21 +67,21 @@ .execute(db) try #sql( """ - CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_zoneID" + CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_zoneID" ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("ownerName", "zoneName") """ ) .execute(db) try #sql( """ - CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName" + CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName" ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("parentRecordName") """ ) .execute(db) try #sql( """ - CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared" + CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared" ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("isShared") """ ) @@ -93,7 +95,7 @@ .execute(db) try #sql( """ - CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( + CREATE TABLE IF NOT EXISTS"\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( "tableName" TEXT NOT NULL PRIMARY KEY, "schema" TEXT NOT NULL, "tableInfo" TEXT NOT NULL @@ -103,7 +105,7 @@ .execute(db) try #sql( """ - CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( "scope" TEXT NOT NULL PRIMARY KEY, "data" TEXT NOT NULL ) STRICT @@ -112,7 +114,7 @@ .execute(db) try #sql( """ - CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_unsyncedRecordIDs" ( + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_unsyncedRecordIDs" ( "recordName" TEXT NOT NULL, "zoneName" TEXT NOT NULL, "ownerName" TEXT NOT NULL, @@ -123,25 +125,13 @@ .execute(db) try #sql( """ - CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( + CREATE TABLE IF NOT EXISTS \ + "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( "pendingRecordZoneChange" BLOB NOT NULL ) STRICT """ ) .execute(db) } - #if DEBUG - try metadatabase.read { db in - let hasSchemaChanges = try migrator.hasSchemaChanges(db) - assert( - !hasSchemaChanges, - """ - A previously run migration has been removed or edited. \ - Metadatabase migrations must not be modified after release. - """ - ) - } - #endif - try migrator.migrate(metadatabase) } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 35dda656..de90aded 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -252,6 +252,7 @@ self.userDatabase = userDatabase self.logger = logger self.metadatabase = try defaultMetadatabase( + database: userDatabase.database, logger: logger, url: try URL.metadatabase( databasePath: userDatabase.path, @@ -1917,7 +1918,7 @@ databasePath: String, containerIdentifier: String? ) throws -> URL { - guard let databaseURL = URL(string: databasePath) + guard let databaseURL = URL(string: databasePath.isEmpty ? ":memory:" : databasePath) else { struct InvalidDatabasePath: Error {} throw InvalidDatabasePath() @@ -2000,6 +2001,8 @@ /// - Parameter containerIdentifier: The identifier of the CloudKit container used to /// synchronize data. Defaults to the value set in the app's entitlements. public func attachMetadatabase(containerIdentifier: String? = nil) throws { + @Dependency(\.context) var context + guard context != .preview else { return } let containerIdentifier = containerIdentifier ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier From d5efce128a5da6d20f559946b59c32dae25108e0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 21 Oct 2025 16:04:48 -0500 Subject: [PATCH 2/6] Revert "Support `attachMetadatabase` in previews" This reverts commit 173471163096cae57b2c82d00499184ca23877ff. --- .../CloudKit/Internal/Metadatabase.swift | 38 ++++++++++++------- Sources/SQLiteData/CloudKit/SyncEngine.swift | 5 +-- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift index 6223c596..8ea33c42 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -4,7 +4,6 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func defaultMetadatabase( - database: any DatabaseWriter, logger: Logger, url: URL ) throws -> any DatabaseWriter { @@ -27,9 +26,7 @@ } let metadatabase: any DatabaseWriter = - if context == .preview { - database - } else if url.isInMemory { + if url.isInMemory { try DatabaseQueue(path: url.absoluteString) } else { try DatabasePool(path: url.path(percentEncoded: false)) @@ -39,10 +36,11 @@ } func migrate(metadatabase: some DatabaseWriter) throws { - try metadatabase.write { db in + var migrator = DatabaseMigrator() + migrator.registerMigration("Create Metadata Tables") { db in try #sql( """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( "recordPrimaryKey" TEXT NOT NULL, "recordType" TEXT NOT NULL, "recordName" TEXT NOT NULL AS ("recordPrimaryKey" || ':' || "recordType"), @@ -67,21 +65,21 @@ .execute(db) try #sql( """ - CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_zoneID" + CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_zoneID" ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("ownerName", "zoneName") """ ) .execute(db) try #sql( """ - CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName" + CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName" ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("parentRecordName") """ ) .execute(db) try #sql( """ - CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared" + CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared" ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("isShared") """ ) @@ -95,7 +93,7 @@ .execute(db) try #sql( """ - CREATE TABLE IF NOT EXISTS"\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( "tableName" TEXT NOT NULL PRIMARY KEY, "schema" TEXT NOT NULL, "tableInfo" TEXT NOT NULL @@ -105,7 +103,7 @@ .execute(db) try #sql( """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( "scope" TEXT NOT NULL PRIMARY KEY, "data" TEXT NOT NULL ) STRICT @@ -114,7 +112,7 @@ .execute(db) try #sql( """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_unsyncedRecordIDs" ( + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_unsyncedRecordIDs" ( "recordName" TEXT NOT NULL, "zoneName" TEXT NOT NULL, "ownerName" TEXT NOT NULL, @@ -125,13 +123,25 @@ .execute(db) try #sql( """ - CREATE TABLE IF NOT EXISTS \ - "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( "pendingRecordZoneChange" BLOB NOT NULL ) STRICT """ ) .execute(db) } + #if DEBUG + try metadatabase.read { db in + let hasSchemaChanges = try migrator.hasSchemaChanges(db) + assert( + !hasSchemaChanges, + """ + A previously run migration has been removed or edited. \ + Metadatabase migrations must not be modified after release. + """ + ) + } + #endif + try migrator.migrate(metadatabase) } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index de90aded..35dda656 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -252,7 +252,6 @@ self.userDatabase = userDatabase self.logger = logger self.metadatabase = try defaultMetadatabase( - database: userDatabase.database, logger: logger, url: try URL.metadatabase( databasePath: userDatabase.path, @@ -1918,7 +1917,7 @@ databasePath: String, containerIdentifier: String? ) throws -> URL { - guard let databaseURL = URL(string: databasePath.isEmpty ? ":memory:" : databasePath) + guard let databaseURL = URL(string: databasePath) else { struct InvalidDatabasePath: Error {} throw InvalidDatabasePath() @@ -2001,8 +2000,6 @@ /// - Parameter containerIdentifier: The identifier of the CloudKit container used to /// synchronize data. Defaults to the value set in the app's entitlements. public func attachMetadatabase(containerIdentifier: String? = nil) throws { - @Dependency(\.context) var context - guard context != .preview else { return } let containerIdentifier = containerIdentifier ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier From 45a24e2835b021cd644228c09997352eae33fdf6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 21 Oct 2025 16:03:49 -0500 Subject: [PATCH 3/6] wip --- .../xcshareddata/swiftpm/Package.resolved | 6 ++--- Sources/SQLiteData/CloudKit/SyncEngine.swift | 22 ++++++++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6d103b31..587d0c8d 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "72bc7483118f950b5981c86ad1ea986d789ceef2694a317cea1b9dfff3119f82", + "originHash" : "41e7781e6c506773b6af84af513bcd6d3b1be59d635e6c4c4bd89638368e4629", "pins" : [ { "identity" : "combine-schedulers", @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "edb84b339542b018477bab1d8e4cca851d5fa93c", - "version" : "0.23.0" + "revision" : "3a95b70a81b7027b8a5117e7dd08188837e5f54e", + "version" : "0.24.0" } }, { diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 35dda656..d59235e0 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -311,9 +311,15 @@ .select(\.file) .fetchOne(db) if let attachedMetadatabasePath { - let attachedMetadatabaseName = URL(filePath: metadatabase.path).lastPathComponent - let metadatabaseName = URL(filePath: attachedMetadatabasePath).lastPathComponent - if attachedMetadatabaseName != metadatabaseName { + let metadatabaseName = URL(string: metadatabase.path)?.lastPathComponent ?? "" + + let attachedMetadatabaseURL = try URL.metadatabase( + databasePath: attachedMetadatabasePath, + containerIdentifier: self.container.containerIdentifier + ) + let attachedMetadatabaseName = attachedMetadatabaseURL.lastPathComponent + + if metadatabaseName != attachedMetadatabaseName { throw SchemaError( reason: .metadatabaseMismatch( attachedPath: attachedMetadatabasePath, @@ -1917,6 +1923,7 @@ databasePath: String, containerIdentifier: String? ) throws -> URL { + let databasePath = databasePath.isEmpty ? ":memory:" : databasePath guard let databaseURL = URL(string: databasePath) else { struct InvalidDatabasePath: Error {} @@ -2022,12 +2029,16 @@ databasePath: databasePath, containerIdentifier: containerIdentifier ) - let path = url.path(percentEncoded: false) + let path = url.isInMemory ? url.absoluteString : url.path(percentEncoded: false) try FileManager.default.createDirectory( at: .applicationSupportDirectory, withIntermediateDirectories: true ) - _ = try DatabasePool(path: path).write { db in + let database: any DatabaseWriter = + url.isInMemory + ? try DatabaseQueue(path: path) + : try DatabasePool(path: path) + _ = try database.write { db in try #sql("SELECT 1").execute(db) } try #sql( @@ -2044,7 +2055,6 @@ package struct SchemaError: LocalizedError { package enum Reason { case cycleDetected - case inMemoryDatabase case invalidForeignKey(ForeignKey) case invalidForeignKeyAction(ForeignKey) case invalidTableName(String) From 852be702fd3b647ceb86b062a6fb087dcaecc86f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 21 Oct 2025 16:06:10 -0500 Subject: [PATCH 4/6] wip --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index d59235e0..b6749f84 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -312,13 +312,11 @@ .fetchOne(db) if let attachedMetadatabasePath { let metadatabaseName = URL(string: metadatabase.path)?.lastPathComponent ?? "" - - let attachedMetadatabaseURL = try URL.metadatabase( + let attachedMetadatabaseName = try URL.metadatabase( databasePath: attachedMetadatabasePath, containerIdentifier: self.container.containerIdentifier ) - let attachedMetadatabaseName = attachedMetadatabaseURL.lastPathComponent - + .lastPathComponent if metadatabaseName != attachedMetadatabaseName { throw SchemaError( reason: .metadatabaseMismatch( From a1ae645ffd49165cedbbfe2f5fd47bf6aafd43fe Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 21 Oct 2025 16:54:10 -0500 Subject: [PATCH 5/6] wip --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index b6749f84..4a37d494 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -311,8 +311,19 @@ .select(\.file) .fetchOne(db) if let attachedMetadatabasePath { - let metadatabaseName = URL(string: metadatabase.path)?.lastPathComponent ?? "" - let attachedMetadatabaseName = try URL.metadatabase( + let metadatabaseName = metadatabase.path.isEmpty ? + try URL.metadatabase( + databasePath: "", + containerIdentifier: self.container.containerIdentifier + ) + .lastPathComponent + : + URL( + filePath: metadatabase.path + ).lastPathComponent + let attachedMetadatabaseName = URL(string: attachedMetadatabasePath)?.lastPathComponent ?? "" + + try URL.metadatabase( databasePath: attachedMetadatabasePath, containerIdentifier: self.container.containerIdentifier ) @@ -1932,9 +1943,7 @@ return URL(string: "file:\(String.sqliteDataCloudKitSchemaName)?mode=memory&cache=shared")! } return - databaseURL - .deletingLastPathComponent() - .appending(component: ".\(databaseURL.deletingPathExtension().lastPathComponent)") + databaseURL.deletingLastPathComponent().appending(component: ".\(databaseURL.deletingPathExtension().lastPathComponent)") .appendingPathExtension("metadata\(containerIdentifier.map { "-\($0)" } ?? "").sqlite") } From cbad9eae94e06a7fc04c8d6f2d245412ff92e659 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 21 Oct 2025 16:55:20 -0500 Subject: [PATCH 6/6] wip --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 25 ++++++++++---------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 4a37d494..438e715d 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -311,17 +311,16 @@ .select(\.file) .fetchOne(db) if let attachedMetadatabasePath { - let metadatabaseName = metadatabase.path.isEmpty ? - try URL.metadatabase( - databasePath: "", - containerIdentifier: self.container.containerIdentifier - ) - .lastPathComponent - : - URL( - filePath: metadatabase.path - ).lastPathComponent - let attachedMetadatabaseName = URL(string: attachedMetadatabasePath)?.lastPathComponent ?? "" + let metadatabaseName = + metadatabase.path.isEmpty + ? try URL.metadatabase( + databasePath: "", + containerIdentifier: self.container.containerIdentifier + ) + .lastPathComponent + : URL(filePath: metadatabase.path).lastPathComponent + let attachedMetadatabaseName = + URL(string: attachedMetadatabasePath)?.lastPathComponent ?? "" try URL.metadatabase( databasePath: attachedMetadatabasePath, @@ -1943,7 +1942,9 @@ return URL(string: "file:\(String.sqliteDataCloudKitSchemaName)?mode=memory&cache=shared")! } return - databaseURL.deletingLastPathComponent().appending(component: ".\(databaseURL.deletingPathExtension().lastPathComponent)") + databaseURL.deletingLastPathComponent().appending( + component: ".\(databaseURL.deletingPathExtension().lastPathComponent)" + ) .appendingPathExtension("metadata\(containerIdentifier.map { "-\($0)" } ?? "").sqlite") }