-
+
```swift
@FetchAll
var items: [Item]
@Table
struct Item {
- let id: Int
+ let id: UUID
var title = ""
var isInStock = true
var notes = ""
@@ -93,22 +90,22 @@ class Item {
Both of the above examples fetch items from an external data store using Swift data types, and both
are automatically observed by SwiftUI so that views are recomputed when the external data changes,
-but SharingGRDB is powered directly by SQLite using [Sharing][], [StructuredQueries][], and
-[GRDB][], and is usable from UIKit, `@Observable` models, and more.
+but SQLiteData is powered directly by SQLite and is usable from UIKit, `@Observable` models, and
+more.
-For more information on SharingGRDB's querying capabilities, see
+For more information on SQLiteData's querying capabilities, see
[Fetching model data][fetching-article].
## Quick start
-Before SharingGRDB's property wrappers can fetch data from SQLite, you need to provide–at
+Before SQLiteData's property wrappers can fetch data from SQLite, you need to provide–at
runtime–the default database it should use. This is typically done as early as possible in your
app's lifetime, like the app entry point in SwiftUI, and is analogous to configuring model storage
in SwiftData:
-| SharingGRDB |
+SQLiteData |
SwiftData |
@@ -120,7 +117,7 @@ struct MyApp: App {
init() {
prepareDependencies {
let db = try! DatabaseQueue(
- // Create/migrate a database
+ // Create/migrate a database
// connection
)
$0.defaultDatabase = db
@@ -136,11 +133,11 @@ struct MyApp: App {
```swift
@main
struct MyApp: App {
- let container = {
+ let container = {
// Create/configure a container
try! ModelContainer(/* ... */)
}()
-
+
var body: some Scene {
WindowGroup {
ContentView()
@@ -158,13 +155,13 @@ struct MyApp: App {
> For more information on preparing a SQLite database, see
> [Preparing a SQLite database][preparing-db-article].
-This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies, like
+This `defaultDatabase` connection is used implicitly by SQLiteData's strategies, like
[`@FetchAll`][fetchall-docs] and [`@FetchOne`][fetchone-docs], which are similar to SwiftData's
`@Query` macro, but more powerful:
-| SharingGRDB |
+SQLiteData |
SwiftData |
@@ -182,8 +179,11 @@ var items
+@FetchAll(Item.order(by: \.isInStock))
+var items
+
@FetchOne(Item.count())
-var inStockItemsCount = 0
+var itemsCount = 0
```
@@ -202,6 +202,9 @@ var items: [Item]
})
var items: [Item]
+// No @Query equivalent of ordering
+// by boolean column.
+
// No @Query equivalent of counting
// entries in database without loading
// all entries.
@@ -216,16 +219,16 @@ a model context, via a property wrapper:
-| SharingGRDB |
+SQLiteData |
SwiftData |
|
```swift
-@Dependency(\.defaultDatabase)
+@Dependency(\.defaultDatabase)
var database
-
+
let newItem = Item(/* ... */)
try database.write { db in
try Item.insert { newItem }
@@ -237,9 +240,9 @@ try database.write { db in
|
```swift
-@Environment(\.modelContext)
+@Environment(\.modelContext)
var modelContext
-
+
let newItem = Item(/* ... */)
modelContext.insert(newItem)
try modelContext.save()
@@ -251,30 +254,55 @@ try modelContext.save()
|
> [!NOTE]
-> For more information on how SharingGRDB compares to SwiftData, see
+> For more information on how SQLiteData compares to SwiftData, see
> [Comparison with SwiftData][comparison-swiftdata-article].
-This is all you need to know to get started with SharingGRDB, but there's much more to learn. Read
+Further, if you want to synchronize the local database to CloudKit so that it is available on
+all your user's devices, simply configure a `SyncEngine` in the entry point of the app:
+
+```swift
+@main
+struct MyApp: App {
+ init() {
+ prepareDependencies {
+ $0.defaultDatabase = try! appDatabase()
+ $0.defaultSyncEngine = SyncEngine(
+ for: $0.defaultDatabase,
+ tables: Item.self
+ )
+ }
+ }
+ // ...
+}
+```
+
+> [!NOTE]
+> For more information on synchronizing the database to CloudKit and sharing records with iCloud
+> users, see [CloudKit Synchronization].
+
+This is all you need to know to get started with SQLiteData, but there's much more to learn. Read
the [articles][articles] below to learn how to best utilize this library:
* [Fetching model data][fetching-article]
* [Observing changes to model data][observing-article]
* [Preparing a SQLite database][preparing-db-article]
* [Dynamic queries][dynamic-queries-article]
+ * [CloudKit Synchronization]
* [Comparison with SwiftData][comparison-swiftdata-article]
-[observing-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/observing
-[dynamic-queries-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/dynamicqueries
-[articles]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore#Essentials
-[comparison-swiftdata-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/comparisonwithswiftdata
-[fetching-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/fetching
-[preparing-db-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/preparingdatabase
-[fetchall-docs]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/fetchall
-[fetchone-docs]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/fetchone
+[observing-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/observing
+[dynamic-queries-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/dynamicqueries
+[articles]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata#Essentials
+[comparison-swiftdata-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/comparisonwithswiftdata
+[fetching-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/fetching
+[preparing-db-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/preparingdatabase
+[CloudKit Synchronization]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/cloudkit
+[fetchall-docs]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/fetchall
+[fetchone-docs]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/fetchone
## Performance
-SharingGRDB leverages high-performance decoding from [StructuredQueries][] to turn fetched data into
+SQLiteData leverages high-performance decoding from [StructuredQueries][] to turn fetched data into
your Swift domain types, and has a performance profile similar to invoking SQLite's C APIs directly.
See the following benchmarks against
@@ -282,20 +310,22 @@ See the following benchmarks against
taste of how it compares:
```
-Orders.fetchAll setup rampup duration
- SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183
- Lighter (1.4.10) 0 0.164 8.059
- SharingGRDB (0.2.0) 0 0.172 8.511
- GRDB (7.4.1, manual decoding) 0 0.376 18.819
- SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994
- SQLite.swift (0.15.3, Codable) 0 0.863 43.261
- GRDB (7.4.1, Codable) 0.002 1.07 53.326
+Orders.fetchAll setup rampup duration
+ SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183
+ Lighter (1.4.10) 0 0.164 8.059
+┌──────────────────────────────────────────────────────────────────┐
+│ SQLiteData (1.0.0) 0 0.172 8.511 │
+└──────────────────────────────────────────────────────────────────┘
+ GRDB (7.4.1, manual decoding) 0 0.376 18.819
+ SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994
+ SQLite.swift (0.15.3, Codable) 0 0.863 43.261
+ GRDB (7.4.1, Codable) 0.002 1.07 53.326
```
## SQLite knowledge required
-SQLite is one of the
-[most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of
+SQLite is one of the
+[most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of
software in the history of software. Knowledge of SQLite is a great skill for any app developer to
have, and this library does not want to conceal it from you. So, we feel that to best wield this
library you should be familiar with the basics of SQLite, including schema design and normalization,
@@ -306,7 +336,6 @@ for data and keep your views up-to-date when data in the database changes, and y
[StructuredQueries][] to build queries, either using its type-safe, discoverable
[query building APIs][], or using its `#sql` macro for writing [safe SQL strings][].
-[Sharing]: https://github.com/pointfreeco/swift-sharing
[StructuredQueries]: https://github.com/pointfreeco/swift-structured-queries
[GRDB]: https://github.com/groue/GRDB.swift
[query building APIs]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore
@@ -315,18 +344,30 @@ for data and keep your views up-to-date when data in the database changes, and y
## Demos
This repo comes with _lots_ of examples to demonstrate how to solve common and complex problems with
-Sharing. Check out [this](./Examples) directory to see them all, including:
+SQLiteData. Check out [this](./Examples) directory to see them all, including:
+
+* [**Case Studies**](./Examples/CaseStudies)
+ Demonstrates how to solve some common application problems in an isolated environment, in
+ both SwiftUI and UIKit. Things like animations, dynamic queries, database transactions, and more.
+
+* [**CloudKitDemo**](./Examples/CloudKitDemo)
+ A simplified demo that shows how to synchronize a SQLite database to CloudKit and how to
+ share records with other iCloud users. See our dedicated articles on [CloudKit Synchronization]
+ and [CloudKit Sharing] for more information.
+
+ [CloudKit Synchronization]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/cloudkit
+ [CloudKit Sharing]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/cloudkitsharing
- * [Case Studies](./Examples/CaseStudies): A number of case studies demonstrating the built-in
- features of the library.
+* [**Reminders**](./Examples/Reminders)
+ A rebuild of Apple's [Reminders][reminders-app-store] app that uses a SQLite database to
+ model the reminders, lists and tags. It features many advanced queries, such as searching, stats
+ aggregation, and multi-table joins. It also features CloudKit synchronization and sharing.
- * [SyncUps](./Examples/SyncUps): We also rebuilt Apple's [Scrumdinger][] demo application using
- modern, best practices for SwiftUI development, including using this library to query and
- persist state using SQLite.
-
- * [Reminders](./Examples/Reminders): A rebuild of Apple's [Reminders][reminders-app-store] app
- that uses a SQLite database to model the reminders, lists and tags. It features many advanced
- queries, such as searching, and stats aggregation.
+* [**SyncUps**](./Examples/SyncUps)
+ This application is a faithful reconstruction of one of Apple's more interesting sample
+ projects, called [Scrumdinger][scrumdinger], and uses SQLite to persist the data for meetings.
+ We have also added CloudKit synchronization so that all changes are automatically made available
+ on all of the user's devices.
[Scrumdinger]: https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger
[reminders-app-store]: https://apps.apple.com/us/app/reminders/id1108187841
@@ -335,51 +376,30 @@ Sharing. Check out [this](./Examples) directory to see them all, including:
The documentation for releases and `main` are available here:
- * [`main`](https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/)
- * [0.x.x](https://swiftpackageindex.com/pointfreeco/sharing-grdb/~/documentation/sharinggrdbcore/)
+ * [`main`](https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/)
+ * [0.x.x](https://swiftpackageindex.com/pointfreeco/sqlite-data/~/documentation/sqlitedata/)
## Installation
-You can add SharingGRDB to an Xcode project by adding it to your project as a package…
-
-> https://github.com/pointfreeco/sharing-grdb
-
-…and adding the `SharingGRDB` product to your target.
-
-> [!TIP]
-> SharingGRDB's primary product is the `SharingGRDB` module, which includes all of the library's
-> functionality, including the `@Fetch` family of property wrappers, the `@Table` macro, and tools
-> for driving StructuredQueries using GRDB. This is the module that most library users should depend
-> on.
->
-> If you are a library author that wishes to extend SharingGRDB with additional functionality, you
-> may want to depend on a different module:
->
-> * `SharingGRDBCore`: This product includes everything in `SharingGRDB` _except_ the macros
-> (`@Table`, `#sql`, _etc._). This module can be imported to extend SharingGRDB with additional
-> functionality without forcing the heavyweight dependency of SwiftSyntax on your users.
-> * `StructuredQueriesGRDB`: This product includes everything in `SharingGRDB` _except_ the
-> `@Fetch` family of property wrappers. It can be imported if you want to extend
-> StructuredQueries' GRDB driver but do not need access to observation tools provided by
-> Sharing.
-> * `StructuredQueriesGRDBCore`: This product includes everything in `StructuredQueriesGRDB`
-> _except_ the macros. This module can be imported to extend StructuredQueries' GRDB driver with
-> additional functionality without forcing the heavyweight dependency of SwiftSyntax on your
-> users.
-
-If you want to use SharingGRDB in a [SwiftPM](https://swift.org/package-manager/) project, it's as
+You can add SQLiteData to an Xcode project by adding it to your project as a package…
+
+> https://github.com/pointfreeco/sqlite-data
+
+…and adding the `SQLiteData` product to your target.
+
+If you want to use SQLiteData in a [SwiftPM](https://swift.org/package-manager/) project, it's as
simple as adding it to your `Package.swift`:
``` swift
dependencies: [
- .package(url: "https://github.com/pointfreeco/sharing-grdb", from: "0.6.0")
+ .package(url: "https://github.com/pointfreeco/sqlite-data", from: "1.0.0")
]
```
And then adding the following product to any target that needs access to the library:
```swift
-.product(name: "SharingGRDB", package: "sharing-grdb"),
+.product(name: "SQLiteData", package: "sqlite-data"),
```
## Community
@@ -389,7 +409,7 @@ problem, there are a number of places you can discuss with fellow
[Point-Free](http://www.pointfree.co) enthusiasts:
* For long-form discussions, we recommend the
- [discussions](http://github.com/pointfreeco/sharing-grdb/discussions) tab of this repo.
+ [discussions](http://github.com/pointfreeco/sqlite-data/discussions) tab of this repo.
* For casual chat, we recommend the
[Point-Free Community Slack](http://www.pointfree.co/slack-invite).
diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift
new file mode 100644
index 00000000..6a34feb6
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift
@@ -0,0 +1,358 @@
+#if canImport(CloudKit)
+ import CloudKit
+ import CryptoKit
+ import CustomDump
+ import StructuredQueriesCore
+
+ extension _CKRecord where Self == CKRecord {
+ public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation
+ public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation
+ }
+
+ extension _CKRecord where Self == CKShare {
+ public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation
+ public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation
+ }
+
+ extension Optional where Wrapped: CKRecord {
+ public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation?
+ public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation?
+ }
+
+ public struct _SystemFieldsRepresentation: QueryBindable, QueryRepresentable {
+ public let queryOutput: Record
+
+ public var queryBinding: QueryBinding {
+ let archiver = NSKeyedArchiver(requiringSecureCoding: true)
+ queryOutput.encodeSystemFields(with: archiver)
+ if isTesting {
+ archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag")
+ }
+ return archiver.encodedData.queryBinding
+ }
+
+ public init(queryOutput: Record) {
+ self.queryOutput = queryOutput
+ }
+
+ public init?(queryBinding: QueryBinding) {
+ guard case .blob(let bytes) = queryBinding else { return nil }
+ try? self.init(data: Data(bytes))
+ }
+
+ public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws {
+ try self.init(data: try Data(decoder: &decoder))
+ }
+
+ private init(data: Data) throws {
+ let coder = try NSKeyedUnarchiver(forReadingFrom: data)
+ coder.requiresSecureCoding = true
+ guard let queryOutput = Record(coder: coder) else {
+ throw DecodingError()
+ }
+ if isTesting {
+ queryOutput._recordChangeTag =
+ coder
+ .decodeObject(of: NSString.self, forKey: "_recordChangeTag") as? String
+ }
+ self.init(queryOutput: queryOutput)
+ }
+
+ private struct DecodingError: Error {}
+ }
+
+ public struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable {
+ public let queryOutput: Record
+
+ public var queryBinding: QueryBinding {
+ let archiver = NSKeyedArchiver(requiringSecureCoding: true)
+ queryOutput.encode(with: archiver)
+ if isTesting {
+ archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag")
+ }
+ return archiver.encodedData.queryBinding
+ }
+
+ public init(queryOutput: Record) {
+ self.queryOutput = queryOutput
+ }
+
+ public init?(queryBinding: QueryBinding) {
+ guard case .blob(let bytes) = queryBinding else { return nil }
+ try? self.init(data: Data(bytes))
+ }
+
+ public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws {
+ try self.init(data: try Data(decoder: &decoder))
+ }
+
+ private init(data: Data) throws {
+ let coder = try NSKeyedUnarchiver(forReadingFrom: data)
+ coder.requiresSecureCoding = true
+ guard let queryOutput = Record(coder: coder) else {
+ throw DecodingError()
+ }
+ if isTesting {
+ queryOutput._recordChangeTag =
+ coder
+ .decodeObject(of: NSString.self, forKey: "_recordChangeTag") as? String
+ }
+ self.init(queryOutput: queryOutput)
+ }
+
+ private struct DecodingError: Error {}
+ }
+
+ extension CKRecord: _CKRecord {}
+
+ public protocol _CKRecord {}
+
+ extension CKDatabase.Scope {
+ public struct RawValueRepresentation: QueryBindable, QueryRepresentable {
+ public let queryOutput: CKDatabase.Scope
+ public var queryBinding: QueryBinding {
+ queryOutput.rawValue.queryBinding
+ }
+ public init(queryOutput: CKDatabase.Scope) {
+ self.queryOutput = queryOutput
+ }
+ public init?(queryBinding: QueryBinding) {
+ guard case .int(let rawValue) = queryBinding else { return nil }
+ try? self.init(rawValue: Int(rawValue))
+ }
+ public init(decoder: inout some QueryDecoder) throws {
+ try self.init(rawValue: Int(decoder: &decoder))
+ }
+ private init(rawValue: Int) throws {
+ guard let queryOutput = CKDatabase.Scope(rawValue: rawValue) else {
+ throw DecodingError()
+ }
+ self.init(queryOutput: queryOutput)
+ }
+ private struct DecodingError: Error {}
+ }
+ }
+
+ @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
+ extension CKRecordKeyValueSetting {
+ subscript(at key: String) -> Int64 {
+ get {
+ self["\(CKRecord.userModificationTimeKey)_\(key)"] as? Int64 ?? -1
+ }
+ set {
+ self["\(CKRecord.userModificationTimeKey)_\(key)"] = max(self[at: key], newValue)
+ }
+ }
+ }
+
+ @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
+ extension URL {
+ init(hash data: some DataProtocol) {
+ @Dependency(\.dataManager) var dataManager
+ let hash = SHA256.hash(data: data).compactMap { String(format: "%02hhx", $0) }.joined()
+ self = dataManager.temporaryDirectory.appendingPathComponent(hash)
+ }
+ }
+
+ @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
+ extension CKRecord {
+ @TaskLocal static var fooo = false
+
+ @discardableResult
+ package func setValue(
+ _ newValue: some CKRecordValueProtocol & Equatable,
+ forKey key: CKRecord.FieldKey,
+ at userModificationTime: Int64
+ ) -> Bool {
+ guard
+ encryptedValues[at: key] <= userModificationTime,
+ encryptedValues[key] != newValue
+ else { return false }
+ encryptedValues[key] = newValue
+ encryptedValues[at: key] = userModificationTime
+ self.userModificationTime = userModificationTime
+ return true
+ }
+
+ @discardableResult
+ package func setValue(
+ _ newValue: [UInt8],
+ forKey key: CKRecord.FieldKey,
+ at userModificationTime: Int64
+ ) -> Bool {
+ @Dependency(\.dataManager) var dataManager
+
+ guard encryptedValues[at: key] <= userModificationTime
+ else {
+ return false
+ }
+
+ let asset = CKAsset(fileURL: URL(hash: newValue))
+ guard let fileURL = asset.fileURL, (self[key] as? CKAsset)?.fileURL != fileURL
+ else { return false }
+ withErrorReporting(.sqliteDataCloudKitFailure) {
+ try dataManager.save(Data(newValue), to: fileURL)
+ }
+ self[key] = asset
+ encryptedValues[at: key] = userModificationTime
+ self.userModificationTime = userModificationTime
+ return true
+ }
+
+ @discardableResult
+ package func removeValue(
+ forKey key: CKRecord.FieldKey,
+ at userModificationTime: Int64
+ ) -> Bool {
+ guard encryptedValues[at: key] <= userModificationTime
+ else {
+ return false
+ }
+ if encryptedValues[key] != nil {
+ encryptedValues[key] = nil
+ encryptedValues[at: key] = userModificationTime
+ self.userModificationTime = userModificationTime
+ return true
+ } else if self[key] != nil {
+ self[key] = nil
+ encryptedValues[at: key] = userModificationTime
+ self.userModificationTime = userModificationTime
+ return true
+ }
+ return false
+ }
+
+ func update(with row: T, userModificationTime: Int64) {
+ for column in T.TableColumns.writableColumns {
+ func open(_ column: some WritableTableColumnExpression) {
+ let column = column as! any WritableTableColumnExpression
+ let value = Value(queryOutput: row[keyPath: column.keyPath])
+ switch value.queryBinding {
+ case .blob(let value):
+ setValue(value, forKey: column.name, at: userModificationTime)
+ case .bool(let value):
+ setValue(value, forKey: column.name, at: userModificationTime)
+ case .double(let value):
+ setValue(value, forKey: column.name, at: userModificationTime)
+ case .date(let value):
+ setValue(value, forKey: column.name, at: userModificationTime)
+ case .int(let value):
+ setValue(value, forKey: column.name, at: userModificationTime)
+ case .null:
+ removeValue(forKey: column.name, at: userModificationTime)
+ case .text(let value):
+ setValue(value, forKey: column.name, at: userModificationTime)
+ case .uint(let value):
+ setValue(value, forKey: column.name, at: userModificationTime)
+ case .uuid(let value):
+ setValue(
+ value.uuidString.lowercased(),
+ forKey: column.name,
+ at: userModificationTime
+ )
+ case .invalid(let error):
+ reportIssue(error)
+ }
+ }
+ open(column)
+ }
+ }
+
+ func update(
+ with other: CKRecord,
+ row: T,
+ columnNames: inout [String],
+ parentForeignKey: ForeignKey?
+ ) {
+ typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable
+
+ self.userModificationTime = other.userModificationTime
+ for column in T.TableColumns.writableColumns {
+ func open(_ column: some WritableTableColumnExpression) {
+ let key = column.name
+ let column = column as! any WritableTableColumnExpression
+ let didSet: Bool
+ if let value = other[key] as? CKAsset {
+ didSet = setValue(value, forKey: key, at: other[at: key])
+ } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol {
+ didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key])
+ } else if other.encryptedValues[key] == nil {
+ didSet = removeValue(forKey: key, at: other.encryptedValues[at: key])
+ } else {
+ didSet = false
+ }
+ /// The row value has been modified more recently than the last known record.
+ var isRowValueModified: Bool {
+ switch Value(queryOutput: row[keyPath: column.keyPath]).queryBinding {
+ case .blob(let value):
+ return (other[key] as? CKAsset)?.fileURL != URL(hash: value)
+ case .bool(let value):
+ return other.encryptedValues[key] != value
+ case .double(let value):
+ return other.encryptedValues[key] != value
+ case .date(let value):
+ return other.encryptedValues[key] != value
+ case .int(let value):
+ return other.encryptedValues[key] != value
+ case .null:
+ return other.encryptedValues[key] != nil
+ case .text(let value):
+ return other.encryptedValues[key] != value
+ case .uint(let value):
+ return other.encryptedValues[key] != value
+ case .uuid(let value):
+ return other.encryptedValues[key] != value.uuidString.lowercased()
+ case .invalid(let error):
+ reportIssue(error)
+ return false
+ }
+ }
+ if didSet || isRowValueModified {
+ columnNames.removeAll(where: { $0 == key })
+ if didSet, let parentForeignKey, key == parentForeignKey.from {
+ self.parent = other.parent
+ }
+ }
+ }
+ open(column)
+ }
+ }
+
+ package var userModificationTime: Int64 {
+ get { encryptedValues[Self.userModificationTimeKey] as? Int64 ?? -1 }
+ set {
+ encryptedValues[Self.userModificationTimeKey] = Swift.max(userModificationTime, newValue)
+ }
+ }
+
+ package static let userModificationTimeKey =
+ "\(String.sqliteDataCloudKitSchemaName)_userModificationTime"
+ }
+
+ extension __CKRecordObjCValue {
+ var queryFragment: QueryFragment {
+ if let value = self as? Int64 {
+ return value.queryFragment
+ } else if let value = self as? Double {
+ return value.queryFragment
+ } else if let value = self as? String {
+ return value.queryFragment
+ } else if let value = self as? Data {
+ return value.queryFragment
+ } else if let value = self as? Date {
+ return value.queryFragment
+ } else {
+ return "\(.invalid(Unbindable()))"
+ }
+ }
+ }
+
+ private struct Unbindable: Error {}
+
+ extension CKRecord {
+ package var _recordChangeTag: String? {
+ get { self[#function] }
+ set { self[#function] = newValue }
+ }
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift
new file mode 100644
index 00000000..1e2d25ec
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift
@@ -0,0 +1,309 @@
+#if canImport(CloudKit)
+ import CloudKit
+ import Dependencies
+ import SwiftUI
+
+ #if canImport(UIKit)
+ import UIKit
+ #endif
+
+ /// A shared record that can be used to present a ``CloudSharingView``.
+ ///
+ /// See for more information.,
+ @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *)
+ public struct SharedRecord: Hashable, Identifiable, Sendable {
+ let container: any CloudContainer
+ public let share: CKShare
+
+ public var id: CKRecord.ID { share.recordID }
+
+ public static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.container === rhs.container && lhs.share == rhs.share
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(ObjectIdentifier(container))
+ hasher.combine(share)
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension SyncEngine {
+ private struct SharingError: LocalizedError {
+ enum Reason {
+ case recordMetadataNotFound
+ case recordNotRoot([ForeignKey])
+ case recordTableNotSynchronized
+ case recordTablePrivate
+ }
+
+ let recordTableName: String
+ let recordPrimaryKey: String
+ let reason: Reason
+ let debugDescription: String
+
+ var errorDescription: String? {
+ "The record could not be shared."
+ }
+ }
+
+ /// Shares a record in CloudKit.
+ ///
+ /// This method will thrown an error if:
+ ///
+ /// * The table the `record` belongs to is not synchronized to CloudKit.
+ /// * The `record` has any foreign keys. Only root records are shareable in CloudKit.
+ /// * The table the `record` belongs to is a "private" table as determined by the
+ /// [`SyncEngine` initializer]().
+ /// * The `record` is being shared before it has been synchronized to CloudKit.
+ /// * Any of the CloudKit APIs invoked throw an error.
+ ///
+ /// The value returned from this method can be used to present a ``CloudSharingView`` which
+ /// allows the user to send a share URL to another user.
+ ///
+ /// - Parameters:
+ /// - record: The record to be shared on CloudKit.
+ /// - configure: A trailing closure that can customize the `CKShare` sent to CloudKit. See
+ /// [Apple's documentation](https://developer.apple.com/documentation/cloudkit/ckshare/systemfieldkey)
+ /// for more info on what can be configured.
+ public func share(
+ record: T,
+ configure: @Sendable (CKShare) -> Void
+ ) async throws -> SharedRecord
+ where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible {
+ guard tablesByName[T.tableName] != nil
+ else {
+ throw SharingError(
+ recordTableName: T.tableName,
+ recordPrimaryKey: record.primaryKey.rawIdentifier,
+ reason: .recordTableNotSynchronized,
+ debugDescription: """
+ Table is not shareable: table type not passed to 'tables' parameter of \
+ 'SyncEngine.init'.
+ """
+ )
+ }
+ if let foreignKeys = foreignKeysByTableName[T.tableName], !foreignKeys.isEmpty {
+ throw SharingError(
+ recordTableName: T.tableName,
+ recordPrimaryKey: record.primaryKey.rawIdentifier,
+ reason: .recordNotRoot(foreignKeys),
+ debugDescription: """
+ Only root records are shareable, but parent record(s) detected via foreign key(s).
+ """
+ )
+ }
+ guard !privateTables.contains(where: { T.self == $0 })
+ else {
+ throw SharingError(
+ recordTableName: T.tableName,
+ recordPrimaryKey: record.primaryKey.rawIdentifier,
+ reason: .recordTablePrivate,
+ debugDescription: """
+ Private tables are not shareable: table type passed to 'privateTables' parameter of \
+ 'SyncEngine.init'.
+ """
+ )
+ }
+ let recordName = record.recordName
+ let metadata =
+ try await metadatabase.read { db in
+ try SyncMetadata
+ .where { $0.recordName.eq(recordName) }
+ .select { ($0.recordType, $0.recordName, $0.lastKnownServerRecord) }
+ .fetchOne(db)
+ } ?? nil
+ guard let (recordType, recordName, lastKnownServerRecord) = metadata
+ else {
+ throw SharingError(
+ recordTableName: T.tableName,
+ recordPrimaryKey: record.primaryKey.rawIdentifier,
+ reason: .recordMetadataNotFound,
+ debugDescription: """
+ No sync metadata found for record. Has the record been saved to the database?
+ """
+ )
+ }
+
+ let rootRecord =
+ lastKnownServerRecord
+ ?? CKRecord(
+ recordType: recordType,
+ recordID: CKRecord.ID(recordName: recordName, zoneID: defaultZone.zoneID)
+ )
+
+ var existingShare: CKShare? {
+ get async throws {
+ guard let shareRecordID = rootRecord.share?.recordID
+ else { return nil }
+ do {
+ return try await container.database(for: rootRecord.recordID)
+ .record(for: shareRecordID) as? CKShare
+ } catch let error as CKError where error.code == .unknownItem {
+ return nil
+ }
+ }
+ }
+
+ let sharedRecord =
+ try await existingShare
+ ?? CKShare(
+ rootRecord: rootRecord,
+ shareID: CKRecord.ID(
+ recordName: "share-\(recordName)",
+ zoneID: rootRecord.recordID.zoneID
+ )
+ )
+
+ configure(sharedRecord)
+ _ = try await container.privateCloudDatabase.modifyRecords(
+ saving: [sharedRecord, rootRecord],
+ deleting: []
+ )
+ try await userDatabase.write { db in
+ try SyncMetadata
+ .where { $0.recordName.eq(recordName) }
+ .update { $0.share = sharedRecord }
+ .execute(db)
+ }
+
+ return SharedRecord(container: container, share: sharedRecord)
+ }
+
+ public func unshare(record: T) async throws
+ where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible {
+ let share = try await metadatabase.read { [recordName = record.recordName] db in
+ try SyncMetadata
+ .where { $0.recordName.eq(recordName) }
+ .select(\.share)
+ .fetchOne(db)
+ ?? nil
+ }
+ guard let share
+ else {
+ reportIssue(
+ """
+ No share found associated with record.
+ """)
+ return
+ }
+
+ let result = try await syncEngines.private?.database.modifyRecords(
+ saving: [],
+ deleting: [share.recordID]
+ )
+ try result?.deleteResults.values.forEach { _ = try $0.get() }
+ }
+
+ /// Accepts a shared record.
+ ///
+ /// This method should be invoked from various delegate methods on the scene delegate of the
+ /// app. See for more info.
+ public func acceptShare(metadata: CKShare.Metadata) async throws {
+ try await acceptShare(metadata: ShareMetadata(rawValue: metadata))
+ }
+ }
+
+ #if canImport(UIKit) && !os(watchOS)
+ /// A view that presents standard screens for adding and removing people from a CloudKit share \
+ /// record.
+ ///
+ /// See for more info.
+ @available(iOS 17, macOS 14, tvOS 17, *)
+ public struct CloudSharingView: UIViewControllerRepresentable {
+ let sharedRecord: SharedRecord
+ let availablePermissions: UICloudSharingController.PermissionOptions
+ let didFinish: (Result) -> Void
+ let didStopSharing: () -> Void
+ let syncEngine: SyncEngine
+ public init(
+ sharedRecord: SharedRecord,
+ availablePermissions: UICloudSharingController.PermissionOptions = [],
+ didFinish: @escaping (Result) -> Void = { _ in },
+ didStopSharing: @escaping () -> Void = {},
+ syncEngine: SyncEngine = {
+ @Dependency(\.defaultSyncEngine) var defaultSyncEngine
+ return defaultSyncEngine
+ }()
+ ) {
+ self.sharedRecord = sharedRecord
+ self.didFinish = didFinish
+ self.didStopSharing = didStopSharing
+ self.availablePermissions = availablePermissions
+ self.syncEngine = syncEngine
+ }
+
+ public func makeCoordinator() -> _CloudSharingDelegate {
+ _CloudSharingDelegate(
+ share: sharedRecord.share,
+ didFinish: didFinish,
+ didStopSharing: didStopSharing,
+ syncEngine: syncEngine
+ )
+ }
+
+ public func makeUIViewController(context: Context) -> UICloudSharingController {
+ let controller = UICloudSharingController(
+ share: sharedRecord.share,
+ container: sharedRecord.container.rawValue
+ )
+ controller.delegate = context.coordinator
+ controller.availablePermissions = availablePermissions
+ return controller
+ }
+
+ public func updateUIViewController(
+ _ uiViewController: UICloudSharingController,
+ context: Context
+ ) {
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, *)
+ public final class _CloudSharingDelegate: NSObject, UICloudSharingControllerDelegate {
+ let share: CKShare
+ let didFinish: (Result) -> Void
+ let didStopSharing: () -> Void
+ let syncEngine: SyncEngine
+ init(
+ share: CKShare,
+ didFinish: @escaping (Result) -> Void,
+ didStopSharing: @escaping () -> Void,
+ syncEngine: SyncEngine
+ ) {
+ self.share = share
+ self.didFinish = didFinish
+ self.didStopSharing = didStopSharing
+ self.syncEngine = syncEngine
+ }
+
+ public func itemThumbnailData(for csc: UICloudSharingController) -> Data? {
+ share[CKShare.SystemFieldKey.thumbnailImageData] as? Data
+ }
+
+ public func itemTitle(for csc: UICloudSharingController) -> String? {
+ share[CKShare.SystemFieldKey.title] as? String
+ }
+
+ public func cloudSharingControllerDidSaveShare(_ csc: UICloudSharingController) {
+ didFinish(.success(()))
+ }
+
+ public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) {
+ Task {
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await syncEngine.deleteShare(shareRecordID: share.recordID)
+ }
+ }
+ didStopSharing()
+ }
+
+ public func cloudSharingController(
+ _ csc: UICloudSharingController,
+ failedToSaveShareWithError error: any Error
+ ) {
+ didFinish(.failure(error))
+ }
+ }
+ #endif
+#endif
diff --git a/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift b/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift
new file mode 100644
index 00000000..93b34516
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift
@@ -0,0 +1,57 @@
+#if canImport(CloudKit)
+ import CloudKit
+ import Dependencies
+ import GRDB
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension DependencyValues {
+ /// The default sync engine used by the application.
+ ///
+ /// Configure this as early as possible in your app's lifetime, like the app entry point in
+ /// SwiftUI, using `prepareDependencies`:
+ ///
+ /// ```swift
+ /// import SQLiteData
+ /// import SwiftUI
+ ///
+ /// ```swift
+ /// @main
+ /// struct MyApp: App {
+ /// init() {
+ /// prepareDependencies {
+ /// $0.defaultDatabase = try! appDatabase()
+ /// $0.defaultSyncEngine = SyncEngine(
+ /// for: $0.defaultDatabase,
+ /// tables: Item.self
+ /// )
+ /// }
+ /// }
+ /// // ...
+ /// }
+ /// ```
+ ///
+ /// > Note: You can only prepare the default sync engine a single time in the lifetime of
+ /// > your app. Attempting to do so more than once will produce a runtime warning.
+ ///
+ /// Once configured, access the default sync engine anywhere using `@Dependency`:
+ ///
+ /// ```swift
+ /// @Dependency(\.defaultSyncEngine) var syncEngine
+ ///
+ /// syncEngine.acceptShare(metadata: metadata)
+ /// ```
+ ///
+ /// See for more info.
+ public var defaultSyncEngine: SyncEngine {
+ get { self[SyncEngine.self] }
+ set { self[SyncEngine.self] = newValue }
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension SyncEngine: TestDependencyKey {
+ public static var testValue: SyncEngine {
+ try! SyncEngine(for: DatabaseQueue())
+ }
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift
new file mode 100644
index 00000000..259aa14c
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+/// A type that can be represented by a string identifier.
+///
+/// A requirement of tables synchronized to CloudKit using a ``SyncEngine``. You should generally
+/// identify tables using Foundation's `UUID` type.
+public protocol IdentifierStringConvertible {
+ init?(rawIdentifier: String)
+ var rawIdentifier: String { get }
+}
+
+extension IdentifierStringConvertible where Self: CustomStringConvertible {
+ public var rawIdentifier: String { description }
+}
+
+extension IdentifierStringConvertible where Self: LosslessStringConvertible {
+ public init?(rawIdentifier: String) {
+ self.init(rawIdentifier)
+ }
+}
+
+extension Bool: IdentifierStringConvertible {}
+extension Character: IdentifierStringConvertible {}
+extension Double: IdentifierStringConvertible {}
+extension Float: IdentifierStringConvertible {}
+#if !(arch(i386) || arch(x86_64))
+ @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
+ extension Float16: IdentifierStringConvertible {}
+#endif
+#if !(os(Windows) || os(Android) || ($Embedded && !os(Linux) && !(os(macOS) || os(iOS) || os(watchOS) || os(tvOS)))) && (arch(i386) || arch(x86_64))
+ extension Float80: IdentifierStringConvertible {}
+#endif
+extension Int: IdentifierStringConvertible {}
+@available(iOS 18, macOS 15, tvOS 18, watchOS 11, *)
+extension Int128: IdentifierStringConvertible {}
+extension Int16: IdentifierStringConvertible {}
+extension Int32: IdentifierStringConvertible {}
+extension Int64: IdentifierStringConvertible {}
+extension Int8: IdentifierStringConvertible {}
+extension String: IdentifierStringConvertible {}
+extension Substring: IdentifierStringConvertible {}
+extension UInt: IdentifierStringConvertible {}
+@available(iOS 18, macOS 15, tvOS 18, watchOS 11, *)
+extension UInt128: IdentifierStringConvertible {}
+extension UInt16: IdentifierStringConvertible {}
+extension UInt32: IdentifierStringConvertible {}
+extension UInt64: IdentifierStringConvertible {}
+extension UInt8: IdentifierStringConvertible {}
+extension Unicode.Scalar: IdentifierStringConvertible {}
+
+extension UUID: IdentifierStringConvertible {
+ public init?(rawIdentifier: String) {
+ self.init(uuidString: rawIdentifier)
+ }
+ public var rawIdentifier: String {
+ description.lowercased()
+ }
+}
diff --git a/Sources/SQLiteData/CloudKit/Internal/CloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/CloudContainer.swift
new file mode 100644
index 00000000..4829102f
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/CloudContainer.swift
@@ -0,0 +1,90 @@
+#if canImport(CloudKit)
+ import CloudKit
+
+ @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *)
+ package protocol CloudContainer: AnyObject, Equatable, Hashable, Sendable {
+ associatedtype Database: CloudDatabase
+
+ func accountStatus() async throws -> CKAccountStatus
+ var containerIdentifier: String? { get }
+ var rawValue: CKContainer { get }
+ var privateCloudDatabase: Database { get }
+ func accept(_ metadata: ShareMetadata) async throws -> CKShare
+ static func createContainer(identifier containerIdentifier: String) -> Self
+ var sharedCloudDatabase: Database { get }
+ @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
+ func shareMetadata(for share: CKShare, shouldFetchRootRecord: Bool) async throws
+ -> ShareMetadata
+ }
+
+ @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *)
+ package struct ShareMetadata: Hashable {
+ package var containerIdentifier: String
+ package var hierarchicalRootRecordID: CKRecord.ID?
+ package var rootRecord: CKRecord?
+ package var share: CKShare
+ package var rawValue: CKShare.Metadata?
+ package init(rawValue: CKShare.Metadata) {
+ self.containerIdentifier = rawValue.containerIdentifier
+ self.hierarchicalRootRecordID = rawValue.hierarchicalRootRecordID
+ self.rootRecord = rawValue.rootRecord
+ self.share = rawValue.share
+ self.rawValue = rawValue
+ }
+ package init(
+ containerIdentifier: String,
+ hierarchicalRootRecordID: CKRecord.ID?,
+ rootRecord: CKRecord?,
+ share: CKShare
+ ) {
+ self.containerIdentifier = containerIdentifier
+ self.hierarchicalRootRecordID = hierarchicalRootRecordID
+ self.rootRecord = rootRecord
+ self.share = share
+ self.rawValue = nil
+ }
+ }
+
+ @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *)
+ extension CloudContainer {
+ package func database(for recordID: CKRecord.ID) -> any CloudDatabase {
+ recordID.zoneID.ownerName == CKCurrentUserDefaultName
+ ? privateCloudDatabase
+ : sharedCloudDatabase
+ }
+ }
+
+ @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *)
+ extension CKContainer: CloudContainer {
+ package func accept(_ metadata: ShareMetadata) async throws -> CKShare {
+ guard let metadata = metadata.rawValue
+ else {
+ fatalError("This should never be called with 'ShareMetadata' that has a nil 'rawValue'")
+ }
+ return try await self.accept(metadata)
+ }
+
+ package static func createContainer(identifier containerIdentifier: String) -> Self {
+ Self(identifier: containerIdentifier)
+ }
+
+ package var rawValue: CKContainer {
+ self
+ }
+
+ @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
+ package func shareMetadata(
+ for share: CKShare,
+ shouldFetchRootRecord: Bool = false
+ ) async throws -> ShareMetadata {
+ try await withUnsafeThrowingContinuation { continuation in
+ let operation = CKFetchShareMetadataOperation(shareURLs: [share.url].compactMap(\.self))
+ operation.shouldFetchRootRecord = true
+ operation.perShareMetadataResultBlock = { url, result in
+ continuation.resume(with: result.map(ShareMetadata.init(rawValue:)))
+ }
+ add(operation)
+ }
+ }
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/CloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/CloudDatabase.swift
new file mode 100644
index 00000000..94a0dc61
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/CloudDatabase.swift
@@ -0,0 +1,123 @@
+#if canImport(CloudKit)
+ import CloudKit
+
+ package protocol CloudDatabase: AnyObject, Hashable, Sendable {
+ var databaseScope: CKDatabase.Scope { get }
+
+ func record(for recordID: CKRecord.ID) async throws -> CKRecord
+
+ @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
+ func records(
+ for ids: [CKRecord.ID],
+ desiredKeys: [CKRecord.FieldKey]?
+ ) async throws -> [CKRecord.ID: Result]
+
+ @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
+ func modifyRecords(
+ saving recordsToSave: [CKRecord],
+ deleting recordIDsToDelete: [CKRecord.ID],
+ savePolicy: CKModifyRecordsOperation.RecordSavePolicy,
+ atomically: Bool
+ ) async throws -> (
+ saveResults: [CKRecord.ID: Result],
+ deleteResults: [CKRecord.ID: Result]
+ )
+
+ @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
+ func modifyRecordZones(
+ saving recordZonesToSave: [CKRecordZone],
+ deleting recordZoneIDsToDelete: [CKRecordZone.ID]
+ ) async throws -> (
+ saveResults: [CKRecordZone.ID: Result],
+ deleteResults: [CKRecordZone.ID: Result]
+ )
+ }
+
+ extension CloudDatabase {
+ @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
+ func modifyRecords(
+ saving recordsToSave: [CKRecord],
+ deleting recordIDsToDelete: [CKRecord.ID]
+ ) async throws -> (
+ saveResults: [CKRecord.ID: Result],
+ deleteResults: [CKRecord.ID: Result]
+ ) {
+ try await modifyRecords(
+ saving: recordsToSave,
+ deleting: recordIDsToDelete,
+ savePolicy: .ifServerRecordUnchanged,
+ atomically: true
+ )
+ }
+
+ @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
+ package func records(
+ for ids: [CKRecord.ID]
+ ) async throws -> [CKRecord.ID: Result] {
+ try await records(for: ids, desiredKeys: nil)
+ }
+ }
+
+ extension CKDatabase: CloudDatabase {}
+
+ final class AnyCloudDatabase: CloudDatabase {
+ let rawValue: any CloudDatabase
+ init(_ rawValue: any CloudDatabase) {
+ self.rawValue = rawValue
+ }
+
+ var databaseScope: CKDatabase.Scope {
+ rawValue.databaseScope
+ }
+
+ func record(for recordID: CKRecord.ID) async throws -> CKRecord {
+ try await rawValue.record(for: recordID)
+ }
+
+ @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
+ func records(
+ for ids: [CKRecord.ID],
+ desiredKeys: [CKRecord.FieldKey]?
+ ) async throws -> [CKRecord.ID: Result] {
+ try await rawValue.records(for: ids)
+ }
+
+ @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
+ func modifyRecords(
+ saving recordsToSave: [CKRecord],
+ deleting recordIDsToDelete: [CKRecord.ID],
+ savePolicy: CKModifyRecordsOperation.RecordSavePolicy,
+ atomically: Bool
+ ) async throws -> (
+ saveResults: [CKRecord.ID: Result],
+ deleteResults: [CKRecord.ID: Result]
+ ) {
+ try await rawValue.modifyRecords(
+ saving: recordsToSave,
+ deleting: recordIDsToDelete,
+ savePolicy: savePolicy,
+ atomically: atomically
+ )
+ }
+
+ @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
+ func modifyRecordZones(
+ saving recordZonesToSave: [CKRecordZone],
+ deleting recordZoneIDsToDelete: [CKRecordZone.ID]
+ ) async throws -> (
+ saveResults: [CKRecordZone.ID: Result],
+ deleteResults: [CKRecordZone.ID: Result]
+ ) {
+ try await rawValue.modifyRecordZones(
+ saving: recordZonesToSave, deleting: recordZoneIDsToDelete)
+ }
+
+ static func == (lhs: AnyCloudDatabase, rhs: AnyCloudDatabase) -> Bool {
+ lhs.rawValue === rhs.rawValue
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(ObjectIdentifier(rawValue))
+ }
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/CloudKitFunctions.swift b/Sources/SQLiteData/CloudKit/Internal/CloudKitFunctions.swift
new file mode 100644
index 00000000..5d166e16
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/CloudKitFunctions.swift
@@ -0,0 +1,26 @@
+#if canImport(CloudKit)
+ import CloudKit
+ import Foundation
+
+ @DatabaseFunction("sqlitedata_icloud_currentTime")
+ func currentTime() -> Int64 {
+ @Dependency(\.currentTime.now) var now
+ return now
+ }
+
+ @DatabaseFunction(
+ "sqlitedata_icloud_hasPermission",
+ as: ((CKShare?.SystemFieldsRepresentation) -> Bool).self,
+ isDeterministic: true
+ )
+ func hasPermission(_ share: CKShare?) -> Bool {
+ guard let share else { return true }
+ return share.publicPermission == .readWrite
+ || share.currentUserParticipant?.permission == .readWrite
+ }
+
+ @DatabaseFunction("sqlitedata_icloud_syncEngineIsSynchronizingChanges")
+ func syncEngineIsSynchronizingChanges() -> Bool {
+ _isSynchronizingChanges
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/DatetimeGenerator.swift b/Sources/SQLiteData/CloudKit/Internal/DatetimeGenerator.swift
new file mode 100644
index 00000000..038f7976
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/DatetimeGenerator.swift
@@ -0,0 +1,26 @@
+import Dependencies
+import Foundation
+
+package struct CurrentTimeGenerator: DependencyKey, Sendable {
+ private var generate: @Sendable () -> Int64
+ package var now: Int64 {
+ get { self.generate() }
+ set { self.generate = { newValue } }
+ }
+ package func callAsFunction() -> Int64 {
+ self.generate()
+ }
+ package static var liveValue: CurrentTimeGenerator {
+ Self { Int64(clock_gettime_nsec_np(CLOCK_REALTIME)) }
+ }
+ package static var testValue: CurrentTimeGenerator {
+ Self { Int64(clock_gettime_nsec_np(CLOCK_REALTIME)) }
+ }
+}
+
+extension DependencyValues {
+ package var currentTime: CurrentTimeGenerator {
+ get { self[CurrentTimeGenerator.self] }
+ set { self[CurrentTimeGenerator.self] = newValue }
+ }
+}
diff --git a/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift b/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift
new file mode 100644
index 00000000..9fc612d8
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift
@@ -0,0 +1,16 @@
+#if canImport(UIKit)
+ import UIKit
+
+ private enum DefaultNotificationCenterKey: DependencyKey {
+ static let liveValue = NotificationCenter.default
+ static var testValue: NotificationCenter {
+ NotificationCenter()
+ }
+ }
+ extension DependencyValues {
+ package var defaultNotificationCenter: NotificationCenter {
+ get { self[DefaultNotificationCenterKey.self] }
+ set { self[DefaultNotificationCenterKey.self] = newValue }
+ }
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift b/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift
new file mode 100644
index 00000000..977dc785
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift
@@ -0,0 +1,14 @@
+#if canImport(CloudKit)
+ import Foundation
+ import StructuredQueriesCore
+
+ @Selection
+ package struct ForeignKey {
+ let table: String
+ let from: String
+ let to: String
+ let onUpdate: ForeignKeyAction
+ let onDelete: ForeignKeyAction
+ let isNotNull: Bool
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/IsolatedWeakVar.swift b/Sources/SQLiteData/CloudKit/Internal/IsolatedWeakVar.swift
new file mode 100644
index 00000000..ef273a73
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/IsolatedWeakVar.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+final class IsolatedWeakVar: @unchecked Sendable {
+ let lock = NSLock()
+ weak var _value: T?
+
+ init() {}
+
+ var value: T? {
+ lock.lock()
+ defer { lock.unlock() }
+ return _value
+ }
+ func set(_ value: T) {
+ precondition(_value == nil)
+ lock.lock()
+ defer { lock.unlock() }
+ _value = value
+ }
+}
diff --git a/Sources/SQLiteData/CloudKit/Internal/Logging.swift b/Sources/SQLiteData/CloudKit/Internal/Logging.swift
new file mode 100644
index 00000000..0e54dd96
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/Logging.swift
@@ -0,0 +1,247 @@
+#if canImport(CloudKit)
+ import CloudKit
+ import os
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension Logger {
+ func log(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) {
+ let prefix = "[\(syncEngine.database.databaseScope.label)] handleEvent:"
+ switch event {
+ case .stateUpdate:
+ debug("\(prefix) stateUpdate")
+ case .accountChange(let changeType):
+ switch changeType {
+ case .signIn(let currentUser):
+ debug(
+ """
+ \(prefix) signIn
+ Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName)
+ """
+ )
+ case .signOut(let previousUser):
+ debug(
+ """
+ \(prefix) signOut
+ Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName)
+ """
+ )
+ case .switchAccounts(let previousUser, let currentUser):
+ debug(
+ """
+ \(prefix) switchAccounts:
+ Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName)
+ Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName)
+ """
+ )
+ @unknown default:
+ debug("unknown")
+ }
+ case .fetchedDatabaseChanges(_, let deletions):
+ let deletions =
+ deletions.isEmpty
+ ? "⚪️ No deletions"
+ : "✅ Zones deleted (\(deletions.count)): "
+ + deletions
+ .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName }
+ .sorted()
+ .joined(separator: ", ")
+ debug(
+ """
+ \(prefix) fetchedDatabaseChanges
+ \(deletions)
+ """
+ )
+ case .fetchedRecordZoneChanges(let modifications, let deletions):
+ let deletionsByRecordType = Dictionary(
+ grouping: deletions,
+ by: \.recordType
+ )
+ let recordTypeDeletions = deletionsByRecordType.keys.sorted()
+ .map { recordType in "\(recordType) (\(deletionsByRecordType[recordType]!.count))" }
+ .joined(separator: ", ")
+ let deletions =
+ deletions.isEmpty
+ ? "⚪️ No deletions" : "✅ Records deleted (\(deletions.count)): \(recordTypeDeletions)"
+
+ let modificationsByRecordType = Dictionary(
+ grouping: modifications,
+ by: \.recordType
+ )
+ let recordTypeModifications = modificationsByRecordType.keys.sorted()
+ .map { recordType in "\(recordType) (\(modificationsByRecordType[recordType]!.count))" }
+ .joined(separator: ", ")
+ let modifications =
+ modifications.isEmpty
+ ? "⚪️ No modifications"
+ : "✅ Records modified (\(modifications.count)): \(recordTypeModifications)"
+
+ debug(
+ """
+ \(prefix) fetchedRecordZoneChanges
+ \(modifications)
+ \(deletions)
+ """
+ )
+ case .sentDatabaseChanges(
+ let savedZones,
+ let failedZoneSaves,
+ let deletedZoneIDs,
+ let failedZoneDeletes
+ ):
+ let savedZoneNames =
+ savedZones
+ .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName }
+ .sorted()
+ .joined(separator: ", ")
+ let savedZones =
+ savedZones.isEmpty
+ ? "⚪️ No saved zones" : "✅ Saved zones (\(savedZones.count)): \(savedZoneNames)"
+
+ let deletedZoneNames =
+ deletedZoneIDs
+ .map { $0.zoneName }
+ .sorted()
+ .joined(separator: ", ")
+ let deletedZones =
+ deletedZoneIDs.isEmpty
+ ? "⚪️ No deleted zones"
+ : "✅ Deleted zones (\(deletedZoneIDs.count)): \(deletedZoneNames)"
+
+ let failedZoneSaveNames =
+ failedZoneSaves
+ .map { $0.zone.zoneID.zoneName + ":" + $0.zone.zoneID.ownerName }
+ .sorted()
+ .joined(separator: ", ")
+ let failedZoneSaves =
+ failedZoneSaves.isEmpty
+ ? "⚪️ No failed saved zones"
+ : "🛑 Failed zone saves (\(failedZoneSaves.count)): \(failedZoneSaveNames)"
+
+ let failedZoneDeleteNames = failedZoneDeletes
+ .keys
+ .map { $0.zoneName }
+ .sorted()
+ .joined(separator: ", ")
+ let failedZoneDeletes =
+ failedZoneDeletes.isEmpty
+ ? "⚪️ No failed deleted zones"
+ : "🛑 Failed zone delete (\(failedZoneDeletes.count)): \(failedZoneDeleteNames)"
+
+ debug(
+ """
+ \(prefix) sentDatabaseChanges
+ \(savedZones)
+ \(deletedZones)
+ \(failedZoneSaves)
+ \(failedZoneDeletes)
+ """
+ )
+ case .sentRecordZoneChanges(
+ let savedRecords,
+ let failedRecordSaves,
+ let deletedRecordIDs,
+ let failedRecordDeletes
+ ):
+ let savedRecordsByRecordType = Dictionary(
+ grouping: savedRecords,
+ by: \.recordType
+ )
+ let savedRecords = savedRecordsByRecordType.keys
+ .sorted()
+ .map { "\($0) (\(savedRecordsByRecordType[$0]!.count))" }
+ .joined(separator: ", ")
+
+ let failedRecordSavesByZoneName = Dictionary(
+ grouping: failedRecordSaves,
+ by: { $0.record.recordID.zoneID.zoneName + ":" + $0.record.recordID.zoneID.ownerName }
+ )
+ let failedRecordSaves = failedRecordSavesByZoneName.keys
+ .sorted()
+ .map { "\($0) (\(failedRecordSavesByZoneName[$0]!.count))" }
+ .joined(separator: ", ")
+
+ debug(
+ """
+ \(prefix) sentRecordZoneChanges
+ \(savedRecordsByRecordType.isEmpty ? "⚪️ No records saved" : "✅ Saved records: \(savedRecords)")
+ \(deletedRecordIDs.isEmpty ? "⚪️ No records deleted" : "✅ Deleted records (\(deletedRecordIDs.count))")
+ \(failedRecordSavesByZoneName.isEmpty ? "⚪️ No records failed save" : "🛑 Records failed save: \(failedRecordSaves)")
+ \(failedRecordDeletes.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete (\(failedRecordDeletes.count))")
+ """
+ )
+ case .willFetchChanges:
+ debug("\(prefix) willFetchChanges")
+ case .willFetchRecordZoneChanges(let zoneID):
+ debug("\(prefix) willFetchRecordZoneChanges: \(zoneID.zoneName)")
+ case .didFetchRecordZoneChanges(let zoneID, let error):
+ let errorType = error.map {
+ switch $0.code {
+ case .internalError: "internalError"
+ case .partialFailure: "partialFailure"
+ case .networkUnavailable: "networkUnavailable"
+ case .networkFailure: "networkFailure"
+ case .badContainer: "badContainer"
+ case .serviceUnavailable: "serviceUnavailable"
+ case .requestRateLimited: "requestRateLimited"
+ case .missingEntitlement: "missingEntitlement"
+ case .notAuthenticated: "notAuthenticated"
+ case .permissionFailure: "permissionFailure"
+ case .unknownItem: "unknownItem"
+ case .invalidArguments: "invalidArguments"
+ case .resultsTruncated: "resultsTruncated"
+ case .serverRecordChanged: "serverRecordChanged"
+ case .serverRejectedRequest: "serverRejectedRequest"
+ case .assetFileNotFound: "assetFileNotFound"
+ case .assetFileModified: "assetFileModified"
+ case .incompatibleVersion: "incompatibleVersion"
+ case .constraintViolation: "constraintViolation"
+ case .operationCancelled: "operationCancelled"
+ case .changeTokenExpired: "changeTokenExpired"
+ case .batchRequestFailed: "batchRequestFailed"
+ case .zoneBusy: "zoneBusy"
+ case .badDatabase: "badDatabase"
+ case .quotaExceeded: "quotaExceeded"
+ case .zoneNotFound: "zoneNotFound"
+ case .limitExceeded: "limitExceeded"
+ case .userDeletedZone: "userDeletedZone"
+ case .tooManyParticipants: "tooManyParticipants"
+ case .alreadyShared: "alreadyShared"
+ case .referenceViolation: "referenceViolation"
+ case .managedAccountRestricted: "managedAccountRestricted"
+ case .participantMayNeedVerification: "participantMayNeedVerification"
+ case .serverResponseLost: "serverResponseLost"
+ case .assetNotAvailable: "assetNotAvailable"
+ case .accountTemporarilyUnavailable: "accountTemporarilyUnavailable"
+ @unknown default: "unknown"
+ }
+ }
+ let error = errorType.map { "\n ❌ \($0)" } ?? ""
+ debug(
+ """
+ \(prefix) willFetchRecordZoneChanges
+ ✅ Zone: \(zoneID.zoneName):\(zoneID.ownerName)\(error)
+ """
+ )
+ case .didFetchChanges:
+ debug("\(prefix) didFetchChanges")
+ case .willSendChanges(let context):
+ debug("\(prefix) willSendChanges: \(context.reason.description)")
+ case .didSendChanges(let context):
+ debug("\(prefix) didSendChanges: \(context.reason.description)")
+ @unknown default:
+ warning("\(prefix) ⚠️ unknown event: \(event.description)")
+ }
+ }
+ }
+
+ extension CKDatabase.Scope {
+ var label: String {
+ switch self {
+ case .public: "public"
+ case .private: "private"
+ case .shared: "shared"
+ @unknown default: "unknown"
+ }
+ }
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift
new file mode 100644
index 00000000..cba0d65d
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift
@@ -0,0 +1,158 @@
+#if canImport(CloudKit)
+ import Foundation
+ import os
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ func defaultMetadatabase(
+ logger: Logger,
+ url: URL
+ ) throws -> any DatabaseWriter {
+ var configuration = Configuration()
+ configuration.prepareDatabase { [logger] db in
+ db.trace {
+ logger.trace("\($0.expandedDescription)")
+ }
+ }
+ logger.debug(
+ """
+ Metadatabase connection:
+ open "\(url.path(percentEncoded: false))"
+ """
+ )
+ try FileManager.default.createDirectory(
+ at: .applicationSupportDirectory,
+ withIntermediateDirectories: true
+ )
+
+ @Dependency(\.context) var context
+ guard !url.isInMemory || context != .live
+ else {
+ struct InMemoryDatabase: Error {}
+ throw InMemoryDatabase()
+ }
+
+ let metadatabase: any DatabaseWriter =
+ if url.isInMemory {
+ try DatabaseQueue(
+ path: url.absoluteString,
+ configuration: configuration
+ )
+ } else {
+ try DatabasePool(
+ path: url.path(percentEncoded: false),
+ configuration: configuration
+ )
+ }
+ try migrate(metadatabase: metadatabase)
+ return metadatabase
+ }
+
+ func migrate(metadatabase: some DatabaseWriter) throws {
+ var migrator = DatabaseMigrator()
+ migrator.registerMigration("Create Metadata Tables") { db in
+ try #sql(
+ """
+ CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_metadata" (
+ "recordPrimaryKey" TEXT NOT NULL,
+ "recordType" TEXT NOT NULL,
+ "recordName" TEXT NOT NULL AS ("recordPrimaryKey" || ':' || "recordType"),
+ "zoneName" TEXT NOT NULL,
+ "ownerName" TEXT NOT NULL,
+ "parentRecordPrimaryKey" TEXT,
+ "parentRecordType" TEXT,
+ "parentRecordName" TEXT AS ("parentRecordPrimaryKey" || ':' || "parentRecordType"),
+ "lastKnownServerRecord" BLOB,
+ "_lastKnownServerRecordAllFields" BLOB,
+ "share" BLOB,
+ "hasLastKnownServerRecord" INTEGER NOT NULL AS ("lastKnownServerRecord" IS NOT NULL),
+ "isShared" INTEGER NOT NULL AS ("share" IS NOT NULL),
+ "userModificationTime" INTEGER NOT NULL DEFAULT (\($currentTime())),
+ "_isDeleted" INTEGER NOT NULL DEFAULT 0,
+
+ PRIMARY KEY ("recordPrimaryKey", "recordType"),
+ UNIQUE ("recordName")
+ ) STRICT
+ """
+ )
+ .execute(db)
+ try #sql(
+ """
+ CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_zoneID"
+ ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("ownerName", "zoneName")
+ """
+ )
+ try #sql(
+ """
+ CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName"
+ ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("parentRecordName")
+ """
+ )
+ .execute(db)
+ try #sql(
+ """
+ CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared"
+ ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("isShared")
+ """
+ )
+ .execute(db)
+ try #sql(
+ """
+ CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_hasLastKnownServerRecord"
+ ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("hasLastKnownServerRecord")
+ """
+ )
+ .execute(db)
+ try #sql(
+ """
+ CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" (
+ "tableName" TEXT NOT NULL PRIMARY KEY,
+ "schema" TEXT NOT NULL,
+ "tableInfo" TEXT NOT NULL
+ ) STRICT
+ """
+ )
+ .execute(db)
+ try #sql(
+ """
+ CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" (
+ "scope" TEXT NOT NULL PRIMARY KEY,
+ "data" TEXT NOT NULL
+ ) STRICT
+ """
+ )
+ .execute(db)
+ try #sql(
+ """
+ CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_unsyncedRecordIDs" (
+ "recordName" TEXT NOT NULL,
+ "zoneName" TEXT NOT NULL,
+ "ownerName" TEXT NOT NULL,
+ PRIMARY KEY ("recordName", "zoneName", "ownerName")
+ ) STRICT
+ """
+ )
+ .execute(db)
+ try #sql(
+ """
+ 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/Internal/MockCloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift
new file mode 100644
index 00000000..faea3f1b
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift
@@ -0,0 +1,139 @@
+import CloudKit
+import CustomDump
+
+@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+package final class MockCloudContainer: CloudContainer, CustomDumpReflectable {
+ package let _accountStatus: LockIsolated
+ package let containerIdentifier: String?
+ package let privateCloudDatabase: MockCloudDatabase
+ package let sharedCloudDatabase: MockCloudDatabase
+
+ package init(
+ accountStatus: CKAccountStatus = .available,
+ containerIdentifier: String?,
+ privateCloudDatabase: MockCloudDatabase,
+ sharedCloudDatabase: MockCloudDatabase
+ ) {
+ self._accountStatus = LockIsolated(accountStatus)
+ self.containerIdentifier = containerIdentifier
+ self.privateCloudDatabase = privateCloudDatabase
+ self.sharedCloudDatabase = sharedCloudDatabase
+
+ guard let containerIdentifier else { return }
+ @Dependency(\.mockCloudContainers) var mockCloudContainers
+ mockCloudContainers.withValue { storage in
+ storage[containerIdentifier] = self
+ }
+ }
+
+ package func accountStatus() -> CKAccountStatus {
+ _accountStatus.withValue(\.self)
+ }
+
+ package var rawValue: CKContainer {
+ fatalError("This should never be called in tests.")
+ }
+
+ package func accountStatus() async throws -> CKAccountStatus {
+ _accountStatus.withValue { $0 }
+ }
+
+ package func shareMetadata(
+ for share: CKShare,
+ shouldFetchRootRecord: Bool
+ ) async throws -> ShareMetadata {
+ let database =
+ share.recordID.zoneID.ownerName == CKCurrentUserDefaultName
+ ? privateCloudDatabase
+ : sharedCloudDatabase
+
+ let rootRecord: CKRecord? = database.storage.withValue {
+ $0[share.recordID.zoneID]?.values.first { record in
+ record.share?.recordID == share.recordID
+ }
+ }
+
+ return ShareMetadata(
+ containerIdentifier: containerIdentifier!,
+ hierarchicalRootRecordID: rootRecord?.recordID,
+ rootRecord: shouldFetchRootRecord ? rootRecord : nil,
+ share: share
+ )
+ }
+
+ package func accept(_ metadata: ShareMetadata) async throws -> CKShare {
+ guard let rootRecord = metadata.rootRecord
+ else {
+ fatalError("Must provide root record in mock shares during tests.")
+ }
+
+ let (saveResults, _) = try sharedCloudDatabase.modifyRecords(
+ saving: [metadata.share, rootRecord]
+ )
+ try saveResults.values.forEach { _ = try $0.get() }
+ return metadata.share
+ }
+
+ package static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer
+ {
+ @Dependency(\.mockCloudContainers) var mockCloudContainers
+ return mockCloudContainers.withValue { storage in
+ let container: MockCloudContainer
+ if let existingContainer = storage[containerIdentifier] {
+ return existingContainer
+ } else {
+ container = MockCloudContainer(
+ accountStatus: .available,
+ containerIdentifier: containerIdentifier,
+ privateCloudDatabase: MockCloudDatabase(databaseScope: .private),
+ sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared)
+ )
+ container.privateCloudDatabase.set(container: container)
+ container.sharedCloudDatabase.set(container: container)
+ }
+ storage[containerIdentifier] = container
+ return container
+ }
+ }
+
+ package static func == (lhs: MockCloudContainer, rhs: MockCloudContainer) -> Bool {
+ lhs === rhs
+ }
+
+ package func hash(into hasher: inout Hasher) {
+ hasher.combine(ObjectIdentifier(self))
+ }
+
+ package var customDumpMirror: Mirror {
+ Mirror(
+ self,
+ children: [
+ ("privateCloudDatabase", privateCloudDatabase),
+ ("sharedCloudDatabase", sharedCloudDatabase),
+ ],
+ displayStyle: .struct
+ )
+ }
+}
+
+@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+private enum MockCloudContainersKey: DependencyKey {
+ static var liveValue: LockIsolated<[String: MockCloudContainer]> {
+ LockIsolated<[String: MockCloudContainer]>([:])
+ }
+ static var testValue: LockIsolated<[String: MockCloudContainer]> {
+ LockIsolated<[String: MockCloudContainer]>([:])
+ }
+}
+
+extension DependencyValues {
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ fileprivate var mockCloudContainers: LockIsolated<[String: MockCloudContainer]> {
+ get {
+ self[MockCloudContainersKey.self]
+ }
+ set {
+ self[MockCloudContainersKey.self] = newValue
+ }
+ }
+}
diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift
new file mode 100644
index 00000000..59099e60
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift
@@ -0,0 +1,322 @@
+import CloudKit
+import CustomDump
+import IssueReporting
+
+@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+package final class MockCloudDatabase: CloudDatabase {
+ package let storage = LockIsolated<[CKRecordZone.ID: [CKRecord.ID: CKRecord]]>([:])
+ let assets = LockIsolated<[AssetID: Data]>([:])
+ package let databaseScope: CKDatabase.Scope
+ let _container = IsolatedWeakVar()
+
+ let dataManager = Dependency(\.dataManager)
+
+ struct AssetID: Hashable {
+ let recordID: CKRecord.ID
+ let key: String
+ }
+
+ package init(databaseScope: CKDatabase.Scope) {
+ self.databaseScope = databaseScope
+ }
+
+ package func set(container: MockCloudContainer) {
+ _container.set(container)
+ }
+
+ package var container: MockCloudContainer {
+ _container.value!
+ }
+
+ package func record(for recordID: CKRecord.ID) throws -> CKRecord {
+ let accountStatus = container.accountStatus()
+ guard accountStatus == .available
+ else { throw ckError(forAccountStatus: accountStatus) }
+ guard let zone = storage[recordID.zoneID]
+ else { throw CKError(.zoneNotFound) }
+ guard let record = zone[recordID]
+ else { throw CKError(.unknownItem) }
+ guard let record = record.copy() as? CKRecord
+ else { fatalError("Could not copy CKRecord.") }
+
+ try assets.withValue { assets in
+ for key in record.allKeys() {
+ guard let assetData = assets[AssetID(recordID: record.recordID, key: key)]
+ else { continue }
+ let url = URL(filePath: UUID().uuidString.lowercased())
+ try dataManager.wrappedValue.save(assetData, to: url)
+ record[key] = CKAsset(fileURL: url)
+ }
+ }
+
+ return record
+ }
+
+ package func records(
+ for ids: [CKRecord.ID],
+ desiredKeys: [CKRecord.FieldKey]?
+ ) throws -> [CKRecord.ID: Result] {
+ let accountStatus = container.accountStatus()
+ guard accountStatus == .available
+ else { throw ckError(forAccountStatus: accountStatus) }
+
+ var results: [CKRecord.ID: Result] = [:]
+ for id in ids {
+ results[id] = Result { try record(for: id) }
+ }
+ return results
+ }
+
+ package func modifyRecords(
+ saving recordsToSave: [CKRecord] = [],
+ deleting recordIDsToDelete: [CKRecord.ID] = [],
+ savePolicy: CKModifyRecordsOperation.RecordSavePolicy = .ifServerRecordUnchanged,
+ atomically: Bool = true
+ ) throws -> (
+ saveResults: [CKRecord.ID: Result],
+ deleteResults: [CKRecord.ID: Result]
+ ) {
+ let accountStatus = container.accountStatus()
+ guard accountStatus == .available
+ else { throw ckError(forAccountStatus: accountStatus) }
+
+ return storage.withValue { storage in
+ var saveResults: [CKRecord.ID: Result] = [:]
+ var deleteResults: [CKRecord.ID: Result] = [:]
+
+ switch savePolicy {
+ case .ifServerRecordUnchanged:
+ for recordToSave in recordsToSave {
+ if let share = recordToSave as? CKShare {
+ let isSavingRootRecord = recordsToSave.contains(where: {
+ $0.share?.recordID == share.recordID
+ })
+ let shareWasPreviouslySaved = storage[share.recordID.zoneID]?[share.recordID] != nil
+ guard shareWasPreviouslySaved || isSavingRootRecord
+ else {
+ reportIssue(
+ """
+ An added share is being saved without its rootRecord being saved in the same \
+ operation.
+ """
+ )
+ saveResults[recordToSave.recordID] = .failure(CKError(.invalidArguments))
+ continue
+ }
+ }
+
+ guard storage[recordToSave.recordID.zoneID] != nil
+ else {
+ saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound))
+ continue
+ }
+
+ let existingRecord = storage[recordToSave.recordID.zoneID]?[recordToSave.recordID]
+
+ func saveRecordToDatabase() {
+ let hasReferenceViolation =
+ recordToSave.parent.map { parent in
+ storage[parent.recordID.zoneID]?[parent.recordID] == nil
+ && !recordsToSave.contains { $0.recordID == parent.recordID }
+ }
+ ?? false
+ guard !hasReferenceViolation
+ else {
+ saveResults[recordToSave.recordID] = .failure(CKError(.referenceViolation))
+ return
+ }
+
+ func root(of record: CKRecord) -> CKRecord {
+ guard let parent = record.parent
+ else { return record }
+ return (storage[parent.recordID.zoneID]?[parent.recordID]).map(root) ?? record
+ }
+ func share(for rootRecord: CKRecord) -> CKShare? {
+ for (_, record) in storage[rootRecord.recordID.zoneID] ?? [:] {
+ guard record.recordID == rootRecord.share?.recordID
+ else { continue }
+ return record as? CKShare
+ }
+ return nil
+ }
+ let rootRecord = root(of: recordToSave)
+ let share = share(for: rootRecord)
+ let isSavingShare = recordsToSave.contains { $0.recordID == share?.recordID }
+ if !isSavingShare,
+ !(recordToSave is CKShare),
+ let share,
+ !(share.publicPermission == .readWrite
+ || share.currentUserParticipant?.permission == .readWrite)
+ {
+ saveResults[recordToSave.recordID] = .failure(CKError(.permissionFailure))
+ return
+ }
+
+ guard let copy = recordToSave.copy() as? CKRecord
+ else { fatalError("Could not copy CKRecord.") }
+ copy._recordChangeTag = UUID().uuidString
+
+ assets.withValue { assets in
+ for key in copy.allKeys() {
+ guard let assetURL = (copy[key] as? CKAsset)?.fileURL
+ else { continue }
+ assets[AssetID(recordID: copy.recordID, key: key)] = try? dataManager.wrappedValue
+ .load(assetURL)
+ }
+ }
+
+ // TODO: This should merge copy's values to more accurately reflect reality
+ storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy
+ saveResults[recordToSave.recordID] = .success(copy)
+ }
+
+ switch (existingRecord, recordToSave._recordChangeTag) {
+ case (.some(let existingRecord), .some(let recordToSaveChangeTag)):
+ // We are trying to save a record with a change tag that also already exists in the
+ // DB. If the tags match, we can save the record. Otherwise, we notify the sync engine
+ // that the server record has changed since it was last synced.
+ if existingRecord._recordChangeTag == recordToSaveChangeTag {
+ precondition(existingRecord._recordChangeTag != nil)
+ saveRecordToDatabase()
+ } else {
+ saveResults[recordToSave.recordID] = .failure(
+ CKError(
+ .serverRecordChanged,
+ userInfo: [
+ CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any,
+ CKRecordChangedErrorClientRecordKey: recordToSave.copy(),
+ ]
+ )
+ )
+ }
+ break
+ case (.some(let existingRecord), .none):
+ // We are trying to save a record that does not have a change tag yet also already
+ // exists in the DB. This means the user has created a new CKRecord from scratch,
+ // giving it a new identity, rather than leveraging an existing CKRecord.
+ saveResults[recordToSave.recordID] = .failure(
+ CKError(
+ .serverRejectedRequest,
+ userInfo: [
+ CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any,
+ CKRecordChangedErrorClientRecordKey: recordToSave.copy(),
+ ]
+ )
+ )
+ case (.none, .some):
+ // We are trying to save a record with a change tag but it does not exist in the DB.
+ // This means the record was deleted by another device.
+ saveResults[recordToSave.recordID] = .failure(CKError(.unknownItem))
+ case (.none, .none):
+ // We are trying to save a record with no change tag and no existing record in the DB.
+ // This means it's a brand new record.
+ saveRecordToDatabase()
+ }
+ }
+ case .allKeys, .changedKeys:
+ fatalError()
+ @unknown default:
+ fatalError()
+ }
+ for recordIDToDelete in recordIDsToDelete {
+ guard storage[recordIDToDelete.zoneID] != nil
+ else {
+ deleteResults[recordIDToDelete] = .failure(CKError(.zoneNotFound))
+ continue
+ }
+ let hasReferenceViolation = !Set(
+ storage[recordIDToDelete.zoneID]?.values
+ .compactMap { $0.parent?.recordID == recordIDToDelete ? $0.recordID : nil }
+ ?? []
+ )
+ .subtracting(recordIDsToDelete)
+ .isEmpty
+
+ guard !hasReferenceViolation
+ else {
+ deleteResults[recordIDToDelete] = .failure(CKError(.referenceViolation))
+ continue
+ }
+ storage[recordIDToDelete.zoneID]?[recordIDToDelete] = nil
+ deleteResults[recordIDToDelete] = .success(())
+ }
+
+ return (saveResults: saveResults, deleteResults: deleteResults)
+ }
+ }
+
+ package func modifyRecordZones(
+ saving recordZonesToSave: [CKRecordZone] = [],
+ deleting recordZoneIDsToDelete: [CKRecordZone.ID] = []
+ ) throws -> (
+ saveResults: [CKRecordZone.ID: Result],
+ deleteResults: [CKRecordZone.ID: Result]
+ ) {
+ let accountStatus = container.accountStatus()
+ guard accountStatus == .available
+ else { throw ckError(forAccountStatus: accountStatus) }
+
+ return storage.withValue { storage in
+ var saveResults: [CKRecordZone.ID: Result] = [:]
+ var deleteResults: [CKRecordZone.ID: Result] = [:]
+
+ for recordZoneToSave in recordZonesToSave {
+ storage[recordZoneToSave.zoneID] = storage[recordZoneToSave.zoneID] ?? [:]
+ saveResults[recordZoneToSave.zoneID] = .success(recordZoneToSave)
+ }
+
+ for recordZoneIDsToDelete in recordZoneIDsToDelete {
+ guard storage[recordZoneIDsToDelete] != nil
+ else {
+ deleteResults[recordZoneIDsToDelete] = .failure(CKError(.zoneNotFound))
+ continue
+ }
+ storage[recordZoneIDsToDelete] = nil
+ deleteResults[recordZoneIDsToDelete] = .success(())
+ }
+
+ return (saveResults: saveResults, deleteResults: deleteResults)
+ }
+ }
+
+ package nonisolated static func == (lhs: MockCloudDatabase, rhs: MockCloudDatabase) -> Bool {
+ lhs === rhs
+ }
+
+ package nonisolated func hash(into hasher: inout Hasher) {
+ hasher.combine(ObjectIdentifier(self))
+ }
+}
+
+@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+extension MockCloudDatabase: CustomDumpReflectable {
+ package var customDumpMirror: Mirror {
+ Mirror(
+ self,
+ children: [
+ "databaseScope": databaseScope,
+ "storage": storage
+ .value
+ .flatMap { _, value in value.values }
+ .sorted {
+ ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName)
+ },
+ ],
+ displayStyle: .struct
+ )
+ }
+}
+
+@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
+private func ckError(forAccountStatus accountStatus: CKAccountStatus) -> CKError {
+ switch accountStatus {
+ case .couldNotDetermine, .restricted, .noAccount:
+ return CKError(.notAuthenticated)
+ case .temporarilyUnavailable:
+ return CKError(.accountTemporarilyUnavailable)
+ case .available:
+ fatalError()
+ @unknown default:
+ fatalError()
+ }
+}
diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift
new file mode 100644
index 00000000..1eca4a4d
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift
@@ -0,0 +1,429 @@
+import CloudKit
+import CustomDump
+import OrderedCollections
+
+@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+package final class MockSyncEngine: SyncEngineProtocol {
+ package let database: MockCloudDatabase
+ package let parentSyncEngine: SyncEngine
+ private let _state: LockIsolated
+ private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([])
+ private let _acceptedShareMetadata = LockIsolated>([])
+
+ package init(
+ database: MockCloudDatabase,
+ parentSyncEngine: SyncEngine,
+ state: MockSyncEngineState
+ ) {
+ self.database = database
+ self.parentSyncEngine = parentSyncEngine
+ self._state = LockIsolated(state)
+ }
+
+ package var scope: CKDatabase.Scope {
+ database.databaseScope
+ }
+
+ package var state: MockSyncEngineState {
+ _state.withValue(\.self)
+ }
+
+ package func acceptShare(metadata: ShareMetadata) {
+ _ = _acceptedShareMetadata.withValue { $0.insert(metadata) }
+ }
+
+ package func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws {
+ let records: [CKRecord]
+ let zoneIDs: [CKRecordZone.ID]
+ switch options.scope {
+ case .all:
+ zoneIDs = Array(database.storage.keys)
+ case .allExcluding(let excludedZoneIDs):
+ zoneIDs = Array(Set(database.storage.keys).subtracting(excludedZoneIDs))
+ case .zoneIDs(let includedZoneIDs):
+ zoneIDs = includedZoneIDs
+ @unknown default:
+ fatalError()
+ }
+ records = zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in
+ accum += database.storage.withValue {
+ ($0[zoneID]?.values).map { Array($0) } ?? []
+ }
+ }
+ await parentSyncEngine.handleEvent(
+ .fetchedRecordZoneChanges(modifications: records, deletions: []),
+ syncEngine: self
+ )
+ }
+
+ package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws {
+ guard
+ !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges
+ .isEmpty
+ else { return }
+ try await parentSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope)
+ }
+
+ package func recordZoneChangeBatch(
+ pendingChanges: [CKSyncEngine.PendingRecordZoneChange],
+ recordProvider: @Sendable (CKRecord.ID) async -> CKRecord?
+ ) async -> CKSyncEngine.RecordZoneChangeBatch? {
+ var recordsToSave: [CKRecord] = []
+ var recordIDsSkipped: [CKRecord.ID] = []
+ var recordIDsToDelete: [CKRecord.ID] = []
+ for pendingChange in pendingChanges {
+ switch pendingChange {
+ case .saveRecord(let recordID):
+ guard let record = await recordProvider(recordID)
+ else {
+ recordIDsSkipped.append(recordID)
+ continue
+ }
+ recordsToSave.append(record)
+ case .deleteRecord(let recordID):
+ recordIDsToDelete.append(recordID)
+ @unknown default:
+ fatalError()
+ }
+ }
+
+ state.remove(pendingRecordZoneChanges: recordsToSave.map { .saveRecord($0.recordID) })
+
+ return CKSyncEngine.RecordZoneChangeBatch(
+ recordsToSave: recordsToSave,
+ recordIDsToDelete: recordIDsToDelete
+ )
+ }
+
+ package func assertFetchChangesScopes(
+ _ scopes: [CKSyncEngine.FetchChangesOptions.Scope],
+ fileID: StaticString = #fileID,
+ filePath: StaticString = #filePath,
+ line: UInt = #line,
+ column: UInt = #column
+ ) {
+ _fetchChangesScopes.withValue {
+ expectNoDifference(
+ scopes,
+ $0,
+ fileID: fileID,
+ filePath: filePath,
+ line: line,
+ column: column
+ )
+ $0.removeAll()
+ }
+ }
+
+ package func assertAcceptedShareMetadata(
+ _ sharedMetadata: Set,
+ fileID: StaticString = #fileID,
+ filePath: StaticString = #filePath,
+ line: UInt = #line,
+ column: UInt = #column
+ ) {
+ _acceptedShareMetadata.withValue {
+ expectNoDifference(
+ sharedMetadata,
+ $0,
+ fileID: fileID,
+ filePath: filePath,
+ line: line,
+ column: column
+ )
+ $0.removeAll()
+ }
+ }
+
+ package func cancelOperations() async {
+ }
+}
+
+@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+package final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectable {
+ private let _pendingRecordZoneChanges = LockIsolated<
+ OrderedSet
+ >([]
+ )
+ private let _pendingDatabaseChanges = LockIsolated<
+ OrderedSet
+ >([])
+ private let fileID: StaticString
+ private let filePath: StaticString
+ private let line: UInt
+ private let column: UInt
+
+ package init(
+ fileID: StaticString = #fileID,
+ filePath: StaticString = #filePath,
+ line: UInt = #line,
+ column: UInt = #column
+ ) {
+ self.fileID = fileID
+ self.filePath = filePath
+ self.line = line
+ self.column = column
+ }
+
+ package func assertPendingRecordZoneChanges(
+ _ changes: OrderedSet,
+ fileID: StaticString = #fileID,
+ filePath: StaticString = #filePath,
+ line: UInt = #line,
+ column: UInt = #column
+ ) {
+ _pendingRecordZoneChanges.withValue {
+ expectNoDifference(
+ Set(changes),
+ Set($0),
+ fileID: fileID,
+ filePath: filePath,
+ line: line,
+ column: column
+ )
+ $0.removeAll()
+ }
+ }
+
+ package func assertPendingDatabaseChanges(
+ _ changes: OrderedSet,
+ fileID: StaticString = #fileID,
+ filePath: StaticString = #filePath,
+ line: UInt = #line,
+ column: UInt = #column
+ ) {
+ _pendingDatabaseChanges.withValue {
+ expectNoDifference(
+ Set(changes),
+ Set($0),
+ fileID: fileID,
+ filePath: filePath,
+ line: line,
+ column: column
+ )
+ $0.removeAll()
+ }
+ }
+
+ package var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] {
+ _pendingRecordZoneChanges.withValue { Array($0) }
+ }
+
+ package var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] {
+ _pendingDatabaseChanges.withValue { Array($0) }
+ }
+
+ package func removePendingChanges() {
+ _pendingDatabaseChanges.withValue { $0.removeAll() }
+ _pendingRecordZoneChanges.withValue { $0.removeAll() }
+ }
+
+ package func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) {
+ self._pendingRecordZoneChanges.withValue {
+ $0.append(contentsOf: pendingRecordZoneChanges)
+ }
+ }
+
+ package func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) {
+ self._pendingRecordZoneChanges.withValue {
+ $0.subtract(pendingRecordZoneChanges)
+ }
+ }
+
+ package func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) {
+ self._pendingDatabaseChanges.withValue {
+ $0.append(contentsOf: pendingDatabaseChanges)
+ }
+ }
+
+ package func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) {
+ self._pendingDatabaseChanges.withValue {
+ $0.subtract(pendingDatabaseChanges)
+ }
+ }
+
+ package var customDumpMirror: Mirror {
+ return Mirror(
+ self,
+ children: [
+ (
+ "pendingRecordZoneChanges",
+ _pendingRecordZoneChanges.withValue(\.self)
+ .sorted(by: comparePendingRecordZoneChange)
+ as Any
+ ),
+ (
+ "pendingDatabaseChanges",
+ _pendingDatabaseChanges.withValue(\.self)
+ .sorted(by: comparePendingDatabaseChange) as Any
+ ),
+ ],
+ displayStyle: .struct
+ )
+ }
+}
+
+@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+private func comparePendingRecordZoneChange(
+ _ lhs: CKSyncEngine.PendingRecordZoneChange,
+ _ rhs: CKSyncEngine.PendingRecordZoneChange
+) -> Bool {
+ switch (lhs, rhs) {
+ case (.saveRecord(let lhs), .saveRecord(let rhs)),
+ (.deleteRecord(let lhs), .deleteRecord(let rhs)):
+ lhs.recordName < rhs.recordName
+ case (.deleteRecord, .saveRecord):
+ true
+ case (.saveRecord, .deleteRecord):
+ false
+ default:
+ false
+ }
+}
+
+@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+private func comparePendingDatabaseChange(
+ _ lhs: CKSyncEngine.PendingDatabaseChange,
+ _ rhs: CKSyncEngine.PendingDatabaseChange
+) -> Bool {
+ switch (lhs, rhs) {
+ case (.saveZone(let lhs), .saveZone(let rhs)):
+ lhs.zoneID.zoneName < rhs.zoneID.zoneName
+ case (.deleteZone(let lhs), .deleteZone(let rhs)):
+ lhs.zoneName < rhs.zoneName
+ case (.deleteZone, .saveZone):
+ true
+ case (.saveZone, .deleteZone):
+ false
+ default:
+ false
+ }
+}
+
+@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+extension SyncEngine {
+ package func processPendingRecordZoneChanges(
+ options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(),
+ scope: CKDatabase.Scope,
+ fileID: StaticString = #fileID,
+ filePath: StaticString = #filePath,
+ line: UInt = #line,
+ column: UInt = #column
+ ) async throws {
+ let syncEngine = syncEngine(for: scope)
+ guard !syncEngine.state.pendingRecordZoneChanges.isEmpty
+ else {
+ reportIssue(
+ "Processing empty set of record zone changes.",
+ fileID: fileID,
+ filePath: filePath,
+ line: line,
+ column: column
+ )
+ return
+ }
+ guard try await container.accountStatus() == .available
+ else {
+ reportIssue(
+ """
+ User must be logged in to process pending changes.
+ """,
+ fileID: fileID,
+ filePath: filePath,
+ line: line,
+ column: column
+ )
+ return
+ }
+
+ let batch = await nextRecordZoneChangeBatch(
+ reason: .scheduled,
+ options: options,
+ syncEngine: {
+ switch scope {
+ case .private:
+ self.private
+ case .shared:
+ self.shared
+ case .public:
+ fatalError("Public database not supported in tests.")
+ @unknown default:
+ fatalError("Unknown database scope not supported in tests.")
+ }
+ }()
+ )
+ guard let batch
+ else { return }
+
+ let (saveResults, deleteResults) = try syncEngine.database.modifyRecords(
+ saving: batch.recordsToSave,
+ deleting: batch.recordIDsToDelete,
+ savePolicy: .ifServerRecordUnchanged,
+ atomically: true
+ )
+
+ var savedRecords: [CKRecord] = []
+ var failedRecordSaves: [(record: CKRecord, error: CKError)] = []
+ var deletedRecordIDs: [CKRecord.ID] = []
+ var failedRecordDeletes: [CKRecord.ID: CKError] = [:]
+ for (recordID, result) in saveResults {
+ switch result {
+ case .success(let record):
+ savedRecords.append(record)
+ case .failure(let error as CKError):
+ guard let record = batch.recordsToSave.first(where: { $0.recordID == recordID })
+ else { fatalError("\(recordID.debugDescription) not found in pending changes") }
+ failedRecordSaves.append((record: record, error: error))
+ case .failure:
+ fatalError("Mocks should only raise 'CKError' values.")
+ }
+ }
+ for (recordID, result) in deleteResults {
+ switch result {
+ case .success:
+ deletedRecordIDs.append(recordID)
+ case .failure(let error as CKError):
+ failedRecordDeletes[recordID] = error
+ case .failure:
+ fatalError("Mocks should only raise 'CKError' values.")
+ }
+ }
+ syncEngine.state.remove(
+ pendingRecordZoneChanges: savedRecords.map { .saveRecord($0.recordID) }
+ )
+ syncEngine.state.remove(
+ pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) }
+ )
+
+ await syncEngine.parentSyncEngine
+ .handleEvent(
+ .sentRecordZoneChanges(
+ savedRecords: savedRecords,
+ failedRecordSaves: failedRecordSaves,
+ deletedRecordIDs: deletedRecordIDs,
+ failedRecordDeletes: failedRecordDeletes
+ ),
+ syncEngine: syncEngine
+ )
+ }
+
+ package var `private`: MockSyncEngine {
+ syncEngines.private as! MockSyncEngine
+ }
+ package var shared: MockSyncEngine {
+ syncEngines.shared as! MockSyncEngine
+ }
+
+ package func syncEngine(for scope: CKDatabase.Scope) -> MockSyncEngine {
+ switch scope {
+ case .public:
+ fatalError("Public database not supported in sync engines.")
+ case .private:
+ `private`
+ case .shared:
+ shared
+ @unknown default:
+ fatalError("Unknown database scope not supported in sync engines.")
+ }
+ }
+}
diff --git a/Sources/SQLiteData/CloudKit/Internal/PendingRecordZoneChange.swift b/Sources/SQLiteData/CloudKit/Internal/PendingRecordZoneChange.swift
new file mode 100644
index 00000000..fad64e0d
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/PendingRecordZoneChange.swift
@@ -0,0 +1,72 @@
+#if canImport(CloudKit)
+ import CloudKit
+
+ @Table("sqlitedata_icloud_pendingRecordZoneChanges")
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ package struct PendingRecordZoneChange {
+ @Column(as: CKSyncEngine.PendingRecordZoneChange.DataRepresentation.self)
+ package let pendingRecordZoneChange: CKSyncEngine.PendingRecordZoneChange
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension PendingRecordZoneChange {
+ package init(_ pendingRecordZoneChange: CKSyncEngine.PendingRecordZoneChange) {
+ self.pendingRecordZoneChange = pendingRecordZoneChange
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension CKSyncEngine.PendingRecordZoneChange {
+ package struct DataRepresentation: QueryBindable, QueryRepresentable {
+ package var queryOutput: CKSyncEngine.PendingRecordZoneChange
+
+ package init(queryOutput: CKSyncEngine.PendingRecordZoneChange) {
+ self.queryOutput = queryOutput
+ }
+
+ package var queryBinding: StructuredQueriesCore.QueryBinding {
+ let archiver = NSKeyedArchiver(requiringSecureCoding: true)
+ switch queryOutput {
+ case .saveRecord(let recordID):
+ recordID.encode(with: archiver)
+ archiver.encode("saveRecord", forKey: "changeType")
+ case .deleteRecord(let recordID):
+ recordID.encode(with: archiver)
+ archiver.encode("deleteRecord", forKey: "changeType")
+ @unknown default:
+ return .invalid(BindingError())
+ }
+ return archiver.encodedData.queryBinding
+ }
+
+ package init?(queryBinding: StructuredQueriesCore.QueryBinding) {
+ guard case .blob(let bytes) = queryBinding else { return nil }
+ try? self.init(data: Data(bytes))
+ }
+
+ package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws {
+ try self.init(data: Data(decoder: &decoder))
+ }
+
+ private init(data: Data) throws {
+ let coder = try NSKeyedUnarchiver(forReadingFrom: data)
+ coder.requiresSecureCoding = true
+ guard let recordID = CKRecord.ID(coder: coder) else {
+ throw DecodingError()
+ }
+ let changeType = coder.decodeObject(of: NSString.self, forKey: "changeType") as? String
+ switch changeType {
+ case "saveRecord":
+ self.init(queryOutput: .saveRecord(recordID))
+ case "deleteRecord":
+ self.init(queryOutput: .deleteRecord(recordID))
+ default:
+ throw DecodingError()
+ }
+ }
+ }
+
+ private struct DecodingError: Error {}
+ private struct BindingError: Error {}
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/Pragmas.swift b/Sources/SQLiteData/CloudKit/Internal/Pragmas.swift
new file mode 100644
index 00000000..d384415a
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/Pragmas.swift
@@ -0,0 +1,64 @@
+@Table
+struct PragmaDatabaseList {
+ static var tableAlias: String? { "databases" }
+ static var tableFragment: QueryFragment { "pragma_database_list()" }
+
+ @Column("seq") let sequence: Int
+ let name: String
+ let file: String
+}
+
+@Table
+struct PragmaForeignKeyList {
+ static var tableAlias: String? { "\(Base.tableName)ForeignKeys" }
+ static var tableFragment: QueryFragment {
+ "pragma_foreign_key_list(\(quote: Base.tableName, delimiter: .text))"
+ }
+
+ let id: Int
+ @Column("seq") let sequence: Int
+ let table: String
+ let from: String
+ let to: String
+ @Column("on_update") let onUpdate: ForeignKeyAction
+ @Column("on_delete") let onDelete: ForeignKeyAction
+ let match: String
+}
+
+package enum ForeignKeyAction: String, QueryBindable {
+ case cascade = "CASCADE"
+ case restrict = "RESTRICT"
+ case setDefault = "SET DEFAULT"
+ case setNull = "SET NULL"
+ case noAction = "NO ACTION"
+}
+
+@Table
+struct PragmaIndexList {
+ static var tableAlias: String? { "\(Base.tableName)Indices" }
+ static var tableFragment: QueryFragment {
+ "pragma_index_list(\(quote: Base.tableName, delimiter: .text))"
+ }
+
+ @Column("seq") let sequence: Int
+ let name: String
+ @Column("unique") let isUnique: Bool
+ let origin: String
+ @Column("partial") let isPartial: Bool
+}
+
+@Table
+struct PragmaTableInfo {
+ static var tableAlias: String? { "\(Base.tableName)TableInfo" }
+ static var schemaName: String? { Base.schemaName }
+ static var tableFragment: QueryFragment {
+ "pragma_table_info(\(quote: Base.tableName, delimiter: .text))"
+ }
+
+ @Column("cid") let columnID: Int
+ let name: String
+ let type: String
+ @Column("notnull") let isNotNull: Bool
+ @Column("dflt_value") let defaultValue: String?
+ @Column("pk") let isPrimaryKey: Bool
+}
diff --git a/Sources/SQLiteData/CloudKit/Internal/RecordType.swift b/Sources/SQLiteData/CloudKit/Internal/RecordType.swift
new file mode 100644
index 00000000..5e9e8a93
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/RecordType.swift
@@ -0,0 +1,24 @@
+import CustomDump
+
+@Table("sqlitedata_icloud_recordTypes")
+package struct RecordType: Hashable {
+ @Column(primaryKey: true)
+ package let tableName: String
+ package let schema: String
+ @Column(as: Set.JSONRepresentation.self)
+ package let tableInfo: Set
+}
+
+extension RecordType: CustomDumpReflectable {
+ package var customDumpMirror: Mirror {
+ Mirror(
+ self,
+ children: [
+ ("tableName", tableName as Any),
+ ("schema", schema),
+ ("tableInfo", tableInfo.sorted(by: { $0.name < $1.name })),
+ ],
+ displayStyle: .struct
+ )
+ }
+}
diff --git a/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift b/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift
new file mode 100644
index 00000000..ed08fc28
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift
@@ -0,0 +1,12 @@
+@Table("sqlite_schema")
+package struct SQLiteSchema {
+ package let type: ObjectType
+ package let name: String
+ @Column("tbl_name")
+ package let tableName: String
+ package let sql: String?
+
+ package enum ObjectType: String, QueryBindable {
+ case table, index, view, trigger
+ }
+}
diff --git a/Sources/SQLiteData/CloudKit/Internal/StateSerialization.swift b/Sources/SQLiteData/CloudKit/Internal/StateSerialization.swift
new file mode 100644
index 00000000..2b76b33b
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/StateSerialization.swift
@@ -0,0 +1,13 @@
+#if canImport(CloudKit)
+ import CloudKit
+ import StructuredQueriesCore
+
+ @Table("sqlitedata_icloud_stateSerialization")
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ package struct StateSerialization {
+ @Column(as: CKDatabase.Scope.RawValueRepresentation.self, primaryKey: true)
+ package var scope: CKDatabase.Scope
+ @Column(as: CKSyncEngine.State.Serialization.JSONRepresentation.self)
+ package var data: CKSyncEngine.State.Serialization
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngine.Event.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngine.Event.swift
new file mode 100644
index 00000000..230aac02
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngine.Event.swift
@@ -0,0 +1,103 @@
+#if canImport(CloudKit)
+ import CloudKit
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension SyncEngine {
+ package enum Event: CustomStringConvertible, Sendable {
+ case stateUpdate(stateSerialization: CKSyncEngine.State.Serialization)
+ case accountChange(changeType: CKSyncEngine.Event.AccountChange.ChangeType)
+ case fetchedDatabaseChanges(
+ modifications: [CKRecordZone.ID],
+ deletions: [(zoneID: CKRecordZone.ID, reason: CKDatabase.DatabaseChange.Deletion.Reason)]
+ )
+ case fetchedRecordZoneChanges(
+ modifications: [CKRecord],
+ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)]
+ )
+ case sentDatabaseChanges(
+ savedZones: [CKRecordZone],
+ failedZoneSaves: [(zone: CKRecordZone, error: CKError)],
+ deletedZoneIDs: [CKRecordZone.ID],
+ failedZoneDeletes: [CKRecordZone.ID: CKError]
+ )
+ case sentRecordZoneChanges(
+ savedRecords: [CKRecord],
+ failedRecordSaves: [(record: CKRecord, error: CKError)],
+ deletedRecordIDs: [CKRecord.ID],
+ failedRecordDeletes: [CKRecord.ID: CKError]
+ )
+ case willFetchChanges
+ case willFetchRecordZoneChanges(zoneID: CKRecordZone.ID)
+ case didFetchChanges
+ case didFetchRecordZoneChanges(zoneID: CKRecordZone.ID, error: CKError?)
+ case willSendChanges(context: CKSyncEngine.SendChangesContext)
+ case didSendChanges(context: CKSyncEngine.SendChangesContext)
+
+ init?(_ event: CKSyncEngine.Event) {
+ switch event {
+ case .stateUpdate(let event):
+ self = .stateUpdate(stateSerialization: event.stateSerialization)
+ case .accountChange(let event):
+ self = .accountChange(changeType: event.changeType)
+ case .fetchedDatabaseChanges(let event):
+ self = .fetchedDatabaseChanges(
+ modifications: event.modifications.map(\.zoneID),
+ deletions: event.deletions.map { (zoneID: $0.zoneID, reason: $0.reason) }
+ )
+ case .fetchedRecordZoneChanges(let event):
+ self = .fetchedRecordZoneChanges(
+ modifications: event.modifications.map(\.record),
+ deletions: event.deletions.map {
+ (recordID: $0.recordID, recordType: $0.recordType)
+ }
+ )
+ case .sentDatabaseChanges(let event):
+ self = .sentDatabaseChanges(
+ savedZones: event.savedZones,
+ failedZoneSaves: event.failedZoneSaves.map { (zone: $0.zone, error: $0.error) },
+ deletedZoneIDs: event.deletedZoneIDs,
+ failedZoneDeletes: event.failedZoneDeletes
+ )
+ case .sentRecordZoneChanges(let event):
+ self = .sentRecordZoneChanges(
+ savedRecords: event.savedRecords,
+ failedRecordSaves: event.failedRecordSaves.map { (record: $0.record, error: $0.error) },
+ deletedRecordIDs: event.deletedRecordIDs,
+ failedRecordDeletes: event.failedRecordDeletes
+ )
+ case .willFetchChanges:
+ self = .willFetchChanges
+ case .willFetchRecordZoneChanges(let event):
+ self = .willFetchRecordZoneChanges(zoneID: event.zoneID)
+ case .didFetchChanges:
+ self = .didFetchChanges
+ case .didFetchRecordZoneChanges(let event):
+ self = .didFetchRecordZoneChanges(zoneID: event.zoneID, error: event.error)
+ case .willSendChanges(let event):
+ self = .willSendChanges(context: event.context)
+ case .didSendChanges(let event):
+ self = .didSendChanges(context: event.context)
+ @unknown default:
+ return nil
+ }
+ }
+
+ package var description: String {
+ switch self {
+ case .stateUpdate: "stateUpdate"
+ case .accountChange: "accountChange"
+ case .fetchedDatabaseChanges: "fetchedDatabaseChanges"
+ case .fetchedRecordZoneChanges: "fetchedRecordZoneChanges"
+ case .sentDatabaseChanges: "sentDatabaseChanges"
+ case .sentRecordZoneChanges: "sentRecordZoneChanges"
+ case .willFetchChanges: "willFetchChanges"
+ case .willFetchRecordZoneChanges: "willFetchRecordZoneChanges"
+ case .didFetchRecordZoneChanges: "didFetchRecordZoneChanges"
+ case .didFetchChanges: "didFetchChanges"
+ case .willSendChanges: "willSendChanges"
+ case .didSendChanges: "didSendChanges"
+ }
+ }
+ }
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift
new file mode 100644
index 00000000..d462e198
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift
@@ -0,0 +1,18 @@
+#if canImport(CloudKit)
+ import CloudKit
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension CKSyncEngine: SyncEngineProtocol {
+ package func recordZoneChangeBatch(
+ pendingChanges: [PendingRecordZoneChange],
+ recordProvider: @Sendable (CKRecord.ID) async -> CKRecord?
+ ) async -> RecordZoneChangeBatch? {
+ await CKSyncEngine
+ .RecordZoneChangeBatch(pendingChanges: pendingChanges, recordProvider: recordProvider)
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension CKSyncEngine.State: CKSyncEngineStateProtocol {
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift
new file mode 100644
index 00000000..6dd52194
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift
@@ -0,0 +1,40 @@
+#if canImport(CloudKit)
+ import CloudKit
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ package protocol SyncEngineDelegate: AnyObject, Sendable {
+ func handleEvent(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) async
+ func nextRecordZoneChangeBatch(
+ reason: CKSyncEngine.SyncReason,
+ options: CKSyncEngine.SendChangesOptions,
+ syncEngine: any SyncEngineProtocol
+ ) async -> CKSyncEngine.RecordZoneChangeBatch?
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ package protocol SyncEngineProtocol: AnyObject, Sendable {
+ associatedtype State: CKSyncEngineStateProtocol
+ associatedtype Database: CloudDatabase
+
+ var database: Database { get }
+ var state: State { get }
+
+ func cancelOperations() async
+ func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws
+ func recordZoneChangeBatch(
+ pendingChanges: [CKSyncEngine.PendingRecordZoneChange],
+ recordProvider: @Sendable (CKRecord.ID) async -> CKRecord?
+ ) async -> CKSyncEngine.RecordZoneChangeBatch?
+ func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ package protocol CKSyncEngineStateProtocol: Sendable {
+ var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { get }
+ var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { get }
+ func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange])
+ func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange])
+ func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange])
+ func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange])
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/TableInfo.swift b/Sources/SQLiteData/CloudKit/Internal/TableInfo.swift
new file mode 100644
index 00000000..1b416666
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/TableInfo.swift
@@ -0,0 +1,10 @@
+import StructuredQueriesCore
+
+@Selection
+package struct TableInfo: Codable, Hashable, QueryDecodable, QueryRepresentable {
+ let defaultValue: String?
+ let isPrimaryKey: Bool
+ let name: String
+ let isNotNull: Bool
+ let type: String
+}
diff --git a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift
new file mode 100644
index 00000000..16077e14
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift
@@ -0,0 +1,534 @@
+#if canImport(CloudKit)
+ import CloudKit
+ import Foundation
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension PrimaryKeyedTable {
+ static func metadataTriggers(
+ parentForeignKey: ForeignKey?,
+ defaultZone: CKRecordZone
+ ) -> [TemporaryTrigger] {
+ [
+ afterInsert(parentForeignKey: parentForeignKey, defaultZone: defaultZone),
+ afterUpdate(parentForeignKey: parentForeignKey, defaultZone: defaultZone),
+ afterDeleteFromUser(parentForeignKey: parentForeignKey, defaultZone: defaultZone),
+ afterDeleteFromSyncEngine,
+ afterPrimaryKeyChange(parentForeignKey: parentForeignKey, defaultZone: defaultZone),
+ ]
+ }
+
+ fileprivate static func afterPrimaryKeyChange(
+ parentForeignKey: ForeignKey?,
+ defaultZone: CKRecordZone
+ ) -> TemporaryTrigger {
+ createTemporaryTrigger(
+ "\(String.sqliteDataCloudKitSchemaName)_after_primary_key_change_on_\(tableName)",
+ ifNotExists: true,
+ after: .update(of: \.primaryKey) { old, new in
+ checkWritePermissions(
+ alias: new,
+ parentForeignKey: parentForeignKey,
+ defaultZone: defaultZone
+ )
+ SyncMetadata
+ .where {
+ $0.recordPrimaryKey.eq(#sql("\(old.primaryKey)"))
+ && $0.recordType.eq(tableName)
+ }
+ .update { $0._isDeleted = true }
+ } when: { old, new in
+ old.primaryKey.neq(new.primaryKey)
+ }
+ )
+ }
+
+ fileprivate static func afterInsert(
+ parentForeignKey: ForeignKey?,
+ defaultZone: CKRecordZone
+ ) -> TemporaryTrigger {
+ createTemporaryTrigger(
+ "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)",
+ ifNotExists: true,
+ after: .insert { new in
+ checkWritePermissions(
+ alias: new,
+ parentForeignKey: parentForeignKey,
+ defaultZone: defaultZone
+ )
+ SyncMetadata.insert(
+ new: new,
+ parentForeignKey: parentForeignKey,
+ defaultZone: defaultZone
+ )
+ }
+ )
+ }
+
+ fileprivate static func afterUpdate(
+ parentForeignKey: ForeignKey?,
+ defaultZone: CKRecordZone
+ ) -> TemporaryTrigger {
+ createTemporaryTrigger(
+ "\(String.sqliteDataCloudKitSchemaName)_after_update_on_\(tableName)",
+ ifNotExists: true,
+ after: .update { _, new in
+ checkWritePermissions(
+ alias: new,
+ parentForeignKey: parentForeignKey,
+ defaultZone: defaultZone
+ )
+ SyncMetadata.insert(
+ new: new,
+ parentForeignKey: parentForeignKey,
+ defaultZone: defaultZone
+ )
+ SyncMetadata.update(
+ new: new,
+ parentForeignKey: parentForeignKey,
+ defaultZone: defaultZone
+ )
+ }
+ )
+ }
+
+ fileprivate static func afterDeleteFromUser(
+ parentForeignKey: ForeignKey?,
+ defaultZone: CKRecordZone
+ ) -> TemporaryTrigger<
+ Self
+ > {
+ createTemporaryTrigger(
+ "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)_from_user",
+ ifNotExists: true,
+ after: .delete { old in
+ checkWritePermissions(
+ alias: old,
+ parentForeignKey: parentForeignKey,
+ defaultZone: defaultZone
+ )
+ SyncMetadata
+ .where {
+ $0.recordPrimaryKey.eq(#sql("\(old.primaryKey)"))
+ && $0.recordType.eq(tableName)
+ }
+ .update { $0._isDeleted = true }
+ } when: { _ in
+ !SyncEngine.isSynchronizingChanges()
+ }
+ )
+ }
+
+ fileprivate static var afterDeleteFromSyncEngine: TemporaryTrigger {
+ createTemporaryTrigger(
+ "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)_from_sync_engine",
+ ifNotExists: true,
+ after: .delete { old in
+ SyncMetadata
+ .where {
+ $0.recordPrimaryKey.eq(#sql("\(old.primaryKey)"))
+ && $0.recordType.eq(tableName)
+ }
+ .delete()
+ } when: { _ in
+ SyncEngine.isSynchronizingChanges()
+ }
+ )
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension SyncMetadata {
+ fileprivate static func insert(
+ new: StructuredQueriesCore.TableAlias.TableColumns,
+ parentForeignKey: ForeignKey?,
+ defaultZone: CKRecordZone
+ ) -> some StructuredQueriesCore.Statement {
+ let (parentRecordPrimaryKey, parentRecordType, zoneName, ownerName) = parentFields(
+ alias: new,
+ parentForeignKey: parentForeignKey,
+ defaultZone: defaultZone
+ )
+ let defaultZoneName = #sql(
+ "\(quote: defaultZone.zoneID.zoneName, delimiter: .text)",
+ as: String.self
+ )
+ let defaultOwnerName = #sql(
+ "\(quote: defaultZone.zoneID.ownerName, delimiter: .text)",
+ as: String.self
+ )
+ return insert {
+ (
+ $0.recordPrimaryKey,
+ $0.recordType,
+ $0.zoneName,
+ $0.ownerName,
+ $0.parentRecordPrimaryKey,
+ $0.parentRecordType
+ )
+ } select: {
+ Values(
+ #sql("\(new.primaryKey)"),
+ T.tableName,
+ zoneName ?? defaultZoneName,
+ ownerName ?? defaultOwnerName,
+ parentRecordPrimaryKey,
+ parentRecordType
+ )
+ } onConflictDoUpdate: { _ in
+ }
+ }
+
+ fileprivate static func update(
+ new: StructuredQueriesCore.TableAlias.TableColumns,
+ parentForeignKey: ForeignKey?,
+ defaultZone: CKRecordZone
+ ) -> some StructuredQueriesCore.Statement {
+ let (parentRecordPrimaryKey, parentRecordType, zoneName, ownerName) = parentFields(
+ alias: new,
+ parentForeignKey: parentForeignKey,
+ defaultZone: defaultZone
+ )
+ return Self.where {
+ $0.recordPrimaryKey.eq(#sql("\(new.primaryKey)"))
+ && $0.recordType.eq(T.tableName)
+ }
+ .update {
+ $0.zoneName = zoneName ?? $0.zoneName
+ $0.ownerName = ownerName ?? $0.ownerName
+ $0.parentRecordPrimaryKey = parentRecordPrimaryKey
+ $0.parentRecordType = parentRecordType
+ $0.userModificationTime = $currentTime()
+ }
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension SyncMetadata {
+ static func callbackTriggers(for syncEngine: SyncEngine) -> [TemporaryTrigger] {
+ [
+ afterInsertTrigger(for: syncEngine),
+ afterZoneUpdateTrigger(),
+ afterUpdateTrigger(for: syncEngine),
+ afterSoftDeleteTrigger(for: syncEngine),
+ ]
+ }
+
+ fileprivate static func afterInsertTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger
+ {
+ createTemporaryTrigger(
+ "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_sqlitedata_icloud_metadata",
+ ifNotExists: true,
+ after: .insert { new in
+ validate(recordName: new.recordName)
+ Values(
+ syncEngine.$didUpdate(
+ recordName: new.recordName,
+ zoneName: new.zoneName,
+ ownerName: new.ownerName,
+ oldZoneName: new.zoneName,
+ oldOwnerName: new.ownerName,
+ descendantRecordNames: #bind(nil)
+ )
+ )
+ } when: { _ in
+ !SyncEngine.isSynchronizingChanges()
+ }
+ )
+ }
+
+ fileprivate static func afterZoneUpdateTrigger() -> TemporaryTrigger {
+ createTemporaryTrigger(
+ "\(String.sqliteDataCloudKitSchemaName)_after_zone_update_on_sqlitedata_icloud_metadata",
+ ifNotExists: true,
+ after: .update {
+ ($0.zoneName, $0.ownerName)
+ } forEachRow: { old, new in
+ let selfAndDescendantRecordNames = descendantRecordNames(
+ recordName: new.recordName,
+ includeSelf: true
+ ) {
+ $0.select(\.recordName)
+ }
+ SyncMetadata
+ .where {
+ $0.recordName.in(selfAndDescendantRecordNames)
+ }
+ .update {
+ $0.zoneName = new.zoneName
+ $0.ownerName = new.ownerName
+ $0.lastKnownServerRecord = nil
+ $0._lastKnownServerRecordAllFields = nil
+ }
+ } when: { old, new in
+ new.zoneName.neq(old.zoneName) || new.ownerName.neq(old.ownerName)
+ }
+ )
+ }
+
+ fileprivate static func afterUpdateTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger
+ {
+ createTemporaryTrigger(
+ "\(String.sqliteDataCloudKitSchemaName)_after_update_on_sqlitedata_icloud_metadata",
+ ifNotExists: true,
+ after: .update { old, new in
+ let zoneChanged = new.zoneName.neq(old.zoneName) || new.ownerName.neq(old.ownerName)
+ let descendantRecordNamesJSON = descendantRecordNames(
+ recordName: new.recordName,
+ includeSelf: false
+ ) {
+ $0.select { $0.recordName.jsonGroupArray() }
+ }
+
+ validate(recordName: new.recordName)
+ Values(
+ syncEngine.$didUpdate(
+ recordName: new.recordName,
+ zoneName: new.zoneName,
+ ownerName: new.ownerName,
+ oldZoneName: old.zoneName,
+ oldOwnerName: old.ownerName,
+ descendantRecordNames: Case().when(zoneChanged, then: descendantRecordNamesJSON)
+ )
+ )
+ } when: { old, new in
+ old._isDeleted.eq(new._isDeleted) && !SyncEngine.isSynchronizingChanges()
+ }
+ )
+ }
+
+ fileprivate static func afterSoftDeleteTrigger(
+ for syncEngine: SyncEngine
+ ) -> TemporaryTrigger {
+ createTemporaryTrigger(
+ "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_sqlitedata_icloud_metadata",
+ ifNotExists: true,
+ after: .update(of: \._isDeleted) { _, new in
+ Values(
+ syncEngine.$didDelete(
+ recordName: new.recordName,
+ record: new.lastKnownServerRecord
+ ?? rootServerRecord(recordName: new.recordName),
+ share: new.share
+ )
+ )
+ } when: { old, new in
+ !old._isDeleted && new._isDeleted && !SyncEngine.isSynchronizingChanges()
+ }
+ )
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ private func parentFields(
+ alias: StructuredQueriesCore.TableAlias.TableColumns,
+ parentForeignKey: ForeignKey?,
+ defaultZone: CKRecordZone
+ ) -> (
+ parentRecordPrimaryKey: SQLQueryExpression?,
+ parentRecordType: SQLQueryExpression?,
+ zoneName: SQLQueryExpression,
+ ownerName: SQLQueryExpression
+ ) {
+ return
+ parentForeignKey
+ .map { foreignKey in
+ let parentRecordPrimaryKey = #sql(
+ #"\#(type(of: alias).QueryValue.self).\#(quote: foreignKey.from)"#,
+ as: String.self
+ )
+ let parentRecordType = #sql("\(bind: foreignKey.table)", as: String.self)
+ let parentMetadata = SyncMetadata.where {
+ $0.recordPrimaryKey.eq(parentRecordPrimaryKey)
+ && $0.recordType.eq(parentRecordType)
+ }
+ return (
+ parentRecordPrimaryKey,
+ parentRecordType,
+ #sql("coalesce(\($currentZoneName()), (\(parentMetadata.select(\.zoneName))))"),
+ #sql("coalesce(\($currentOwnerName()), (\(parentMetadata.select(\.ownerName))))")
+ )
+ }
+ ?? (
+ nil,
+ nil,
+ SQLQueryExpression($currentZoneName()),
+ SQLQueryExpression($currentOwnerName())
+ )
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ private func validate(
+ recordName: some QueryExpression
+ ) -> some StructuredQueriesCore.Statement {
+ #sql(
+ """
+ SELECT RAISE(ABORT, \(quote: SyncEngine.invalidRecordNameError, delimiter: .text))
+ WHERE NOT \(recordName.isValidCloudKitRecordName)
+ """,
+ as: Never.self
+ )
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ private func checkWritePermissions(
+ alias: StructuredQueriesCore.TableAlias.TableColumns,
+ parentForeignKey: ForeignKey?,
+ defaultZone: CKRecordZone
+ ) -> some StructuredQueriesCore.Statement {
+ let (parentRecordPrimaryKey, parentRecordType, _, _) = parentFields(
+ alias: alias,
+ parentForeignKey: parentForeignKey,
+ defaultZone: defaultZone
+ )
+
+ return With {
+ SyncMetadata
+ .where {
+ $0.recordPrimaryKey.is(parentRecordPrimaryKey)
+ && $0.recordType.is(parentRecordType)
+ }
+ .select { RootShare.Columns(parentRecordName: $0.parentRecordName, share: $0.share) }
+ .union(
+ all: true,
+ SyncMetadata
+ .select {
+ RootShare.Columns(parentRecordName: $0.parentRecordName, share: $0.share)
+ }
+ .join(RootShare.all) { $0.recordName.is($1.parentRecordName) }
+ )
+ } query: {
+ RootShare
+ .select { _ in
+ #sql(
+ "RAISE(ABORT, \(quote: SyncEngine.writePermissionError, delimiter: .text))",
+ as: Never.self
+ )
+ }
+ .where {
+ !SyncEngine.isSynchronizingChanges()
+ && $0.parentRecordName.is(nil)
+ && !$hasPermission($0.share)
+ }
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ private func descendantRecordNames(
+ recordName: some QueryExpression,
+ includeSelf: Bool,
+ select: (Where) -> Select
+ ) -> some Statement {
+ With {
+ SyncMetadata
+ .where { $0.recordName.eq(recordName) }
+ .select {
+ DescendantMetadata.Columns(recordName: $0.recordName, parentRecordName: #bind(nil))
+ }
+ .union(
+ all: true,
+ SyncMetadata
+ .select {
+ DescendantMetadata.Columns(
+ recordName: $0.recordName,
+ parentRecordName: $0.parentRecordName
+ )
+ }
+ .join(DescendantMetadata.all) { $0.parentRecordName.eq($1.recordName) }
+ )
+ } query: {
+ select(
+ DescendantMetadata.where {
+ if !includeSelf {
+ $0.recordName.neq(recordName)
+ }
+ }
+ )
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ private func rootServerRecord(
+ recordName: some QueryExpression
+ ) -> some QueryExpression {
+ With {
+ SyncMetadata
+ .where { $0.recordName.eq(recordName) }
+ .select { AncestorMetadata.Columns($0) }
+ .union(
+ all: true,
+ SyncMetadata
+ .select { AncestorMetadata.Columns($0) }
+ .join(AncestorMetadata.all) { $0.recordName.is($1.parentRecordName) }
+ )
+ } query: {
+ AncestorMetadata
+ .select(\.lastKnownServerRecord)
+ .where { $0.parentRecordName.is(nil) }
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ private func parentLastKnownServerRecord(
+ parentRecordPrimaryKey: some QueryExpression,
+ parentRecordType: some QueryExpression
+ ) -> some QueryExpression {
+ SyncMetadata
+ .select(\.lastKnownServerRecord)
+ .where {
+ $0.recordPrimaryKey.is(parentRecordPrimaryKey)
+ && $0.recordType.is(parentRecordType)
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension AncestorMetadata.Columns {
+ init(_ metadata: SyncMetadata.TableColumns) {
+ self.init(
+ recordName: metadata.recordName,
+ parentRecordName: metadata.parentRecordName,
+ lastKnownServerRecord: metadata.lastKnownServerRecord
+ )
+ }
+ }
+
+ extension QueryExpression {
+ fileprivate var isValidCloudKitRecordName: some QueryExpression {
+ substr(1, 1).neq("_") && octetLength().lte(255) && octetLength().eq(length())
+ }
+ }
+
+ @Table @Selection
+ private struct DescendantMetadata {
+ let recordName: String
+ let parentRecordName: String?
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ @Table @Selection
+ private struct AncestorMetadata {
+ let recordName: String
+ let parentRecordName: String?
+ @Column(as: CKRecord?.SystemFieldsRepresentation.self)
+ let lastKnownServerRecord: CKRecord?
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ @Table @Selection
+ struct RecordWithRoot {
+ let parentRecordName: String?
+ let recordName: String
+ @Column(as: CKRecord?.SystemFieldsRepresentation.self)
+ let lastKnownServerRecord: CKRecord?
+ let rootRecordName: String
+ @Column(as: CKRecord?.SystemFieldsRepresentation.self)
+ let rootLastKnownServerRecord: CKRecord?
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ @Table @Selection
+ private struct RootShare {
+ let parentRecordName: String?
+ @Column(as: CKShare?.SystemFieldsRepresentation.self)
+ let share: CKShare?
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/UnsyncedRecordID.swift b/Sources/SQLiteData/CloudKit/Internal/UnsyncedRecordID.swift
new file mode 100644
index 00000000..f24a3e04
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/UnsyncedRecordID.swift
@@ -0,0 +1,51 @@
+#if canImport(CloudKit)
+ import CloudKit
+ import StructuredQueriesCore
+
+ @Table("sqlitedata_icloud_unsyncedRecordIDs")
+ package struct UnsyncedRecordID: Equatable {
+ package let recordName: String
+ package let zoneName: String
+ package let ownerName: String
+ }
+
+ extension UnsyncedRecordID {
+ package init(recordID: CKRecord.ID) {
+ recordName = recordID.recordName
+ zoneName = recordID.zoneID.zoneName
+ ownerName = recordID.zoneID.ownerName
+ }
+ package static func find(_ recordID: CKRecord.ID) -> Where {
+ Self.where {
+ $0.recordName.eq(recordID.recordName)
+ && $0.zoneName.eq(recordID.zoneID.zoneName)
+ && $0.ownerName.eq(recordID.zoneID.ownerName)
+ }
+ }
+ package static func findAll(_ recordIDs: some Collection) -> Where<
+ UnsyncedRecordID
+ > {
+ let condition: QueryFragment = recordIDs.map {
+ "(\(bind: $0.recordName), \(bind: $0.zoneID.zoneName), \(bind: $0.zoneID.ownerName))"
+ }
+ .joined(separator: ", ")
+ return Self.where {
+ #sql("(\($0.recordName), \($0.zoneName), \($0.ownerName)) IN (\(condition))")
+ }
+ }
+ }
+
+ extension CKRecord.ID {
+ convenience init(unsyncedRecordID: UnsyncedRecordID) {
+ self.init(
+ recordName: unsyncedRecordID.recordName,
+ zoneID:
+ CKRecordZone
+ .ID(
+ zoneName: unsyncedRecordID.zoneName,
+ ownerName: unsyncedRecordID.ownerName
+ )
+ )
+ }
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/Internal/_SendableMetatype.swift b/Sources/SQLiteData/CloudKit/Internal/_SendableMetatype.swift
new file mode 100644
index 00000000..64a0774e
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/Internal/_SendableMetatype.swift
@@ -0,0 +1,5 @@
+#if swift(>=6.2)
+ public typealias _SendableMetatype = SendableMetatype
+#else
+ public typealias _SendableMetatype = Any
+#endif
diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift
new file mode 100644
index 00000000..4f9a5d29
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift
@@ -0,0 +1,2135 @@
+#if canImport(CloudKit)
+ import CloudKit
+ import ConcurrencyExtras
+ import CustomDump
+ import Dependencies
+ import GRDB
+ import OrderedCollections
+ import OSLog
+ import Observation
+ import StructuredQueriesCore
+ import SwiftData
+
+ #if canImport(UIKit)
+ import UIKit
+ #endif
+
+ /// An object that manages the synchronization of local and remote SQLite data.
+ ///
+ /// See for more information.
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ public final class SyncEngine: Observable, Sendable {
+ package let userDatabase: UserDatabase
+ package let logger: Logger
+ package let metadatabase: any DatabaseWriter
+ package let tables: [any (PrimaryKeyedTable & _SendableMetatype).Type]
+ package let privateTables: [any (PrimaryKeyedTable & _SendableMetatype).Type]
+ let tablesByName: [String: any (PrimaryKeyedTable & _SendableMetatype).Type]
+ private let tablesByOrder: [String: Int]
+ let foreignKeysByTableName: [String: [ForeignKey]]
+ package let syncEngines = LockIsolated(SyncEngines())
+ package let defaultZone: CKRecordZone
+ let defaultSyncEngines:
+ @Sendable (any DatabaseReader, SyncEngine)
+ -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol)
+ package let container: any CloudContainer
+ let dataManager = Dependency(\.dataManager)
+ private let observationRegistrar = ObservationRegistrar()
+ private let notificationsObserver = LockIsolated<(any NSObjectProtocol)?>(nil)
+
+ /// The error message used when a write occurs to a record for which the current user
+ /// does not have permission.
+ ///
+ /// This error is thrown from any database write to a row for which the current user does
+ /// not have permissions to write, as determined by its `CKShare` (if applicable). To catch
+ /// this error try casting it to `DatabaseError` and checking its message:
+ ///
+ /// ```swift
+ /// do {
+ /// try await database.write { db in
+ /// Reminder.find(id)
+ /// .update { $0.title = "Personal" }
+ /// .execute(db)
+ /// }
+ /// } catch let error as DatabaseError where error.message == SyncEngine.writePermissionError {
+ /// // User does not have permission to write to this record.
+ /// }
+ /// ```
+ public static let writePermissionError =
+ "co.pointfree.SQLiteData.CloudKit.write-permission-error"
+ public static let invalidRecordNameError =
+ "co.pointfree.SQLiteData.CloudKit.invalid-record-name-error"
+
+ /// Initialize a sync engine.
+ ///
+ /// - Parameters:
+ /// - database: The database to synchronize to CloudKit.
+ /// - tables: A list of tables that you want to synchronize _and_ that you want to be
+ /// shareable with other users on CloudKit.
+ /// - privateTables: A list of tables that you want to synchronize to CloudKit but that
+ /// you do not want to be shareable with other users.
+ /// - containerIdentifier: The container identifier in CloudKit to synchronize to. If omitted
+ /// the container will be determined from the entitlements of your app.
+ /// - defaultZone: The zone for all records to be stored in.
+ /// - startImmediately: Determines if the sync engine starts right away or requires an
+ /// explicit call to ``stop()``. By default this argument is `true`.
+ /// - logger: The logger used to log events in the sync engine. By default a `.disabled`
+ /// logger is used, which means logs are not printed.
+ public convenience init<
+ each T1: PrimaryKeyedTable & _SendableMetatype,
+ each T2: PrimaryKeyedTable & _SendableMetatype
+ >(
+ for database: any DatabaseWriter,
+ tables: repeat (each T1).Type,
+ privateTables: repeat (each T2).Type,
+ containerIdentifier: String? = nil,
+ defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"),
+ startImmediately: Bool = DependencyValues._current.context == .live,
+ logger: Logger = isTesting
+ ? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit")
+ ) throws
+ where
+ repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible,
+ repeat (each T2).PrimaryKey.QueryOutput: IdentifierStringConvertible
+ {
+ let containerIdentifier =
+ containerIdentifier
+ ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier
+
+ var allTables: [any (PrimaryKeyedTable & _SendableMetatype).Type] = []
+ var allPrivateTables: [any (PrimaryKeyedTable & _SendableMetatype).Type] = []
+ for table in repeat each tables {
+ allTables.append(table)
+ }
+ for privateTable in repeat each privateTables {
+ allPrivateTables.append(privateTable)
+ }
+ let userDatabase = UserDatabase(database: database)
+
+ guard !isTesting
+ else {
+ let privateDatabase = MockCloudDatabase(databaseScope: .private)
+ let sharedDatabase = MockCloudDatabase(databaseScope: .shared)
+ try self.init(
+ container: MockCloudContainer(
+ containerIdentifier: containerIdentifier ?? "iCloud.co.pointfree.SQLiteData.Tests",
+ privateCloudDatabase: privateDatabase,
+ sharedCloudDatabase: sharedDatabase
+ ),
+ defaultZone: defaultZone,
+ defaultSyncEngines: { _, syncEngine in
+ (
+ private: MockSyncEngine(
+ database: privateDatabase,
+ parentSyncEngine: syncEngine,
+ state: MockSyncEngineState()
+ ),
+ shared: MockSyncEngine(
+ database: sharedDatabase,
+ parentSyncEngine: syncEngine,
+ state: MockSyncEngineState()
+ )
+ )
+ },
+ userDatabase: userDatabase,
+ logger: logger,
+ tables: allTables,
+ privateTables: allPrivateTables
+ )
+ try setUpSyncEngine()
+ if startImmediately {
+ _ = try start()
+ }
+ return
+ }
+
+ guard let containerIdentifier else {
+ throw SchemaError(
+ reason: .noCloudKitContainer,
+ debugDescription: """
+ No default CloudKit container found. Please add a container identifier to your app's \
+ entitlements.
+ """
+ )
+ }
+
+ let container = CKContainer(identifier: containerIdentifier)
+ try self.init(
+ container: container,
+ defaultZone: defaultZone,
+ defaultSyncEngines: { metadatabase, syncEngine in
+ (
+ private: CKSyncEngine(
+ CKSyncEngine.Configuration(
+ database: container.privateCloudDatabase,
+ stateSerialization: try? metadatabase.read { db in
+ try StateSerialization
+ .find(#bind(.private))
+ .select(\.data)
+ .fetchOne(db)
+ },
+ delegate: syncEngine
+ )
+ ),
+ shared: CKSyncEngine(
+ CKSyncEngine.Configuration(
+ database: container.sharedCloudDatabase,
+ stateSerialization: try? metadatabase.read { db in
+ try StateSerialization
+ .find(#bind(.shared))
+ .select(\.data)
+ .fetchOne(db)
+ },
+ delegate: syncEngine
+ )
+ )
+ )
+ },
+ userDatabase: userDatabase,
+ logger: logger,
+ tables: allTables,
+ privateTables: allPrivateTables
+ )
+ try setUpSyncEngine()
+ if startImmediately {
+ _ = try start()
+ }
+ }
+
+ package init(
+ container: any CloudContainer,
+ defaultZone: CKRecordZone,
+ defaultSyncEngines:
+ @escaping @Sendable (
+ any DatabaseReader,
+ SyncEngine
+ ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol),
+ userDatabase: UserDatabase,
+ logger: Logger,
+ tables: [any (PrimaryKeyedTable & _SendableMetatype).Type],
+ privateTables: [any (PrimaryKeyedTable & _SendableMetatype).Type] = []
+ ) throws {
+ let allTables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init))
+ .map(\.type)
+ self.tables = allTables
+ self.privateTables = privateTables
+
+ let foreignKeysByTableName = Dictionary(
+ uniqueKeysWithValues: try userDatabase.read { db in
+ try allTables.map { table -> (String, [ForeignKey]) in
+ func open(_: T.Type) throws -> (String, [ForeignKey]) {
+ (
+ table.tableName,
+ try PragmaForeignKeyList
+ .join(PragmaTableInfo.all) { $0.from.eq($1.name) }
+ .select {
+ ForeignKey.Columns(
+ table: $0.table,
+ from: $0.from,
+ to: $0.to,
+ onUpdate: $0.onUpdate,
+ onDelete: $0.onDelete,
+ isNotNull: $1.isNotNull
+ )
+ }
+ .fetchAll(db)
+ )
+ }
+ return try open(table)
+ }
+ }
+ )
+ self.container = container
+ self.defaultZone = defaultZone
+ self.defaultSyncEngines = defaultSyncEngines
+ self.userDatabase = userDatabase
+ self.logger = logger
+ self.metadatabase = try defaultMetadatabase(
+ logger: logger,
+ url: try URL.metadatabase(
+ databasePath: userDatabase.path,
+ containerIdentifier: container.containerIdentifier
+ )
+ )
+ self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) })
+ self.foreignKeysByTableName = foreignKeysByTableName
+ tablesByOrder = try SQLiteData.tablesByOrder(
+ userDatabase: userDatabase,
+ tables: allTables,
+ tablesByName: tablesByName
+ )
+ #if os(iOS)
+ @Dependency(\.defaultNotificationCenter) var defaultNotificationCenter
+ notificationsObserver.withValue {
+ $0 = defaultNotificationCenter.addObserver(
+ forName: UIApplication.willResignActiveNotification,
+ object: nil,
+ queue: nil
+ ) { [syncEngines] _ in
+ Task { @MainActor in
+ let taskIdentifier = UIApplication.shared.beginBackgroundTask()
+ defer { UIApplication.shared.endBackgroundTask(taskIdentifier) }
+ let (privateSyncEngine, sharedSyncEngine) = syncEngines.withValue {
+ ($0.private, $0.shared)
+ }
+ try await privateSyncEngine?.sendChanges(CKSyncEngine.SendChangesOptions())
+ try await sharedSyncEngine?.sendChanges(CKSyncEngine.SendChangesOptions())
+ }
+ }
+ }
+ #endif
+ try validateSchema()
+ }
+
+ deinit {
+ notificationsObserver.withValue {
+ guard let observer = $0
+ else { return }
+ NotificationCenter.default.removeObserver(observer)
+ }
+ }
+
+ nonisolated package func setUpSyncEngine() throws {
+ try userDatabase.write { db in
+ try setUpSyncEngine(writableDB: db)
+ }
+ }
+
+ nonisolated package func setUpSyncEngine(writableDB db: Database) throws {
+ let attachedMetadatabasePath: String? =
+ try PragmaDatabaseList
+ .where { $0.name.eq(String.sqliteDataCloudKitSchemaName) }
+ .select(\.file)
+ .fetchOne(db)
+ if let attachedMetadatabasePath {
+ let attachedMetadatabaseName = URL(filePath: metadatabase.path).lastPathComponent
+ let metadatabaseName = URL(filePath: attachedMetadatabasePath).lastPathComponent
+ if attachedMetadatabaseName != metadatabaseName {
+ throw SchemaError(
+ reason: .metadatabaseMismatch(
+ attachedPath: attachedMetadatabasePath,
+ syncEngineConfiguredPath: metadatabase.path
+ ),
+ debugDescription: """
+ Metadatabase attached in 'prepareDatabase' does not match metadatabase prepared in \
+ 'SyncEngine.init'. Are different CloudKit container identifiers being provided?
+ """
+ )
+ }
+
+ } else {
+ try #sql(
+ """
+ ATTACH DATABASE \(bind: metadatabase.path) AS \(quote: .sqliteDataCloudKitSchemaName)
+ """
+ )
+ .execute(db)
+ }
+ db.add(function: $currentTime)
+ db.add(function: $syncEngineIsSynchronizingChanges)
+ db.add(function: $didUpdate)
+ db.add(function: $didDelete)
+ db.add(function: $hasPermission)
+ db.add(function: $currentZoneName)
+ db.add(function: $currentOwnerName)
+
+ for trigger in SyncMetadata.callbackTriggers(for: self) {
+ try trigger.execute(db)
+ }
+
+ for table in tables {
+ try table.createTriggers(
+ foreignKeysByTableName: foreignKeysByTableName,
+ tablesByName: tablesByName,
+ defaultZone: defaultZone,
+ db: db
+ )
+ }
+ }
+
+ /// Starts the sync engine if it is stopped.
+ ///
+ /// When a sync engine is started it will upload all data stored locally that has not yet
+ /// been synchronized to CloudKit, and will download all changes from CloudKit since the
+ /// last time it synchronized.
+ ///
+ /// > Note: By default, sync engines start syncing when initialized.
+ public func start() async throws {
+ try await start().value
+ }
+
+ /// Stops the sync engine if it is running.
+ ///
+ /// All edits made after stopping the sync engine will not be synchronized to CloudKit.
+ /// You must start the sync engine again using ``start()`` to synchronize the changes.
+ public func stop() {
+ guard isRunning else { return }
+ observationRegistrar.withMutation(of: self, keyPath: \.isRunning) {
+ syncEngines.withValue {
+ $0 = SyncEngines()
+ }
+ }
+ }
+
+ /// Determines if the sync engine is currently running or not.
+ public var isRunning: Bool {
+ observationRegistrar.access(self, keyPath: \.isRunning)
+ return syncEngines.withValue {
+ $0.isRunning
+ }
+ }
+
+ private func start() throws -> Task {
+ guard !isRunning else { return Task {} }
+ observationRegistrar.withMutation(of: self, keyPath: \.isRunning) {
+ syncEngines.withValue {
+ let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self)
+ $0 = SyncEngines(
+ private: privateSyncEngine,
+ shared: sharedSyncEngine
+ )
+ }
+ }
+
+ let previousRecordTypes = try metadatabase.read { db in
+ try RecordType.all.fetchAll(db)
+ }
+ let currentRecordTypes = try userDatabase.read { db in
+ let namesAndSchemas =
+ try SQLiteSchema
+ .where {
+ $0.type.eq(#bind(.table))
+ && $0.tableName.in(tables.map { $0.tableName })
+ }
+ .fetchAll(db)
+ return try namesAndSchemas.compactMap { schema -> RecordType? in
+ guard let sql = schema.sql, let table = tablesByName[schema.name]
+ else { return nil }
+ func open(_: T.Type) throws -> RecordType {
+ try RecordType(
+ tableName: schema.name,
+ schema: sql,
+ tableInfo: Set(
+ PragmaTableInfo
+ .select {
+ TableInfo.Columns(
+ defaultValue: $0.defaultValue,
+ isPrimaryKey: $0.isPrimaryKey,
+ name: $0.name,
+ isNotNull: $0.isNotNull,
+ type: $0.type
+ )
+ }
+ .fetchAll(db)
+ )
+ )
+ }
+ return try open(table)
+ }
+ }
+ let previousRecordTypeByTableName = Dictionary(
+ uniqueKeysWithValues: previousRecordTypes.map {
+ ($0.tableName, $0)
+ }
+ )
+ let currentRecordTypeByTableName = Dictionary(
+ uniqueKeysWithValues: currentRecordTypes.map {
+ ($0.tableName, $0)
+ }
+ )
+ return Task {
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ guard try await container.accountStatus() == .available
+ else { return }
+ try await uploadRecordsToCloudKit(
+ previousRecordTypeByTableName: previousRecordTypeByTableName,
+ currentRecordTypeByTableName: currentRecordTypeByTableName
+ )
+ try await updateLocalFromSchemaChange(
+ previousRecordTypeByTableName: previousRecordTypeByTableName,
+ currentRecordTypeByTableName: currentRecordTypeByTableName
+ )
+ try await cacheUserTables(recordTypes: currentRecordTypes)
+ }
+ }
+ }
+
+ private func cacheUserTables(recordTypes: [RecordType]) async throws {
+ try await userDatabase.write { db in
+ try RecordType
+ .upsert { recordTypes.map { RecordType.Draft($0) } }
+ .execute(db)
+ }
+ }
+
+ private func uploadRecordsToCloudKit(
+ previousRecordTypeByTableName: [String: RecordType],
+ currentRecordTypeByTableName: [String: RecordType]
+ ) async throws {
+ try await enqueueLocallyPendingChanges()
+ try await userDatabase.write { db in
+ try PendingRecordZoneChange.delete().execute(db)
+
+ let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in
+ previousRecordTypeByTableName[tableName] == nil
+ }
+
+ try $_isSynchronizingChanges.withValue(false) {
+ for tableName in newTableNames {
+ try self.uploadRecordsToCloudKit(tableName: tableName, db: db)
+ }
+ }
+ }
+ }
+
+ private func enqueueLocallyPendingChanges() async throws {
+ let pendingRecordZoneChanges = try await metadatabase.read { db in
+ try PendingRecordZoneChange
+ .select(\.pendingRecordZoneChange)
+ .fetchAll(db)
+ }
+ let changesByIsPrivate = Dictionary(grouping: pendingRecordZoneChanges) {
+ switch $0 {
+ case .deleteRecord(let recordID), .saveRecord(let recordID):
+ recordID.zoneID.ownerName == CKCurrentUserDefaultName
+ @unknown default:
+ false
+ }
+ }
+ syncEngines.withValue {
+ $0.private?.state.add(pendingRecordZoneChanges: changesByIsPrivate[true] ?? [])
+ $0.shared?.state.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? [])
+ }
+ }
+
+ private func enqueueUnknownRecordsForCloudKit() async throws {
+ try await userDatabase.write { db in
+ try $_isSynchronizingChanges.withValue(false) {
+ try SyncMetadata
+ .where { !$0.hasLastKnownServerRecord }
+ .update { $0.recordPrimaryKey = $0.recordPrimaryKey }
+ .execute(db)
+ }
+ }
+ }
+
+ private func uploadRecordsToCloudKit(table: T.Type, db: Database) throws {
+ try T.update { $0.primaryKey = $0.primaryKey }.execute(db)
+ }
+
+ private func uploadRecordsToCloudKit(tableName: String, db: Database) throws {
+ guard let table = self.tablesByName[tableName]
+ else { return }
+ func open(_: T.Type) throws {
+ try uploadRecordsToCloudKit(table: T.self, db: db)
+ }
+ try open(table)
+ }
+
+ private func updateLocalFromSchemaChange(
+ previousRecordTypeByTableName: [String: RecordType],
+ currentRecordTypeByTableName: [String: RecordType]
+ ) async throws {
+ let tablesWithChangedSchemas = currentRecordTypeByTableName.filter { tableName, recordType in
+ previousRecordTypeByTableName[tableName]?.schema != recordType.schema
+ }
+
+ for (tableName, currentRecordType) in tablesWithChangedSchemas {
+ guard let table = tablesByName[tableName]
+ else { continue }
+ func open(_: T.Type) async throws {
+ let previousRecordType = previousRecordTypeByTableName[tableName]
+ let changedColumns = currentRecordType.tableInfo.subtracting(
+ previousRecordType?.tableInfo ?? []
+ )
+ .map(\.name)
+ let lastKnownServerRecords = try await metadatabase.read { db in
+ try SyncMetadata
+ .where { $0.recordType.eq(tableName) }
+ .select(\._lastKnownServerRecordAllFields)
+ .fetchAll(db)
+ }
+ for case .some(let lastKnownServerRecord) in lastKnownServerRecords {
+ let query = try await updateQuery(
+ for: T.self,
+ record: lastKnownServerRecord,
+ columnNames: T.TableColumns.writableColumns.map(\.name),
+ changedColumnNames: changedColumns
+ )
+ try await userDatabase.write { db in
+ try #sql(query).execute(db)
+ }
+ }
+ }
+ try await open(table)
+ }
+ }
+
+ package func tearDownSyncEngine() throws {
+ try userDatabase.write { db in
+ for table in tables.reversed() {
+ try table.dropTriggers(defaultZone: defaultZone, db: db)
+ }
+ for trigger in SyncMetadata.callbackTriggers(for: self).reversed() {
+ try trigger.drop().execute(db)
+ }
+ }
+ try metadatabase.erase()
+ try migrate(metadatabase: metadatabase)
+ }
+
+ func deleteLocalData() async throws {
+ stop()
+ try tearDownSyncEngine()
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await userDatabase.write { db in
+ for table in tables {
+ func open(_: T.Type) {
+ withErrorReporting(.sqliteDataCloudKitFailure) {
+ try T.delete().execute(db)
+ }
+ }
+ open(table)
+ }
+ try setUpSyncEngine(writableDB: db)
+ }
+ }
+ try await start()
+ }
+
+ @DatabaseFunction(
+ "sqlitedata_icloud_didUpdate",
+ as: ((
+ String,
+ String,
+ String,
+ String,
+ String,
+ [String]?.JSONRepresentation
+ ) -> Void).self
+ )
+ func didUpdate(
+ recordName: String,
+ zoneName: String,
+ ownerName: String,
+ oldZoneName: String,
+ oldOwnerName: String,
+ descendantRecordNames: [String]?
+ ) {
+ var oldChanges: [CKSyncEngine.PendingRecordZoneChange] = []
+ var newChanges: [CKSyncEngine.PendingRecordZoneChange] = []
+
+ let oldZoneID = CKRecordZone.ID(zoneName: oldZoneName, ownerName: oldOwnerName)
+ let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
+
+ if oldZoneID != zoneID {
+ oldChanges.append(.deleteRecord(CKRecord.ID(recordName: recordName, zoneID: oldZoneID)))
+ for descendantRecordName in descendantRecordNames ?? [] {
+ oldChanges.append(
+ .deleteRecord(CKRecord.ID(recordName: descendantRecordName, zoneID: oldZoneID))
+ )
+ }
+ newChanges.append(.saveRecord(CKRecord.ID(recordName: recordName, zoneID: zoneID)))
+ for descendantRecordName in descendantRecordNames ?? [] {
+ newChanges.append(
+ .saveRecord(CKRecord.ID(recordName: descendantRecordName, zoneID: zoneID))
+ )
+ }
+ } else {
+ newChanges.append(
+ .saveRecord(CKRecord.ID(recordName: recordName, zoneID: zoneID))
+ )
+ }
+
+ guard isRunning else {
+ // TODO: Perform this work in a trigger instead of a task.
+ Task { [changes = oldChanges + newChanges] in
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await userDatabase.write { db in
+ try PendingRecordZoneChange
+ .insert {
+ for change in changes {
+ PendingRecordZoneChange(change)
+ }
+ }
+ .execute(db)
+ }
+ }
+ }
+ return
+ }
+ let oldSyncEngine = self.syncEngines.withValue {
+ oldZoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared
+ }
+ let syncEngine = self.syncEngines.withValue {
+ zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared
+ }
+ oldSyncEngine?.state.add(pendingRecordZoneChanges: oldChanges)
+ syncEngine?.state.add(pendingRecordZoneChanges: newChanges)
+ }
+
+ @DatabaseFunction(
+ "sqlitedata_icloud_didDelete",
+ as: ((String, CKRecord?.SystemFieldsRepresentation, CKShare?.SystemFieldsRepresentation)
+ -> Void).self
+ )
+ func didDelete(recordName: String, record: CKRecord?, share: CKShare?) {
+ let zoneID = record?.recordID.zoneID ?? defaultZone.zoneID
+ var changes: [CKSyncEngine.PendingRecordZoneChange] = [
+ .deleteRecord(
+ CKRecord.ID(
+ recordName: recordName,
+ zoneID: zoneID
+ )
+ )
+ ]
+ if let share {
+ changes.append(.deleteRecord(share.recordID))
+ }
+ guard isRunning else {
+ Task { [changes] in
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await userDatabase.write { db in
+ try PendingRecordZoneChange
+ .insert { changes.map { PendingRecordZoneChange($0) } }
+ .execute(db)
+ }
+ }
+ }
+ return
+ }
+
+ let syncEngine = self.syncEngines.withValue {
+ zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared
+ }
+ syncEngine?.state.add(pendingRecordZoneChanges: changes)
+ }
+
+ package func acceptShare(metadata: ShareMetadata) async throws {
+ guard let rootRecordID = metadata.hierarchicalRootRecordID
+ else {
+ reportIssue("Attempting to share without root record information.")
+ return
+ }
+ let container = type(of: container).createContainer(identifier: metadata.containerIdentifier)
+ _ = try await container.accept(metadata)
+ try await syncEngines.shared?.fetchChanges(
+ CKSyncEngine.FetchChangesOptions(
+ scope: .zoneIDs([rootRecordID.zoneID]),
+ operationGroup: nil
+ )
+ )
+ }
+
+ /// A query expression that can be used in SQL queries to determine if the ``SyncEngine``
+ /// is currently writing changes to the database.
+ ///
+ /// See for more info.
+ public static func isSynchronizingChanges() -> some QueryExpression {
+ $syncEngineIsSynchronizingChanges()
+ }
+ }
+
+ extension PrimaryKeyedTable {
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ fileprivate static func createTriggers(
+ foreignKeysByTableName: [String: [ForeignKey]],
+ tablesByName: [String: any PrimaryKeyedTable.Type],
+ defaultZone: CKRecordZone,
+ db: Database
+ ) throws {
+ let parentForeignKey =
+ foreignKeysByTableName[tableName]?.count == 1
+ ? foreignKeysByTableName[tableName]?.first
+ : nil
+
+ for trigger in metadataTriggers(parentForeignKey: parentForeignKey, defaultZone: defaultZone)
+ {
+ try trigger.execute(db)
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ fileprivate static func dropTriggers(defaultZone: CKRecordZone, db: Database) throws {
+ for trigger in metadataTriggers(parentForeignKey: nil, defaultZone: defaultZone).reversed() {
+ try trigger.drop().execute(db)
+ }
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate {
+ public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
+ guard let event = Event(event)
+ else {
+ reportIssue("Unrecognized event received: \(event)")
+ return
+ }
+ await handleEvent(event, syncEngine: syncEngine)
+ }
+
+ package func handleEvent(_ event: Event, syncEngine: any SyncEngineProtocol) async {
+ logger.log(event, syncEngine: syncEngine)
+
+ switch event {
+ case .accountChange(let changeType):
+ await handleAccountChange(changeType: changeType, syncEngine: syncEngine)
+ case .stateUpdate(let stateSerialization):
+ await handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine)
+ case .fetchedDatabaseChanges(let modifications, let deletions):
+ await handleFetchedDatabaseChanges(
+ modifications: modifications,
+ deletions: deletions,
+ syncEngine: syncEngine
+ )
+ case .sentDatabaseChanges:
+ break
+ case .fetchedRecordZoneChanges(let modifications, let deletions):
+ await handleFetchedRecordZoneChanges(
+ modifications: modifications,
+ deletions: deletions,
+ syncEngine: syncEngine
+ )
+ case .sentRecordZoneChanges(
+ let savedRecords,
+ let failedRecordSaves,
+ let deletedRecordIDs,
+ let failedRecordDeletes
+ ):
+ await handleSentRecordZoneChanges(
+ savedRecords: savedRecords,
+ failedRecordSaves: failedRecordSaves,
+ deletedRecordIDs: deletedRecordIDs,
+ failedRecordDeletes: failedRecordDeletes,
+ syncEngine: syncEngine
+ )
+ case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges,
+ .didFetchChanges, .willSendChanges, .didSendChanges:
+ break
+ @unknown default:
+ break
+ }
+ }
+
+ public func nextRecordZoneChangeBatch(
+ _ context: CKSyncEngine.SendChangesContext,
+ syncEngine: CKSyncEngine
+ ) async -> CKSyncEngine.RecordZoneChangeBatch? {
+ await nextRecordZoneChangeBatch(
+ reason: context.reason,
+ options: context.options,
+ syncEngine: syncEngine
+ )
+ }
+
+ package func nextRecordZoneChangeBatch(
+ reason: CKSyncEngine.SyncReason = .scheduled,
+ options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(scope: .all),
+ syncEngine: any SyncEngineProtocol
+ ) async -> CKSyncEngine.RecordZoneChangeBatch? {
+ var changes = await pendingRecordZoneChanges(options: options, syncEngine: syncEngine)
+ guard !changes.isEmpty
+ else { return nil }
+
+ changes.sort { lhs, rhs in
+ switch (lhs, rhs) {
+ case (.saveRecord(let lhs), .saveRecord(let rhs)):
+ guard
+ let lhsRecordType = lhs.tableName,
+ let lhsIndex = tablesByOrder[lhsRecordType],
+ let rhsRecordType = rhs.tableName,
+ let rhsIndex = tablesByOrder[rhsRecordType]
+ else { return true }
+ return lhsIndex < rhsIndex
+ case (.deleteRecord(let lhs), .deleteRecord(let rhs)):
+ guard
+ let lhsRecordType = lhs.tableName,
+ let lhsIndex = tablesByOrder[lhsRecordType],
+ let rhsRecordType = rhs.tableName,
+ let rhsIndex = tablesByOrder[rhsRecordType]
+ else { return true }
+ return lhsIndex > rhsIndex
+ case (.saveRecord, .deleteRecord):
+ return false
+ case (.deleteRecord, .saveRecord):
+ return true
+ default:
+ return true
+ }
+ }
+
+ #if DEBUG
+ struct State {
+ var missingTables: [CKRecord.ID] = []
+ var missingRecords: [CKRecord.ID] = []
+ var sentRecords: [CKRecord.ID] = []
+ }
+ let state = LockIsolated(State())
+ defer {
+ let state = state.withValue(\.self)
+ let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName)
+ .reduce(into: [String]()) {
+ strings,
+ keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"]
+ }
+ .joined(separator: ", ")
+ let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName)
+ .reduce(into: [String]()) {
+ strings,
+ keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"]
+ }
+ .joined(separator: ", ")
+ let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName)
+ .reduce(into: [String]()) {
+ strings,
+ keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"]
+ }
+ .joined(separator: ", ")
+ logger.debug(
+ """
+ [\(syncEngine.database.databaseScope.label)] nextRecordZoneChangeBatch: \(reason)
+ \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)")
+ \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)")
+ \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)")
+ """
+ )
+ }
+ #endif
+
+ let batch = await syncEngine.recordZoneChangeBatch(pendingChanges: changes) { recordID in
+ var missingTable: CKRecord.ID?
+ var missingRecord: CKRecord.ID?
+ var sentRecord: CKRecord.ID?
+ #if DEBUG
+ defer {
+ state.withValue { [missingTable, missingRecord, sentRecord] in
+ if let missingTable { $0.missingTables.append(missingTable) }
+ if let missingRecord { $0.missingRecords.append(missingRecord) }
+ if let sentRecord { $0.sentRecords.append(sentRecord) }
+ }
+ }
+ #endif
+
+ guard
+ let (metadata, allFields) = await withErrorReporting(
+ .sqliteDataCloudKitFailure,
+ catching: {
+ try await metadatabase.read { db in
+ try SyncMetadata
+ .find(recordID)
+ .select { ($0, $0._lastKnownServerRecordAllFields) }
+ .fetchOne(db)
+ }
+ }
+ )
+ ?? nil
+ else {
+ syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
+ return nil
+ }
+ guard let table = tablesByName[metadata.recordType]
+ else {
+ syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
+ missingTable = recordID
+ return nil
+ }
+ func open(_: T.Type) async -> CKRecord? {
+ let row =
+ withErrorReporting(.sqliteDataCloudKitFailure) {
+ try userDatabase.read { db in
+ try T
+ .where {
+ #sql("\($0.primaryKey) = \(bind: metadata.recordPrimaryKey)")
+ }
+ .fetchOne(db)
+ }
+ }
+ ?? nil
+ guard let row
+ else {
+ syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
+ missingRecord = recordID
+ return nil
+ }
+
+ let record =
+ allFields
+ ?? CKRecord(
+ recordType: metadata.recordType,
+ recordID: recordID
+ )
+ if let parentRecordName = metadata.parentRecordName,
+ let parentRecordType = metadata.parentRecordType,
+ !privateTables.contains(where: { $0.tableName == parentRecordType })
+ {
+ record.parent = CKRecord.Reference(
+ recordID: CKRecord.ID(
+ recordName: parentRecordName,
+ zoneID: recordID.zoneID
+ ),
+ action: .none
+ )
+ } else {
+ record.parent = nil
+ }
+
+ record.update(
+ with: T(queryOutput: row),
+ userModificationTime: metadata.userModificationTime
+ )
+ await refreshLastKnownServerRecord(record)
+ sentRecord = recordID
+ return record
+ }
+ return await open(table)
+ }
+ return batch
+ }
+
+ private func pendingRecordZoneChanges(
+ options: CKSyncEngine.SendChangesOptions,
+ syncEngine: any SyncEngineProtocol
+ ) async -> [CKSyncEngine.PendingRecordZoneChange] {
+ var changes = syncEngine.state.pendingRecordZoneChanges.filter(options.scope.contains)
+ guard !changes.isEmpty
+ else { return [] }
+
+ let deletedRecordIDs: [CKRecord.ID] = changes.compactMap {
+ switch $0 {
+ case .saveRecord(_):
+ return nil
+ case .deleteRecord(let recordID):
+ return recordID
+ @unknown default:
+ return nil
+ }
+ }
+
+ let (sharesToDelete, recordsWithRoot):
+ ([CKShare?], [(lastKnownServerRecord: CKRecord?, rootLastKnownServerRecord: CKRecord?)]) =
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ guard !deletedRecordIDs.isEmpty
+ else { return ([], []) }
+
+ return try await metadatabase.read { db in
+ let sharesToDelete =
+ try SyncMetadata
+ .findAll(deletedRecordIDs)
+ .where(\.isShared)
+ .select(\.share)
+ .fetchAll(db)
+
+ let recordsWithRoot =
+ try With {
+ SyncMetadata
+ .findAll(deletedRecordIDs)
+ .where { $0.parentRecordName.is(nil) }
+ .select {
+ RecordWithRoot.Columns(
+ parentRecordName: $0.parentRecordName,
+ recordName: $0.recordName,
+ lastKnownServerRecord: $0.lastKnownServerRecord,
+ rootRecordName: $0.recordName,
+ rootLastKnownServerRecord: $0.lastKnownServerRecord
+ )
+ }
+ .union(
+ all: true,
+ SyncMetadata
+ .join(RecordWithRoot.all) { $1.recordName.is($0.parentRecordName) }
+ .select { metadata, tree in
+ RecordWithRoot.Columns(
+ parentRecordName: metadata.parentRecordName,
+ recordName: metadata.recordName,
+ lastKnownServerRecord: metadata.lastKnownServerRecord,
+ rootRecordName: tree.rootRecordName,
+ rootLastKnownServerRecord: tree.lastKnownServerRecord
+ )
+ }
+ )
+ } query: {
+ RecordWithRoot
+ .select { ($0.lastKnownServerRecord, $0.rootLastKnownServerRecord) }
+ }
+ .fetchAll(db)
+
+ return (sharesToDelete, recordsWithRoot)
+ }
+ }
+ ?? ([], [])
+
+ let shareRecordIDsToDelete = sharesToDelete.compactMap(\.?.recordID)
+
+ for recordWithRoot in recordsWithRoot {
+ guard
+ let lastKnownServerRecord = recordWithRoot.lastKnownServerRecord,
+ let rootLastKnownServerRecord = recordWithRoot.rootLastKnownServerRecord
+ else { continue }
+ guard let rootShareRecordID = rootLastKnownServerRecord.share?.recordID
+ else { continue }
+ guard shareRecordIDsToDelete.contains(rootShareRecordID)
+ else { continue }
+ changes.removeAll(where: { $0 == .deleteRecord(lastKnownServerRecord.recordID) })
+ syncEngine.state.remove(
+ pendingRecordZoneChanges: [.deleteRecord(lastKnownServerRecord.recordID)]
+ )
+ }
+
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ guard !deletedRecordIDs.isEmpty
+ else { return }
+ try await userDatabase.write { db in
+ try SyncMetadata
+ .findAll(deletedRecordIDs)
+ .delete()
+ .execute(db)
+ }
+ }
+
+ return changes
+ }
+
+ package func handleAccountChange(
+ changeType: CKSyncEngine.Event.AccountChange.ChangeType,
+ syncEngine: any SyncEngineProtocol
+ ) async {
+ guard syncEngine === syncEngines.private
+ else { return }
+
+ switch changeType {
+ case .signIn:
+ syncEngine.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)])
+ await withErrorReporting {
+ try await enqueueUnknownRecordsForCloudKit()
+ }
+ case .signOut, .switchAccounts:
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await deleteLocalData()
+ }
+ @unknown default:
+ break
+ }
+ }
+
+ package func handleStateUpdate(
+ stateSerialization: CKSyncEngine.State.Serialization,
+ syncEngine: any SyncEngineProtocol
+ ) async {
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await userDatabase.write { db in
+ try StateSerialization.upsert {
+ StateSerialization.Draft(
+ scope: syncEngine.database.databaseScope,
+ data: stateSerialization
+ )
+ }
+ .execute(db)
+ }
+ }
+ }
+
+ package func handleFetchedDatabaseChanges(
+ modifications: [CKRecordZone.ID],
+ deletions: [(zoneID: CKRecordZone.ID, reason: CKDatabase.DatabaseChange.Deletion.Reason)],
+ syncEngine: any SyncEngineProtocol
+ ) async {
+ let defaultZoneDeleted =
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await userDatabase.write { db in
+ var defaultZoneDeleted = false
+ for (zoneID, reason) in deletions {
+ switch reason {
+ case .deleted, .purged:
+ try deleteRecords(in: zoneID, db: db)
+ if zoneID == self.defaultZone.zoneID {
+ defaultZoneDeleted = true
+ }
+ case .encryptedDataReset:
+ try uploadRecords(in: zoneID, db: db)
+ @unknown default:
+ reportIssue("Unknown deletion reason: \(reason)")
+ }
+ }
+ return defaultZoneDeleted
+ }
+ }
+ ?? false
+ if defaultZoneDeleted {
+ syncEngine.state.add(pendingDatabaseChanges: [.saveZone(self.defaultZone)])
+ }
+ @Sendable
+ func deleteRecords(in zoneID: CKRecordZone.ID, db: Database) throws {
+ let recordTypes = Dictionary(
+ grouping:
+ try SyncMetadata
+ .where { $0.zoneName.eq(zoneID.zoneName) && $0.ownerName.eq(zoneID.ownerName) }
+ .select { ($0.recordType, $0.recordPrimaryKey) }
+ .fetchAll(db),
+ by: \.0
+ )
+ .mapValues {
+ $0.map(\.1)
+ }
+ for (recordType, primaryKeys) in recordTypes {
+ guard let table = tablesByName[recordType]
+ else { continue }
+ func open(_: T.Type) {
+ withErrorReporting(.sqliteDataCloudKitFailure) {
+ try T.where { #sql("\($0.primaryKey)").in(primaryKeys) }.delete().execute(db)
+ }
+ }
+ open(table)
+ }
+ }
+ @Sendable
+ func uploadRecords(in zoneID: CKRecordZone.ID, db: Database) throws {
+ let recordTypes = Set(
+ try SyncMetadata
+ .where(\.hasLastKnownServerRecord)
+ .select(\.lastKnownServerRecord)
+ .fetchAll(db)
+ .compactMap { $0?.recordID.zoneID == zoneID ? $0?.recordType : nil }
+ )
+ var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = []
+ for recordType in recordTypes {
+ guard let table = tablesByName[recordType]
+ else { continue }
+ func open(_: T.Type) {
+ withErrorReporting(.sqliteDataCloudKitFailure) {
+ pendingRecordZoneChanges.append(
+ contentsOf: try T.select(\._recordName).fetchAll(db).map {
+ .saveRecord(CKRecord.ID(recordName: $0, zoneID: zoneID))
+ }
+ )
+ }
+ }
+ open(table)
+ }
+ syncEngine.state.add(pendingRecordZoneChanges: pendingRecordZoneChanges)
+ }
+ }
+
+ package func handleFetchedRecordZoneChanges(
+ modifications: [CKRecord] = [],
+ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [],
+ syncEngine: any SyncEngineProtocol
+ ) async {
+ let deletedRecordIDsByRecordType = OrderedDictionary(
+ grouping: deletions.sorted { lhs, rhs in
+ guard
+ let lhsIndex = tablesByOrder[lhs.recordType],
+ let rhsIndex = tablesByOrder[rhs.recordType]
+ else { return true }
+ return lhsIndex > rhsIndex
+ },
+ by: \.recordType
+ )
+ .mapValues { $0.map(\.recordID) }
+ for (recordType, recordIDs) in deletedRecordIDsByRecordType {
+ if let table = tablesByName[recordType] {
+ func open(_: T.Type) async {
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await userDatabase.write { db in
+ try T
+ .where {
+ #sql("\($0.primaryKey)").in(
+ SyncMetadata.findAll(recordIDs)
+ .select(\.recordPrimaryKey)
+ )
+ }
+ .delete()
+ .execute(db)
+
+ try UnsyncedRecordID
+ .findAll(recordIDs)
+ .delete()
+ .execute(db)
+ }
+ }
+ }
+ await open(table)
+ } else if recordType == CKRecord.SystemType.share {
+ for shareRecordID in recordIDs {
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await deleteShare(shareRecordID: shareRecordID)
+ }
+ }
+ } else {
+ // NB: Deleting a record from a table we do not currently recognize.
+ }
+ }
+
+ let unsyncedRecords =
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ var unsyncedRecordIDs = try await userDatabase.write { db in
+ Set(
+ try UnsyncedRecordID.all
+ .fetchAll(db)
+ .map(CKRecord.ID.init(unsyncedRecordID:))
+ )
+ }
+ let modificationRecordIDs = Set(modifications.map(\.recordID))
+ let unsyncedRecordIDsToDelete = modificationRecordIDs.intersection(unsyncedRecordIDs)
+ unsyncedRecordIDs.subtract(modificationRecordIDs)
+ if !unsyncedRecordIDsToDelete.isEmpty {
+ try await userDatabase.write { db in
+ try UnsyncedRecordID
+ .findAll(unsyncedRecordIDsToDelete)
+ .delete()
+ .execute(db)
+ }
+ }
+ let results = try await syncEngine.database.records(for: Array(unsyncedRecordIDs))
+ var unsyncedRecords: [CKRecord] = []
+ for (recordID, result) in results {
+ switch result {
+ case .success(let record):
+ unsyncedRecords.append(record)
+ case .failure(let error as CKError) where error.code == .unknownItem:
+ try await userDatabase.write { db in
+ try UnsyncedRecordID.find(recordID).delete().execute(db)
+ }
+ case .failure:
+ continue
+ }
+ }
+ return unsyncedRecords
+ }
+ ?? [CKRecord]()
+
+ let modifications = (modifications + unsyncedRecords).sorted { lhs, rhs in
+ guard
+ let lhsRecordType = lhs.recordID.tableName,
+ let lhsIndex = tablesByOrder[lhsRecordType],
+ let rhsRecordType = rhs.recordID.tableName,
+ let rhsIndex = tablesByOrder[rhsRecordType]
+ else { return true }
+ return lhsIndex < rhsIndex
+ }
+
+ enum ShareOrReference {
+ case share(CKShare)
+ case reference(CKShare.Reference)
+ }
+ let shares: [ShareOrReference] =
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await userDatabase.write { db in
+ var shares: [ShareOrReference] = []
+ for record in modifications {
+ if let share = record as? CKShare {
+ shares.append(.share(share))
+ } else {
+ upsertFromServerRecord(record, db: db)
+ if let shareReference = record.share {
+ shares.append(.reference(shareReference))
+ }
+ }
+ }
+ return shares
+ }
+ }
+ ?? []
+
+ await withTaskGroup(of: Void.self) { group in
+ for share in shares {
+ group.addTask {
+ switch share {
+ case .share(let share):
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await self.cacheShare(share)
+ }
+ case .reference(let shareReference):
+ guard
+ let record = try? await syncEngine.database.record(for: shareReference.recordID),
+ let share = record as? CKShare
+ else { return }
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await self.cacheShare(share)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ package func handleSentRecordZoneChanges(
+ savedRecords: [CKRecord] = [],
+ failedRecordSaves: [(record: CKRecord, error: CKError)] = [],
+ deletedRecordIDs: [CKRecord.ID] = [],
+ failedRecordDeletes: [CKRecord.ID: CKError] = [:],
+ syncEngine: any SyncEngineProtocol
+ ) async {
+ for savedRecord in savedRecords {
+ await refreshLastKnownServerRecord(savedRecord)
+ }
+
+ var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = []
+ var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = []
+ defer {
+ syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges)
+ syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges)
+ }
+ for (failedRecord, error) in failedRecordSaves {
+ func clearServerRecord() async {
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await userDatabase.write { db in
+ try SyncMetadata
+ .find(failedRecord.recordID)
+ .update { $0.setLastKnownServerRecord(nil) }
+ .execute(db)
+ }
+ }
+ }
+
+ switch error.code {
+ case .serverRecordChanged:
+ guard let serverRecord = error.serverRecord else { continue }
+ await upsertFromServerRecord(serverRecord)
+ newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))
+
+ case .zoneNotFound:
+ let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID)
+ newPendingDatabaseChanges.append(.saveZone(zone))
+ newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))
+ await clearServerRecord()
+
+ case .unknownItem:
+ newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))
+ await clearServerRecord()
+
+ case .serverRejectedRequest:
+ await clearServerRecord()
+
+ case .referenceViolation:
+ guard
+ let recordPrimaryKey = failedRecord.recordID.recordPrimaryKey,
+ let table = tablesByName[failedRecord.recordType],
+ foreignKeysByTableName[table.tableName]?.count == 1,
+ let foreignKey = foreignKeysByTableName[table.tableName]?.first
+ else {
+ continue
+ }
+ func open(_: T.Type) async throws {
+ try await userDatabase.write { db in
+ try $_isSynchronizingChanges.withValue(false) {
+ switch foreignKey.onDelete {
+ case .cascade:
+ try T
+ .where { #sql("\($0.primaryKey) = \(bind: recordPrimaryKey)") }
+ .delete()
+ .execute(db)
+ case .restrict:
+ preconditionFailure(
+ "'RESTRICT' foreign key actions not supported for parent relationships."
+ )
+ case .setDefault:
+ guard
+ let recordType = try RecordType.find(table.tableName).fetchOne(db),
+ let columnInfo = recordType.tableInfo.first(where: {
+ $0.name == foreignKey.from
+ })
+ else { return }
+ let defaultValue = columnInfo.defaultValue ?? "NULL"
+ try #sql(
+ """
+ UPDATE \(T.self)
+ SET \(quote: foreignKey.from, delimiter: .identifier) = (\(raw: defaultValue))
+ WHERE \(T.primaryKey) = \(bind: recordPrimaryKey)
+ """
+ )
+ .execute(db)
+ break
+ case .setNull:
+ try #sql(
+ """
+ UPDATE \(T.self)
+ SET \(quote: foreignKey.from, delimiter: .identifier) = NULL
+ WHERE \(T.primaryKey) = \(bind: recordPrimaryKey)
+ """
+ )
+ .execute(db)
+ case .noAction:
+ preconditionFailure(
+ "'NO ACTION' foreign key actions not supported for parent relationships."
+ )
+ }
+ }
+ }
+ }
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await open(table)
+ }
+
+ case .permissionFailure:
+ guard
+ let recordPrimaryKey = failedRecord.recordID.recordPrimaryKey,
+ let table = tablesByName[failedRecord.recordType]
+ else { continue }
+ func open(_: T.Type) async throws {
+ do {
+ let serverRecord = try await container.sharedCloudDatabase.record(
+ for: failedRecord.recordID
+ )
+ await upsertFromServerRecord(serverRecord, force: true)
+ } catch let error as CKError where error.code == .unknownItem {
+ try await userDatabase.write { db in
+ try T
+ .where { #sql("\($0.primaryKey) = \(bind: recordPrimaryKey)") }
+ .delete()
+ .execute(db)
+ }
+ }
+ }
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await open(table)
+ }
+
+ case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable,
+ .notAuthenticated, .operationCancelled, .batchRequestFailed,
+ .internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement,
+ .invalidArguments, .resultsTruncated, .assetFileNotFound,
+ .assetFileModified, .incompatibleVersion, .constraintViolation, .changeTokenExpired,
+ .badDatabase, .quotaExceeded, .limitExceeded, .userDeletedZone, .tooManyParticipants,
+ .alreadyShared, .managedAccountRestricted, .participantMayNeedVerification,
+ .serverResponseLost, .assetNotAvailable, .accountTemporarilyUnavailable:
+ continue
+ @unknown default:
+ continue
+ }
+ }
+
+ let enqueuedUnsyncedRecordID =
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await userDatabase.write { db in
+ var enqueuedUnsyncedRecordID = false
+ for (failedRecordID, error) in failedRecordDeletes {
+ guard
+ error.code == .referenceViolation
+ else { continue }
+ try UnsyncedRecordID.insert(or: .ignore) {
+ UnsyncedRecordID(recordID: failedRecordID)
+ }
+ .execute(db)
+ syncEngine.state.remove(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)])
+ enqueuedUnsyncedRecordID = true
+ }
+ return enqueuedUnsyncedRecordID
+ }
+ }
+ ?? false
+ if enqueuedUnsyncedRecordID {
+ await handleFetchedRecordZoneChanges(syncEngine: syncEngine)
+ }
+ }
+
+ private func cacheShare(_ share: CKShare) async throws {
+ let metadata = try await container.shareMetadata(for: share, shouldFetchRootRecord: false)
+ guard let rootRecordID = metadata.hierarchicalRootRecordID
+ else { return }
+ try await userDatabase.write { db in
+ try SyncMetadata
+ .find(rootRecordID)
+ .update { $0.share = share }
+ .execute(db)
+ }
+ }
+
+ func deleteShare(shareRecordID: CKRecord.ID) async throws {
+ try await userDatabase.write { db in
+ let shareAndRecordNameAndZone =
+ try SyncMetadata
+ .where(\.isShared)
+ .select { ($0.share, $0.recordName, $0.zoneName, $0.ownerName) }
+ .fetchAll(db)
+ .first(where: { share, _, _, _ in share?.recordID == shareRecordID }) ?? nil
+ guard let (_, recordName, zoneName, ownerName) = shareAndRecordNameAndZone
+ else { return }
+ try SyncMetadata
+ .find(
+ CKRecord.ID(
+ recordName: recordName,
+ zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
+ )
+ )
+ .update { $0.share = nil }
+ .execute(db)
+ }
+ }
+
+ private func upsertFromServerRecord(
+ _ serverRecord: CKRecord,
+ force: Bool = false
+ ) async {
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await userDatabase.write { db in
+ upsertFromServerRecord(serverRecord, force: force, db: db)
+ }
+ }
+ }
+
+ private func upsertFromServerRecord(
+ _ serverRecord: CKRecord,
+ force: Bool = false,
+ db: Database
+ ) {
+ withErrorReporting(.sqliteDataCloudKitFailure) {
+ guard let recordPrimaryKey = serverRecord.recordID.recordPrimaryKey
+ else { return }
+
+ try SyncMetadata.insert {
+ SyncMetadata(
+ recordPrimaryKey: recordPrimaryKey,
+ recordType: serverRecord.recordType,
+ zoneName: serverRecord.recordID.zoneID.zoneName,
+ ownerName: serverRecord.recordID.zoneID.ownerName,
+ parentRecordPrimaryKey: serverRecord.parent?.recordID.recordPrimaryKey,
+ parentRecordType: serverRecord.parent?.recordID.tableName,
+ lastKnownServerRecord: serverRecord,
+ _lastKnownServerRecordAllFields: serverRecord,
+ share: nil,
+ userModificationTime: serverRecord.userModificationTime
+ )
+ } onConflict: {
+ ($0.recordPrimaryKey, $0.recordType)
+ } doUpdate: {
+ if tablesByName[serverRecord.recordType] == nil {
+ $0.setLastKnownServerRecord(serverRecord)
+ } else {
+ $0.zoneName = serverRecord.recordID.zoneID.zoneName
+ $0.ownerName = serverRecord.recordID.zoneID.ownerName
+ }
+ }
+ .execute(db)
+
+ guard
+ let metadata = try SyncMetadata.find(serverRecord.recordID).fetchOne(db),
+ let table = tablesByName[serverRecord.recordType]
+ else {
+ return
+ }
+
+ serverRecord.userModificationTime = metadata.userModificationTime
+
+ func open(_: T.Type) throws {
+ var columnNames: [String] = T.TableColumns.writableColumns.map(\.name)
+ if !force,
+ let allFields = metadata._lastKnownServerRecordAllFields,
+ let row = try T.find(#sql("\(bind: metadata.recordPrimaryKey)")).fetchOne(db)
+ {
+ serverRecord.update(
+ with: allFields,
+ row: T(queryOutput: row),
+ columnNames: &columnNames,
+ parentForeignKey: foreignKeysByTableName[T.tableName]?.count == 1
+ ? foreignKeysByTableName[T.tableName]?.first
+ : nil
+ )
+ }
+
+ do {
+ try $_currentZoneID.withValue(serverRecord.recordID.zoneID) {
+ try #sql(upsert(T.self, record: serverRecord, columnNames: columnNames)).execute(db)
+ }
+ try UnsyncedRecordID.find(serverRecord.recordID).delete().execute(db)
+ try SyncMetadata
+ .find(serverRecord.recordID)
+ .update { $0.setLastKnownServerRecord(serverRecord) }
+ .execute(db)
+ } catch {
+ guard
+ let error = error as? DatabaseError,
+ error.resultCode == .SQLITE_CONSTRAINT,
+ error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY
+ else {
+ throw error
+ }
+ try UnsyncedRecordID.insert(or: .ignore) {
+ UnsyncedRecordID(recordID: serverRecord.recordID)
+ }
+ .execute(db)
+ }
+ }
+ try open(table)
+ }
+ }
+
+ private func refreshLastKnownServerRecord(_ record: CKRecord) async {
+ await withErrorReporting(.sqliteDataCloudKitFailure) {
+ try await metadatabase.write { db in
+ let metadata = try SyncMetadata.find(record.recordID).fetchOne(db)
+ func updateLastKnownServerRecord() throws {
+ try SyncMetadata
+ .find(record.recordID)
+ .update { $0.setLastKnownServerRecord(record) }
+ .execute(db)
+ }
+
+ if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate {
+ if let recordDate = record.modificationDate, lastKnownDate < recordDate {
+ try updateLastKnownServerRecord()
+ }
+ } else {
+ try updateLastKnownServerRecord()
+ }
+ }
+ }
+ }
+
+ private func updateQuery(
+ for _: T.Type,
+ record: CKRecord,
+ columnNames: some Collection,
+ changedColumnNames: some Collection
+ ) async throws -> QueryFragment {
+ let nonPrimaryKeyChangedColumns =
+ changedColumnNames
+ .filter { $0 != T.columns.primaryKey.name }
+ guard
+ !nonPrimaryKeyChangedColumns.isEmpty
+ else {
+ return ""
+ }
+ var record = record
+ let recordHasAsset = nonPrimaryKeyChangedColumns.contains { columnName in
+ record[columnName] is CKAsset
+ }
+ if recordHasAsset {
+ record = try await container.database(for: record.recordID).record(for: record.recordID)
+ }
+
+ var query: QueryFragment = "INSERT INTO \(T.self) ("
+ query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", "))
+ query.append(") VALUES (")
+ query.append(
+ columnNames
+ .map { columnName in
+ if let asset = record[columnName] as? CKAsset {
+ let data = try? asset.fileURL.map { try dataManager.wrappedValue.load($0) }
+ if data == nil {
+ reportIssue("Asset data not found on disk")
+ }
+ return data?.queryFragment ?? "NULL"
+ } else {
+ return record.encryptedValues[columnName]?.queryFragment ?? "NULL"
+ }
+ }
+ .joined(separator: ", ")
+ )
+ query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET ")
+ query.append(
+ nonPrimaryKeyChangedColumns
+ .map { columnName in
+ if let asset = record[columnName] as? CKAsset {
+ let data = try? asset.fileURL.map { try dataManager.wrappedValue.load($0) }
+ if data == nil {
+ reportIssue("Asset data not found on disk")
+ }
+ return "\(quote: columnName) = \(data?.queryFragment ?? "NULL")"
+ } else {
+ return
+ "\(quote: columnName) = \(record.encryptedValues[columnName]?.queryFragment ?? "NULL")"
+ }
+ }
+ .joined(separator: ",")
+ )
+ return query
+ }
+ }
+
+ @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *)
+ extension CKSyncEngine.PendingRecordZoneChange {
+ var id: CKRecord.ID? {
+ switch self {
+ case .saveRecord(let id):
+ return id
+ case .deleteRecord(let id):
+ return id
+ @unknown default:
+ return nil
+ }
+ }
+ }
+
+ extension CKRecord.ID {
+ var tableName: String? {
+ guard
+ let i = recordName.utf8.lastIndex(of: UTF8.CodeUnit(ascii: ":")),
+ let j = recordName.utf8.index(i, offsetBy: 1, limitedBy: recordName.utf8.endIndex)
+ else { return nil }
+ let recordTypeBytes = recordName.utf8[j...]
+ guard !recordTypeBytes.isEmpty else { return nil }
+ return String(Substring(recordTypeBytes))
+ }
+
+ var recordPrimaryKey: String? {
+ guard
+ let i = recordName.utf8.lastIndex(of: UTF8.CodeUnit(ascii: ":"))
+ else { return nil }
+ let recordPrimaryKeyBytes = recordName.utf8[.. URL {
+ guard let databaseURL = URL(string: databasePath)
+ else {
+ struct InvalidDatabasePath: Error {}
+ throw InvalidDatabasePath()
+ }
+ guard !databaseURL.isInMemory
+ else {
+ return URL(string: "file:\(String.sqliteDataCloudKitSchemaName)?mode=memory&cache=shared")!
+ }
+ return
+ databaseURL
+ .deletingLastPathComponent()
+ .appending(component: ".\(databaseURL.deletingPathExtension().lastPathComponent)")
+ .appendingPathExtension("metadata\(containerIdentifier.map { "-\($0)" } ?? "").sqlite")
+ }
+
+ package var isInMemory: Bool {
+ path.isEmpty
+ || path.hasPrefix(":memory:")
+ || absoluteString.hasPrefix(":memory:")
+ || URLComponents(url: self, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .contains(where: { $0.name == "mode" && $0.value == "memory" })
+ == true
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ package struct SyncEngines {
+ private let rawValue: (private: any SyncEngineProtocol, shared: any SyncEngineProtocol)?
+ init() {
+ rawValue = nil
+ }
+ init(private: any SyncEngineProtocol, shared: any SyncEngineProtocol) {
+ rawValue = (`private`, shared)
+ }
+ var isRunning: Bool {
+ rawValue != nil
+ }
+ package var `private`: (any SyncEngineProtocol)? {
+ guard let `private` = rawValue?.private
+ else {
+ reportIssue("Private sync engine has not been set.")
+ return nil
+ }
+ return `private`
+ }
+ package var `shared`: (any SyncEngineProtocol)? {
+ guard let `shared` = rawValue?.shared
+ else {
+ reportIssue("Shared sync engine has not been set.")
+ return nil
+ }
+ return `shared`
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension Database {
+ /// Attaches the metadatabase to an existing database connection.
+ ///
+ /// Invoke this method when preparing your database connection in order to allow querying the
+ /// ``SyncMetadata`` table (see for more info):
+ ///
+ /// ```swift
+ /// func appDatabase() -> any DatabaseWriter {
+ /// var configuration = Configuration()
+ /// configuration.prepareDatabase = { db in
+ /// db.attachMetadatabase()
+ /// …
+ /// }
+ /// }
+ /// ```
+ ///
+ /// By default this method will use the container identifier assigned in your app's
+ /// entitlements. If you wish to use a different container identifier then you can provide
+ /// the `containerIdentifier` argument.
+ ///
+ /// See for more information on preparing your database.
+ ///
+ /// - 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 {
+ let containerIdentifier =
+ containerIdentifier
+ ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier
+
+ guard let containerIdentifier else {
+ throw SyncEngine.SchemaError(
+ reason: .noCloudKitContainer,
+ debugDescription: """
+ No default CloudKit container found. Please add a container identifier to your app's \
+ entitlements.
+ """
+ )
+ }
+
+ let databasePath = try PragmaDatabaseList.select(\.file).fetchOne(self)
+ guard let databasePath else {
+ struct PathError: Error {}
+ throw SyncEngine.SchemaError(
+ reason: .unknown,
+ debugDescription: """
+ Expected to load a database path from the connection, but failed to do so.
+ """
+ )
+ }
+ let url = try URL.metadatabase(
+ databasePath: databasePath,
+ containerIdentifier: containerIdentifier
+ )
+ let path = url.path(percentEncoded: false)
+ try FileManager.default.createDirectory(
+ at: .applicationSupportDirectory,
+ withIntermediateDirectories: true
+ )
+ _ = try DatabasePool(path: path).write { db in
+ try #sql("SELECT 1").execute(db)
+ }
+ try #sql(
+ """
+ ATTACH DATABASE \(bind: path) AS \(quote: .sqliteDataCloudKitSchemaName)
+ """
+ )
+ .execute(self)
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension SyncEngine {
+ package struct SchemaError: LocalizedError {
+ package enum Reason {
+ case cycleDetected
+ case inMemoryDatabase
+ case invalidForeignKey(ForeignKey)
+ case invalidForeignKeyAction(ForeignKey)
+ case invalidTableName(String)
+ case metadatabaseMismatch(attachedPath: String, syncEngineConfiguredPath: String)
+ case noCloudKitContainer
+ case nonNullColumnsWithoutDefault(tableName: String, columnNames: [String])
+ case unknown
+ case uniquenessConstraint
+ }
+ package let reason: Reason
+ package let debugDescription: String
+
+ package var errorDescription: String? {
+ "Could not synchronize data with iCloud."
+ }
+ }
+
+ fileprivate func validateSchema() throws {
+ let tableNames = Set(tables.map { $0.tableName })
+ for tableName in tableNames {
+ if tableName.contains(":") {
+ throw SyncEngine.SchemaError(
+ reason: .invalidTableName(tableName),
+ debugDescription: "Table name contains invalid character ':'"
+ )
+ }
+ }
+ try userDatabase.read { db in
+ for (tableName, foreignKeys) in foreignKeysByTableName {
+ let invalidForeignKey = foreignKeys.first(where: { tablesByName[$0.table] == nil })
+ if let invalidForeignKey {
+ throw SyncEngine.SchemaError(
+ reason: .invalidForeignKey(invalidForeignKey),
+ debugDescription: """
+ Foreign key \(tableName.debugDescription).\(invalidForeignKey.from.debugDescription) \
+ references table \(invalidForeignKey.table.debugDescription) that is not \
+ synchronized. Update 'SyncEngine.init' to synchronize \
+ \(invalidForeignKey.table.debugDescription).
+ """
+ )
+ }
+
+ if foreignKeys.count == 1,
+ let foreignKey = foreignKeys.first,
+ [.restrict, .noAction].contains(foreignKey.onDelete)
+ {
+ throw SyncEngine.SchemaError(
+ reason: .invalidForeignKeyAction(foreignKey),
+ debugDescription: """
+ Foreign key \(tableName.debugDescription).\(foreignKey.from.debugDescription) action \
+ not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'.
+ """
+ )
+ }
+ }
+
+ for table in tables {
+ func open(_: T.Type) throws {
+ let columnsWithUniqueConstraints = try PragmaIndexList
+ .where { $0.isUnique && $0.origin != "pk" }
+ .select(\.name)
+ .fetchAll(db)
+ if !columnsWithUniqueConstraints.isEmpty {
+ throw SyncEngine.SchemaError(
+ reason: .uniquenessConstraint,
+ debugDescription: """
+ Uniqueness constraints are not supported for synchronized tables.
+ """
+ )
+ }
+ }
+ try open(table)
+ }
+ }
+ }
+ }
+
+ private struct HashablePrimaryKeyedTableType: Hashable {
+ let type: any (PrimaryKeyedTable & _SendableMetatype).Type
+ init(_ type: any (PrimaryKeyedTable & _SendableMetatype).Type) {
+ self.type = type
+ }
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(ObjectIdentifier(type))
+ }
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.type == rhs.type
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ private func tablesByOrder(
+ userDatabase: UserDatabase,
+ tables: [any (PrimaryKeyedTable & _SendableMetatype).Type],
+ tablesByName: [String: any (PrimaryKeyedTable & _SendableMetatype).Type]
+ ) throws -> [String: Int] {
+ let tableDependencies = try userDatabase.read { db in
+ var dependencies:
+ [HashablePrimaryKeyedTableType: [any (PrimaryKeyedTable & _SendableMetatype).Type]] = [:]
+ for table in tables {
+ func open(_: T.Type) throws -> [String] {
+ try PragmaForeignKeyList.select(\.table)
+ .fetchAll(db)
+ }
+ let toTables = try open(table)
+ for toTable in toTables {
+ guard let toTableType = tablesByName[toTable]
+ else { continue }
+ dependencies[HashablePrimaryKeyedTableType(table), default: []].append(toTableType)
+ }
+ }
+ return dependencies
+ }
+
+ var visited = Set()
+ var marked = Set()
+ var result: [String: Int] = [:]
+ for table in tableDependencies.keys {
+ try visit(table: table)
+ }
+ return result
+
+ func visit(table: HashablePrimaryKeyedTableType) throws {
+ guard !visited.contains(table)
+ else { return }
+ guard !marked.contains(table)
+ else {
+ throw SyncEngine.SchemaError(
+ reason: .cycleDetected,
+ debugDescription: """
+ Cycles are not currently permitted in schemas, e.g. a table that references itself.
+ """
+ )
+ }
+
+ marked.insert(table)
+ for dependency in tableDependencies[table] ?? [] {
+ try visit(table: HashablePrimaryKeyedTableType(dependency))
+ }
+ marked.remove(table)
+ visited.insert(table)
+ result[table.type.tableName] = result.count
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension Updates {
+ mutating func setLastKnownServerRecord(_ lastKnownServerRecord: CKRecord?) {
+ self.zoneName = lastKnownServerRecord?.recordID.zoneID.zoneName ?? self.zoneName
+ self.ownerName = lastKnownServerRecord?.recordID.zoneID.ownerName ?? self.ownerName
+ self.lastKnownServerRecord = lastKnownServerRecord
+ self._lastKnownServerRecordAllFields = lastKnownServerRecord
+ if let lastKnownServerRecord {
+ self.userModificationTime = lastKnownServerRecord.userModificationTime
+ }
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ private func upsert(
+ _: T.Type,
+ record: CKRecord,
+ columnNames: some Collection
+ ) -> QueryFragment {
+ let allColumnNames = T.TableColumns.writableColumns.map(\.name)
+ let hasNonPrimaryKeyColumns = columnNames.contains(where: { $0 != T.columns.primaryKey.name })
+ var query: QueryFragment = "INSERT INTO \(T.self) ("
+ query.append(allColumnNames.map { "\(quote: $0)" }.joined(separator: ", "))
+ query.append(") VALUES (")
+ query.append(
+ allColumnNames
+ .map { columnName in
+ if let asset = record[columnName] as? CKAsset {
+ @Dependency(\.dataManager) var dataManager
+ return (try? asset.fileURL.map { try dataManager.load($0) })?
+ .queryFragment ?? "NULL"
+ } else {
+ return record.encryptedValues[columnName]?.queryFragment ?? "NULL"
+ }
+ }
+ .joined(separator: ", ")
+ )
+ query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO ")
+ if hasNonPrimaryKeyColumns {
+ query.append("UPDATE SET ")
+ query.append(
+ columnNames
+ .filter { columnName in columnName != T.columns.primaryKey.name }
+ .map {
+ """
+ \(quote: $0) = "excluded".\(quote: $0)
+ """
+ }
+ .joined(separator: ", ")
+ )
+ } else {
+ query.append("NOTHING")
+ }
+ return query
+ }
+
+ @TaskLocal package var _isSynchronizingChanges = false
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ @TaskLocal package var _currentZoneID: CKRecordZone.ID?
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ @DatabaseFunction("sqlitedata_icloud_currentZoneName")
+ func currentZoneName() -> String? {
+ _currentZoneID?.zoneName
+ }
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ @DatabaseFunction("sqlitedata_icloud_currentOwnerName")
+ func currentOwnerName() -> String? {
+ _currentZoneID?.ownerName
+ }
+#endif
diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift
new file mode 100644
index 00000000..44672565
--- /dev/null
+++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift
@@ -0,0 +1,194 @@
+#if canImport(CloudKit)
+ import CloudKit
+
+ /// A table that tracks metadata related to synchronized data.
+ ///
+ /// Each row of this table represents a synchronized record across all tables synchronized with
+ /// CloudKit. This means that the sum of the count of rows across all synchronized tables in your
+ /// application is the number of rows this one single table holds. However, this table is held
+ /// in a database separate from your app's database.
+ ///
+ /// See for more info.
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ @Table("sqlitedata_icloud_metadata")
+ public struct SyncMetadata: Hashable, Sendable {
+ /// The unique identifier of the record synchronized.
+ public var recordPrimaryKey: String
+
+ /// The type of the record synchronized, _i.e._ its table name.
+ public var recordType: String
+
+ /// The record zone name.
+ public var zoneName: String
+
+ /// The record owner name.
+ public var ownerName: String
+
+ /// The name of the record synchronized.
+ ///
+ /// This field encodes both the table name and primary key of the record synchronized in
+ /// the format "primaryKey:tableName", for example:
+ ///
+ /// ```swift
+ /// "8c4d1e4e-49b2-4f60-b6df-3c23881b87c6:reminders"
+ /// ```
+ @Column(generated: .virtual)
+ public let recordName: String
+
+ /// The unique identifier of this record's parent, if any.
+ public var parentRecordPrimaryKey: String?
+
+ /// The type of this record's parent, _i.e._ its table name, if any.
+ public var parentRecordType: String?
+
+ /// The name of this record's parent, if any.
+ ///
+ /// This field encodes both the table name and primary key of the parent record in the format
+ /// "primaryKey:tableName", for example:
+ ///
+ /// ```swift
+ /// "d35e1f81-46e4-45d1-904b-2b7df1661e3e:remindersLists"
+ /// ```
+ @Column(generated: .virtual)
+ public let parentRecordName: String?
+
+ /// The last known `CKRecord` received from the server.
+ ///
+ /// This record holds only the fields that are archived when using `encodeSystemFields(with:)`.
+ @Column(as: CKRecord?.SystemFieldsRepresentation.self)
+ public var lastKnownServerRecord: CKRecord?
+
+ /// The last known `CKRecord` received from the server with all fields archived.
+ @Column(as: CKRecord?._AllFieldsRepresentation.self)
+ public var _lastKnownServerRecordAllFields: CKRecord?
+
+ /// The `CKShare` associated with this record, if it is shared.
+ @Column(as: CKShare?.SystemFieldsRepresentation.self)
+ public var share: CKShare?
+
+ /// Determines if the metadata has been "soft" deleted. It will be fully deleted once the
+ /// next batch of pending changes is processed.
+ public var _isDeleted = false
+
+ @Column(generated: .virtual)
+ public let hasLastKnownServerRecord: Bool
+
+ /// Determines if the record associated with this metadata is currently shared in CloudKit.
+ ///
+ /// This can only return `true` for root records. For example, the metadata associated with a
+ /// `RemindersList` can have `isShared == true`, but a `Reminder` associated with the list
+ /// will have `isShared == false`.
+ @Column(generated: .virtual)
+ public let isShared: Bool
+
+ /// The time the user last modified the record.
+ public var userModificationTime: Int64
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension SyncMetadata {
+ package init(
+ recordPrimaryKey: String,
+ recordType: String,
+ zoneName: String,
+ ownerName: String,
+ parentRecordPrimaryKey: String? = nil,
+ parentRecordType: String? = nil,
+ lastKnownServerRecord: CKRecord? = nil,
+ _lastKnownServerRecordAllFields: CKRecord? = nil,
+ share: CKShare? = nil,
+ userModificationTime: Int64
+ ) {
+ self.recordPrimaryKey = recordPrimaryKey
+ self.recordType = recordType
+ self.recordName = "\(recordPrimaryKey):\(recordType)"
+ self.zoneName = zoneName
+ self.ownerName = ownerName
+ self.parentRecordPrimaryKey = parentRecordPrimaryKey
+ self.parentRecordType = parentRecordType
+ if let parentRecordPrimaryKey, let parentRecordType {
+ self.parentRecordName = "\(parentRecordPrimaryKey):\(parentRecordType)"
+ } else {
+ self.parentRecordName = nil
+ }
+ self.lastKnownServerRecord = lastKnownServerRecord
+ self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields
+ self.share = share
+ self.hasLastKnownServerRecord = lastKnownServerRecord != nil
+ self.isShared = share != nil
+ self.userModificationTime = userModificationTime
+ }
+
+ package static func find(_ recordID: CKRecord.ID) -> Where {
+ Self.where {
+ $0.recordName.eq(recordID.recordName)
+ && $0.zoneName.eq(recordID.zoneID.zoneName)
+ && $0.ownerName.eq(recordID.zoneID.ownerName)
+ }
+ }
+
+ package static func findAll(_ recordIDs: some Collection) -> Where {
+ let condition: QueryFragment = recordIDs.map {
+ "(\(bind: $0.recordName), \(bind: $0.zoneID.zoneName), \(bind: $0.zoneID.ownerName))"
+ }
+ .joined(separator: ", ")
+ return Self.where {
+ #sql("(\($0.recordName), \($0.zoneName), \($0.ownerName)) IN (\(condition))")
+ }
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible {
+ /// A query for finding the metadata associated with a record.
+ ///
+ /// - Parameter primaryKey: The primary key of the record whose metadata to look up.
+ public static func metadata(for primaryKey: PrimaryKey.QueryOutput) -> Where {
+ SyncMetadata.where {
+ #sql(
+ """
+ \($0.recordPrimaryKey) = \(PrimaryKey(queryOutput: primaryKey)) \
+ AND \($0.recordType) = \(bind: tableName)
+ """
+ )
+ }
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConvertible {
+ /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID.
+ ///
+ /// - Parameter id: The ID of the record.
+ package static func recordName(for id: PrimaryKey.QueryOutput) -> String {
+ "\(id.rawIdentifier):\(tableName)"
+ }
+
+ var recordName: String {
+ Self.recordName(for: self[keyPath: Self.columns.primaryKey.keyPath])
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension PrimaryKeyedTableDefinition where PrimaryKey.QueryOutput: IdentifierStringConvertible {
+ /// A query expression for whether or not this row has associated sync metadata.
+ ///
+ /// This helper can be useful when joining your tables to the ``SyncMetadata`` table:
+ ///
+ /// ```swift
+ /// RemindersList
+ /// .leftJoin(SyncMetadata.all) { $0.hasMetadata.in($1) }
+ /// ```
+ public func hasMetadata(in metadata: SyncMetadata.TableColumns) -> some QueryExpression {
+ metadata.recordType.eq(QueryValue.tableName)
+ && #sql("\(primaryKey)").eq(metadata.recordPrimaryKey)
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension PrimaryKeyedTableDefinition {
+ var _recordName: some QueryExpression {
+ #sql("\(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)")
+ }
+ }
+#endif
diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md
new file mode 100644
index 00000000..6e2c2350
--- /dev/null
+++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md
@@ -0,0 +1,872 @@
+# Getting started with CloudKit
+
+Learn how to seamlessly add CloudKit synchronization to your SQLiteData application.
+
+## Overview
+
+SQLiteData allows you to seamlessly synchronize your SQLite database with CloudKit. After a few
+steps to set up your project and a ``SyncEngine``, your database can be automatically synchronized
+to CloudKit. However, distributing your app's schema across many devices is an impactful decision
+to make, and so an abundance of care must be taken to make sure all devices remain consistent
+and capable of communicating with each other. Please read the documentation closely and thoroughly
+to make sure you understand how to best prepare your app for cloud synchronization.
+
+ - [Setting up your project](#Setting-up-your-project)
+ - [Setting up a SyncEngine](#Setting-up-a-SyncEngine)
+ - [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind)
+ - [Primary keys](#Primary-keys)
+ - [Primary keys on every table](#Primary-keys-on-every-table)
+ - [Uniqueness constraints](#Uniqueness-constraints)
+ - [Foreign key relationships](#Foreign-key-relationships)
+ - [Record conflicts](#Record-conflicts)
+ - [Backwards compatible migrations](#Backwards-compatible-migrations)
+ - [Adding tables](#Adding-tables)
+ - [Adding columns](#Adding-columns)
+ - [Disallowed migrations](#Disallowed-migrations)
+ - [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users)
+ - [Assets](#Assets)
+ - [Accessing CloudKit metadata](#Accessing-CloudKit-metadata)
+ - [How SQLiteData handles distributed schema scenarios](#How-SQLiteData-handles-distributed-schema-scenarios)
+ - [Unit testing and Xcode previews](#Unit-testing-and-Xcode-previews)
+ - [Preparing an existing schema for synchronization](#Preparing-an-existing-schema-for-synchronization)
+ - [Convert Int primary keys to UUID](#Convert-Int-primary-keys-to-UUID)
+ - [Add primary key to all tables](#Add-primary-key-to-all-tables)
+ - [Migrating from Swift Data to SQLiteData](#Migrating-from-Swift-Data-to-SQLiteData)
+ - [Separating schema migrations from data migrations](#Separating-schema-migrations-from-data-migrations)
+ - [Tips and tricks](#Tips-and-tricks)
+ - [Updating triggers to be compatible with synchronization](#Updating-triggers-to-be-compatible-with-synchronization)
+ - [Topics](#Topics)
+ - [Go deeper](#Go-deeper)
+
+## Setting up your project
+
+The steps to set up your SQLiteData project for CloudKit synchronization are the
+[same for setting up][setup-cloudkit-apple] any other kind of project for CloudKit:
+
+ * Follow the [Configuring iCloud services] guide for enabling iCloud entitlements in your project.
+ * Follow the [Configuring background execution modes] guide for adding the Background Modes
+ capability to your project.
+ * If you want to enable sharing of records with other iCloud users, be sure to add a
+ `CKSharingSupported` key to your Info.plist with a value of `true`. This is subtly documented
+ in [Apple's documentation for sharing].
+ * Once you are ready to deploy your app be sure to read Apple's documentation on
+ [Deploying an iCloud Container’s Schema].
+
+With those steps completed, you are ready to configure a ``SyncEngine`` that will facilitate
+synchronizing your database to and from CloudKit.
+
+[Deploying an iCloud Container’s Schema]: https://developer.apple.com/documentation/CloudKit/deploying-an-icloud-container-s-schema
+[Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic
+[setup-cloudkit-apple]: https://developer.apple.com/documentation/swiftdata/syncing-model-data-across-a-persons-devices#Add-the-iCloud-and-Background-Modes-capabilities
+[Configuring iCloud services]: https://developer.apple.com/documentation/Xcode/configuring-icloud-services
+[Configuring background execution modes]: https://developer.apple.com/documentation/Xcode/configuring-background-execution-modes
+
+## Setting up a SyncEngine
+
+The foundational tool used to synchronize your SQLite database to CloudKit is a ``SyncEngine``.
+This is a wrapper around CloudKit's `CKSyncEngine` and performs all the necessary work to listen
+for changes in your database to play them back to CloudKit, and listen for changes in CloudKit to
+play them back to SQLite.
+
+Before constructing a ``SyncEngine`` you must have already created and migrated your app's local
+SQLite database as detailed in . Immediately after that is done in the
+`prepareDependencies` of the entry point of your app you will override the
+``Dependencies/DependencyValues/defaultSyncEngine`` dependency with a sync engine that specifies
+the database to synchronize, as well as the tables you want to synchronize:
+
+```swift
+@main
+struct MyApp: App {
+ init() {
+ try! prepareDependencies {
+ $0.defaultDatabase = try appDatabase()
+ $0.defaultSyncEngine = try SyncEngine(
+ for: $0.defaultDatabase,
+ tables: RemindersList.self, Reminder.self
+ )
+ }
+ }
+
+ // ...
+}
+```
+
+The `SyncEngine`
+[initializer]()
+has more options you may be interested in configuring.
+
+> Important: You must explicitly provide all tables that you want to synchronize. We do this so that
+> you can have the option of having some local tables that are not synchronized to CloudKit, such as
+> full-text search indices, cached data, etc.
+
+Once this work is done the app should work exactly as it did before, but now any changes made
+to the database will be synchronized to CloudKit. You will still interact with your local SQLite
+database the same way you always have. You can use ``FetchAll`` to fetch data to be used in a view
+or `@Observable` model, and you can use the `defaultDatabase` dependency to write to the database.
+
+There is one additional step you can optionally take if you want to gain access to the underlying
+CloudKit metadata that is stored by the library. When constructing the connection to your database
+you can use the `prepareDatabase` method on `Configuration` to attach the metadatabase:
+
+```swift
+func appDatabase() -> any DatabaseWriter {
+ var configuration = Configuration()
+ configuration.prepareDatabase = { db in
+ db.attachMetadatabase()
+ …
+ }
+}
+```
+
+This will allow you to query the ``SyncMetadata`` table, which gives you access to the `CKRecord`
+stored for each of your records, as well as the `CKShare` for any shared records.
+
+See the ``GRDB/Database/attachMetadatabase(containerIdentifier:)`` for more information, as well
+as below.
+
+## Designing your schema with synchronization in mind
+
+Distributing your app's schema across many devices is a big decision to make for your app, and
+care must be taken. It is not true that you can simply take any existing schema, add a
+``SyncEngine`` to it, and have it magically synchronize data across all devices and across all
+versions of your app. There are a number of principles to keep in mind while designing and evolving
+your schema to make sure every device can synchronize changes to every other device, no matter the
+version.
+
+#### Globally unique primary keys
+
+> TL;DR: Primary keys should be globally unique identifiers, such as UUID. We further recommend
+> specifying a `NOT NULL` constraint with a `ON CONFLICT REPLACE` action.
+
+Primary keys are an important concept in SQL schema design, and SQLite makes it easy to add a
+primary key by using an `AUTOINCREMENT` integer. This makes it so that newly inserted rows get
+a unique ID by simply adding 1 to the largest ID in the table. However, that does not play nicely
+with distributed schemas. That would make it possible for two devices to create a record with
+`id: 1`, and when those records synchronize there would be an irreconcilable conflict.
+
+For this reason, primary keys in SQLite tables should be _globally_ unique, such as a UUID. The
+easiest way to do this is to store your table's ID in a `TEXT` column, adding a
+default with a freshly generated UUID, and further adding a `ON CONFLICT REPLACE` constraint:
+
+```sql
+CREATE TABLE "reminders" (
+ "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()),
+ …
+)
+```
+
+> Tip: The `ON CONFLICT REPLACE` clause must be placed directly after `NOT NULL`.
+
+This allows you to insert a row with a NULL value for the primary key and SQLite will compute
+the primary key from the default value specified. This kind of pattern is commonly used with the
+`Draft` type generated for primary keyed tables:
+
+```swift
+try database.write { db in
+ try Reminder.upsert {
+ // ℹ️ Omitting 'id' allows the database to initialize it for you.
+ Reminder.Draft(title: "Get milk")
+ }
+ .execute(db)
+}
+```
+
+If you would like to use a unique identifier other than the `UUID` provided by Foundation, you can
+conform your identifier type to ``IdentifierStringConvertible``. We still recommend using
+`NOT NULL ON CONFLICT REPLACE` on your column, as well as a default, but the default will need
+to be provided outside of SQLite. You can do this by registering a function in SQLite and calling
+out to it for the default value of your column:
+
+```sql
+CREATE TABLE "reminders" (
+ "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (customUUIDv7()),
+ …
+)
+```
+
+Registering custom database functions for ID generation also makes it possible to generate
+deterministic IDs for tests, making it easier to test your queries.
+
+#### Primary keys on every table
+
+> TL;DR: Each synchronized table must have a single, non-compound primary key to aid in
+> synchronization, even if it is not used by your app.
+
+_Every_ table being synchronized must have a single primary key and cannot have compound primary
+keys. This includes join tables that typically only have two foreign keys pointing to the two
+tables they are joining. For example, a `ReminderTag` table that joins reminders to tags should be
+designed like so:
+
+```sql
+CREATE TABLE "reminderTags" (
+ "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()),
+ "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE,
+ "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE
+) STRICT
+```
+
+Note that the `id` column might not be needed for your application's logic, but it is necessary to
+facilitate synchronizing to CloudKit.
+
+#### Foreign key relationships
+
+> TL;DR: Foreign key constraints can be enabled and you can use `ON DELETE` actions to
+> cascade deletions.
+
+SQLiteData can synchronize many-to-one and many-to-many relationships to CloudKit,
+and you can enforce foreign key constraints in your database connection. While it is possible for
+the sync engine to receive records in an order that could cause a foreign key constraint failure,
+such as receiving a child record before its parent, the sync engine will cache the child record
+until the parent record has been synchronized, at which point the child record will also be
+synchronized.
+
+Currently the only actions supported for `ON DELETE` are `CASCADE`, `SET NULL` and `SET DEFAULT`.
+In particular, `RESTRICT` and `NO ACTION` are not supported, and if you try to use those actions
+in your schema an error will be thrown when constructing ``SyncEngine``.
+
+#### Uniqueness constraints
+
+> TL;DR: SQLite tables cannot have `UNIQUE` constraints on their columns in order to allow
+> for distributed creation of records.
+
+Tables with unique constraints on their columns, other than on the primary key, cannot be
+synchronized. As an example, suppose you have a `Tag` table with a unique constraint on the
+`title` column. It is not clear how the application should handle if two different devices create
+a tag with the title "Family" at the same time. When the two devices synchronize their data
+they will have a conflict on the uniqueness constraint, but it would not be correct to
+discard one of the tags.
+
+For this reason uniqueness constraints are not allowed in schemas, and this will be validated
+when a ``SyncEngine`` is first created. If a uniqueness constraint is detected an error will be
+thrown.
+
+Sometimes it is possible to make the column that you want to be unique into the primary key of
+your table. For example, tags with a unique title could be modeled like so:
+
+```swift
+@Table struct Tag {
+ let title: String
+}
+// CREATE TABLE "tags" (
+// "title" TEXT NOT NULL PRIMARY KEY
+// ) STRICT
+```
+
+This will make it so that there can be at most one tag with a specific title. However, there are
+important caveats to be aware of:
+
+> Important: The primary key of a row is encoded into the `recordName` of a `CKRecord`, along with
+> the table name. There are [restrictions][CKRecord.ID] on the value of `recordName`:
+>
+> * It may only contain ASCII characters
+> * It must be less than 255 characters
+> * It must not begin with an underscore
+>
+> If your primary key violates any of these rules, a `DatabaseError` will be thrown with a message
+> of ``SyncEngine/invalidRecordNameError``.
+
+[CKRecord.ID]: https://developer.apple.com/documentation/cloudkit/ckrecord/id
+
+## Backwards compatible migrations
+
+> TL;DR: Database migrations should be done carefully and with full backwards compatibility
+> in mind in order to support multiple devices running with different schema versions.
+
+Migrations of a distributed schema come with even more complications than what is mentioned above.
+If you ship a 1.0 of your app, and then in 1.1 you add a column to a table, you will need to
+contend with the fact that users of the 1.0 will be creating records without that column. This can
+cause problems if your migration is not designed correctly.
+
+#### Adding tables
+
+Adding new tables to a schema is perfectly safe thing to do in a CloudKit application. If a record
+from a device is synchronized to a device that does not have that table it will cache the record
+for later use. Then, when a device updates to the newest version of the app and detects a new table
+has been added to the schema, it will populate the table with the cached records it received.
+
+#### Adding columns
+
+> TL;DR: When adding columns to a table that has already been deployed to users' devices, you will
+either need to make the column nullable, or a default value must be provided with an
+`ON CONFLICT REPLACE` clause.
+
+As an example, suppose the 1.0 of your app shipped a table for a reminders list:
+
+```swift
+@Table
+struct RemindersList {
+ let id: UUID
+ var title = ""
+}
+```
+
+…and you created the SQL table for this like so:
+
+```sql
+CREATE TABLE "remindersLists" (
+ "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()),
+ "title" TEXT NOT NULL DEFAULT ''
+) STRICT
+```
+
+Next suppose in 1.1 you want to add a column to the `RemindersList` type:
+
+```diff
+ @Table
+ struct RemindersList {
+ let id: UUID
+ var title = ""
++ var position = 0
+ }
+```
+
+…with the corresponding SQL migration:
+
+```sql
+ALTER TABLE "remindersLists"
+ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0
+```
+
+Unfortunately this schema is problematic for synchronization. When a device running the 1.0 of the
+app creates a record, it will not have the `position` field. And when that synchronizes to devices
+running the 1.1 of the app, the ``SyncEngine`` will attempt to run a query that is essentially this:
+
+```sql
+INSERT INTO "remindersLists"
+("id", "title", "position")
+VALUES
+(NULL, 'Personal', NULL)
+```
+
+This will generate a SQL error because the "position" column was declared as `NOT NULL`, and so this
+record will not properly synchronize to devices running a newer version of the app.
+
+The fix is to allow for inserting `NULL` values into `NOT NULL` columns by using the default of the
+column. This can be done like so:
+
+```sql
+ALTER TABLE "remindersLists"
+ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0
+```
+
+> Important: The `ON CONFLICT REPLACE` clause must come directly after `NOT NULL` because it
+> modifies that constraint.
+
+Now when this query is executed:
+
+```sql
+INSERT INTO "remindersLists"
+("id", "title", "position")
+VALUES
+(NULL, 'Personal', NULL)
+```
+
+…it will use 0 for the `position` column.
+
+Sometimes it is not possible to specify a default for a newly added column. Suppose in version 1.2
+of your app you add groups for reminders lists. This can be expressed as a new field on the
+`RemindersList` type:
+
+```diff
+ @Table
+ struct RemindersList {
+ let id: UUID
+ var title = ""
+ var position = 0
++ var remindersListGroupID: RemindersListGroup.ID
+ }
+```
+
+However, there is no sensible default that can be used for this schema. But, if you migrate your
+table like so:
+
+```sql
+ALTER TABLE "remindersLists"
+ADD COLUMN "remindersListGroupID" TEXT NOT NULL
+REFERENCES "remindersListGroups"("id")
+```
+
+…then this will be problematic when older devices create reminders lists with no
+`remindersListGroupID`. In this situation you have no choice but to make the field optional in
+the type:
+
+```diff
+ @Table
+ struct RemindersList {
+ let id: UUID
+ var title = ""
+ var position = 0
+- var remindersListGroupID: RemindersListGroup.ID
++ var remindersListGroupID: RemindersListGroup.ID?
+ }
+```
+
+And your migration will need to add a nullable column to the table:
+
+```diff
+ ALTER TABLE "remindersLists"
+-ADD COLUMN "remindersListGroupID" TEXT NOT NULL
++ADD COLUMN "remindersListGroupID" TEXT
+ REFERENCES "remindersListGroups"("id")
+```
+
+It may be disappointing to have to weaken your domain modeling to accommodate synchronization, but
+that is the unfortunate reality of a distributed schema. In order to allow multiple versions of your
+schema to be run on devices so that each device can create new records and edit existing records
+that all devices can see, you will need to make some compromises.
+
+#### Disallowed migrations
+
+Certain kinds of migrations are simply not allowed when synchronizing your schema to multiple
+devices. They are:
+
+ * Removing columns
+ * Renaming columns
+ * Renaming tables
+
+## Record conflicts
+
+> TL;DR: Conflicts are handled automatically using a "last edit wins" strategy for each
+> column of the record.
+
+Conflicts between record edits will inevitably happen, and it's just a fact of dealing with
+distributed data. The library handles conflicts automatically, but does so with a single strategy
+that is currently not customizable. When a column is edited on a record, the library keeps track
+of the timestamp for that particular column. When merging two conflicting records, each column
+is analyzed, and the column that was most recently edited will win over the older data.
+
+We do not employ more advanced merge conflict strategies, such as CRDT synchronization. We may
+allow for these kinds of strategies in the future, but for now "field-wise last edit wins" is
+the only strategy available and we feel serves the needs of the most number of people.
+
+## Sharing records with other iCloud users
+
+SQLiteData provides the tools necessary to share a record with another iCloud user so that
+multiple users can collaborate on a single record. Sharing a record with another user brings
+extra complications to an app that go beyond the existing complications of sharing a schema
+across many devices. Please read the documentation carefully and thoroughly to understand
+how to best situate your app for sharing that does not cause problems down the road.
+
+See for more information.
+
+## Assets
+
+> TL;DR: The library packages all BLOB columns in a table into `CKAsset`s and seamlessly decodes
+> `CKAsset`s back into your tables. We recommend putting large binary blobs of data in their own
+> tables.
+
+All BLOB columns in a table are automatically turned into `CKAsset`s and synchronized to CloudKit.
+This process is completely seamless and you do not have to take any explicit steps to support
+assets.
+
+However, general database design guidelines still apply. In particular, it is not recommended to
+store large binary blobs in a table that is queried often. If done naively you may accidentally
+large amounts of data into memory when querying your table, and further large binary blobs can
+slow down SQLite's ability to efficiently access the rows in your tables.
+
+It is recommended to hold binary blobs in a separate, but related, table. For example, if you are
+building a reminders app that has lists, and you allow your users to assign an image to a list.
+One way to model this is a table for the reminders list data, without the image, and then another
+table for the image data associated with a reminders list. Further, the primary key of the cover
+image table can be the foreign key pointing to the associated reminders list:
+
+```swift
+@Table
+struct RemindersList: Identifiable {
+ let id: UUID
+ var title = ""
+}
+
+@Table
+struct RemindersListCoverImage {
+ @Column(primaryKey: true)
+ let remindersListID: RemindersList.ID
+ var image: Data
+}
+/*
+CREATE TABLE "remindersListCoverImages" (
+ "remindersListID" TEXT PRIMARY KEY NOT NULL REFERENCES "remindersLists"("id"),
+ "image" BLOB NOT NULL
+)
+*/
+```
+
+This allows you to efficiently query `RemindersList` while still allowing you to load the image
+data for a list when you need it.
+
+## Accessing CloudKit metadata
+
+While the library tries to make CloudKit synchronization as seamless and hidden as possible,
+there are times you will need to access the underlying CloudKit types for your tables and records.
+The ``SyncMetadata``table is the central place where this data is stored, and it is publicly
+exposed for you to query it in whichever way you want.
+
+> Important: In order to query the `SyncMetadata` table from your database connection you will need
+to attach the metadatabase to your database connection. This can be done with the
+``GRDB/Database/attachMetadatabase(containerIdentifier:)`` method defined on `Database`. See
+ for more information on how to do this.
+
+With that done you can use the ``StructuredQueriesCore/PrimaryKeyedTable/metadata(for:)`` method
+to construct a SQL query for fetching the meta data associated with one of your records.
+
+For example, if you want to retrieve the `CKRecord` that is associated with a particular row in
+one of your tables, say a reminder, then you can use ``SyncMetadata/lastKnownServerRecord`` to
+retrieve the `CKRecord` and then invoke a CloudKit database function to retrieve all of the details:
+
+```swift
+let lastKnownServerRecord = try database.read { db in
+ try RemindersList
+ .metadata(for: remindersListID)
+ .select(\.lastKnownServerRecord)
+ .fetchOne(db)
+ ?? nil
+}
+guard let lastKnownServerRecord
+else { return }
+
+let ckRecord = try await container.privateCloudDatabase
+ .record(for: lastKnownServerRecord.recordID)
+```
+
+> Important: In the above snippet we are explicitly using `privateCloudDatabase`, but that is
+> only appropriate if the user is the owner of the record. If the user is only a participant in
+> a shared record, which can be determined from [SyncMetadata.share](),
+> then you must use `sharedCloudDatabase` to fetch the newest record.
+
+You are free to invoke any CloudKit functions you want with the `CKRecord` retrieved from
+``SyncMetadata``. Any changes made directly with CloudKit will be automatically synced to your
+SQLite database by the ``SyncEngine``.
+
+It is also possible to fetch the `CKShare` associated with a record if it has been shared, which
+will give you access to the most current list of participants and permissions for the shared record:
+
+```swift
+let share = try database.read { db in
+ try RemindersList
+ .metadata(for: remindersListID)
+ .select(\.share)
+ .fetchOne(db)
+}
+guard let share
+else { return }
+
+let ckRecord = try await container.sharedCloudDatabase
+ .record(for: share.recordID)
+```
+
+> Important: In the above snippet we are using the `sharedCloudDatabase` and this is always
+appropriate to use when fetching the details of a `CKShare` as they are always stored in the
+shared database.
+
+It is also possible to join the ``SyncMetadata`` table directly to your tables so that you can
+select this additional information on a per-record basis. For example, if you want to select all
+reminders lists, along with a boolean that determines if it is shared or not, you can do the
+following:
+
+```swift
+@Selection struct Row {
+ let remindersList: RemindersList
+ let isShared: Bool
+}
+
+@FetchAll(
+ RemindersList
+ .leftJoin(SyncMetadata.all) { $0.hasMetadata(in: $1) }
+ .select {
+ Row.Columns(
+ remindersList: $0,
+ isShared: $1.isShared ?? false
+ )
+ }
+)
+var rows
+```
+
+Here we have used the ``StructuredQueriesCore/PrimaryKeyedTableDefinition/hasMetadata(in:)`` helper
+that is defined on all primary key tables so that we can join ``SyncMetadata`` to `RemindersList`.
+
+
+
+## Unit testing and Xcode previews
+
+It is possible to run your features in tests and previews even when using the ``SyncEngine``. You
+will need to prepare it for dependencies exactly as you do in the entry point of your app. This
+can lead to some code duplication, and so you may want to extract that work to a mutating
+`bootstrapDatabase` method on `DependencyValues` like so:
+
+```swift
+extension DependencyValues {
+ mutating func bootstrapDatabase() throws {
+ defaultDatabase = try Reminders.appDatabase()
+ defaultSyncEngine = try SyncEngine(
+ for: defaultDatabase,
+ tables: RemindersList.self,
+ RemindersListAsset.self,
+ Reminder.self,
+ Tag.self,
+ ReminderTag.self
+ )
+ }
+}
+```
+
+Then in your app entry point you can use it like so:
+
+```swift
+@main
+struct MyApp: App {
+ init() {
+ try! prepareDependencies {
+ try! $0.bootstrapDatabase()
+ }
+ }
+
+ // ...
+}
+```
+
+In tests you can use it like so:
+
+```swift
+@Suite(.dependencies { try! $0.bootstrapDatabase() })
+struct MySuite {
+ // ...
+}
+```
+
+And in preivews you can use it like so:
+
+```swift
+#Preview {
+ try! prepareDependencies {
+ try! $0.bootstrapDatabase()
+ }
+ // ...
+}
+```
+
+## Preparing an existing schema for synchronization
+
+If you have an existing app deployed to the app store using SQLite, then there may be a number
+of steps you must take to prepare for adding CloudKit synchronization, mostly having to do with
+primary keys.
+
+### Convert Int primary keys to UUID
+
+The most important step for migrating an existing SQLite database to be compatible with CloudKit
+synchronization is converting any `Int` primary keys in your tables to UUID, or some other
+globally unique identifier. This can be done in a new migration that is registered when provisioning
+your database, but it does take a few queries to accomplish because SQLite does not support
+changing the definition of an existing column.
+
+The steps are roughly: 1) create a table with the new schema, 2) copy data over from old
+table to new table and convert integer IDs to UUIDs, 3) drop the old table, and finally 4) rename
+the new table to have the same name as the old table.
+
+```swift
+migrator.registerMigration("Convert 'remindersLists' table primary key to UUID") { db in
+ // Step 1: Create new table with updated schema
+ try #sql("""
+ CREATE TABLE "new_remindersLists" (
+ "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()),
+ -- all other columns from 'remindersLists' table
+ ) STRICT
+ """)
+ .execute(db)
+
+ // Step 2: Copy data from 'remindersLists' to 'new_remindersLists' and convert integer
+ // IDs to UUIDs
+ try #sql("""
+ INSERT INTO "new_remindersLists"
+ (
+ "id",
+ -- all other columns from 'remindersLists' table
+ )
+ SELECT
+ -- This converts integers to UUIDs, e.g. 1 -> 00000000-0000-0000-0000-000000000001
+ '00000000-0000-0000-0000-' || printf('%012x', "id"),
+ -- all other columns from 'remindersLists' table
+ FROM "remindersLists"
+ """)
+ .execute(db)
+
+ // Step 3: Drop the old 'remindersLists' table
+ try #sql("""
+ DROP TABLE "remindersLists"
+ """)
+ .execute(db)
+
+ // Step 4: Rename 'new_remindersLists' to 'remindersLists'
+ try #sql("""
+ ALTER TABLE "new_remindersLists" RENAME TO "remindersLists"
+ """)
+ .execute(db)
+}
+```
+
+This will need to be done for every table that uses an integer for its primary key. Further,
+for tables with foreign keys, you will need to adapt step 1 to change the types of those
+columns to TEXT and will need to perform the integer-to-UUID conversion for those columns in
+step 2:
+
+```swift
+migrator.registerMigration("Convert 'reminders' table primary key to UUID") { db in
+ // Step 1: Create new table with updated schema
+ try #sql("""
+ CREATE TABLE "new_reminders" (
+ "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()),
+ "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE,
+ -- all other columns from 'reminders' table
+ ) STRICT
+ """)
+ .execute(db)
+
+ // Step 2: Copy data from 'reminders' to 'new_reminders' and convert integer
+ // IDs to UUIDs
+ try #sql("""
+ INSERT INTO "new_reminders"
+ (
+ "id",
+ "remindersListID",
+ -- all other columns from 'reminders' table
+ )
+ SELECT
+ -- This converts integers to UUIDs, e.g. 1 -> 00000000-0000-0000-0000-000000000001
+ '00000000-0000-0000-0000-' || printf('%012x', "id"),
+ '00000000-0000-0000-0000-' || printf('%012x', "remindersListID"),
+ -- all other columns from 'reminders' table
+ FROM "remindersLists"
+ """)
+ .execute(db)
+
+ // Step 3 and 4 are unchanged...
+}
+```
+
+### Add primary key to all tables
+
+All tables must have a primary key to be synchronized to CloudKit, even typically you would not
+add one to the table. For example, a join table that joins reminders to tags:
+
+```swift
+@Table
+struct ReminderTag {
+ let reminderID: Reminder.ID
+ let tagID: Tag.ID
+}
+```
+
+…must be updated to have a primary key:
+
+
+```diff
+ @Table
+ struct ReminderTag {
++ let id: UUID
+ let reminderID: Reminder.ID
+ let tagID: Tag.ID
+ }
+```
+
+And a migration must be run to add that column to the table. However, you must perform a multi-step
+migration similar to what is described above in .
+You must 1) create a new table with the new primary key column, 2) copy data from the old table
+to the new table, 3) delete the old table, and finally 4) rename the new table.
+
+Here is how such a migration can look like for the `ReminderTag` table above:
+
+```swift
+migrator.registerMigration("Add primary key to 'reminderTags' table") { db in
+ // Step 1: Create new table with updated schema
+ try #sql("""
+ CREATE TABLE "new_reminderTags" (
+ "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()),
+ "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE,
+ "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE
+ ) STRICT
+ """)
+ .execute(db)
+
+ // Step 2: Copy data from 'reminderTags' to 'new_reminderTags'
+ try #sql("""
+ INSERT INTO "new_reminderTags"
+ ("reminderID", "tagID")
+ SELECT "reminderID", "tagID"
+ FROM "reminderTags"
+ """)
+ .execute(db)
+
+ // Step 3: Drop the old 'reminderTags' table
+ try #sql("""
+ DROP TABLE "reminderTags"
+ """)
+ .execute(db)
+
+ // Step 4: Rename 'new_reminderTags' to 'reminderTags'
+ try #sql("""
+ ALTER TABLE "new_reminderTags" RENAME TO "reminderTags"
+ """)
+ .execute(db)
+}
+```
+
+## Tips and tricks
+
+### Updating triggers to be compatible with synchronization
+
+If you have triggers installed on your tables, then you may want to customize their definitions
+to behave differently depending on whether a write is happening to your database from your own
+code or from the sync engine. For example, if you have a trigger that refreshes an `updatedAt`
+timestamp on a row when it is edited, it would not be appropriate to do that when the sync engine
+updates a row from data received from CloudKit. But, if you have a trigger that updates a local
+[FTS] index, then you would want to perform that work regardless if your app is updating the data
+or CloudKit is updating the data.
+
+[FTS]: https://sqlite.org/fts5.html
+
+To customize this behavior you can use the ``SyncEngine/isSynchronizingChanges()`` SQL expression.
+It represents a custom database function that is installed in your database connection, and it will
+return true if the write to your database originates from the sync engine. You can use it in a
+trigger like so:
+
+```swift
+#sql(
+ """
+ CREATE TEMPORARY TRIGGER "…"
+ AFTER DELETE ON "…"
+ FOR EACH ROW WHEN NOT \(SyncEngine.isSynchronizingChanges())
+ BEGIN
+ …
+ END
+ """
+)
+```
+
+Or if you are using the trigger building tools from [StructuredQueries] you can use it like so:
+
+[StructuredQueries]: https://github.com/pointfreeco/swift-structured-queries
+
+```swift
+Model.createTemporaryTrigger(
+ after: .insert { new in
+ // ...
+ } when: { _ in
+ !SyncEngine.isSynchronizingChanges()
+ }
+)
+```
+
+This will skip the trigger's action when the row is being updated due to data being synchronized
+from CloudKit.
+
+### Developing in the simulator
+
+It is possible to develop your app with CloudKit synchronization using the iOS simulator, but
+you must be aware that simulators do not support push notifications, and so changes do not
+synchronize from CloudKit to simulator automatically. Sometimes you can simply close and re-open
+the app to have the simulator sync with CloudKit, but the most certain way to force synchronization
+is to kill the app and relaunch it fresh.
diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md
new file mode 100644
index 00000000..01e5e32e
--- /dev/null
+++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md
@@ -0,0 +1,473 @@
+# Sharing data with other iCloud users
+
+Learn how to allow your users to share certain records with other iCloud users for collaboration.
+
+## Overview
+
+SQLiteData provides the tools necessary to share a record with another iCloud user so that multiple
+users can collaborate on a single record. Sharing a record with another user brings extra
+complications to an app that go beyond the existing complications of sharing a schema across many
+devices. Please read the documentation carefully and thoroughly to understand how to best design
+your schema for sharing that does not cause problems down the road.
+
+> Important: To enable sharing of records be sure to add a `CKSharingSupported` key to your
+Info.plist with a value of `true`. This is subtly documented in [Apple's documentation for sharing].
+
+[Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic
+
+ - [Creating CKShare records](#Creating-CKShare-records)
+ - [Accepting shared records](#Accepting-shared-records)
+ - [Diving deeper into sharing](#Diving-deeper-into-sharing)
+ - [Sharing root records](#Sharing-root-records)
+ - [Sharing foreign key relationships](#Sharing-foreign-key-relationships)
+ - [One-to-many relationships](#One-to-many-relationships)
+ - [Many-to-many relationships](#Many-to-many-relationships)
+ - [One-to-"at most one" relationships](#One-to-at-most-one-relationships)
+ - [Sharing permissions](#Sharing-permissions)
+ - [Controlling what data is shared](#Controlling-what-data-is-shared)
+ - [Querying share metadata](#Querying-share-metadata)
+
+## Creating CKShare records
+
+To share a record with another user one must first create a `CKShare`. SQLiteData provides the
+method ``SyncEngine/share(record:configure:)`` on ``SyncEngine`` for generating a `CKShare` for a
+record. Further, the value returned from this method can be stored in a view and be used to drive a
+sheet to display a ``CloudSharingView``, which is a wrapper around UIKit's
+`UICloudSharingController`.
+
+As an example, a reminders app that wants to allow sharing a reminders list with another user can do
+so like this:
+
+```swift
+struct RemindersListView: View {
+ let remindersList: RemindersList
+ @State var sharedRecord: SharedRecord?
+
+ var body: some View {
+ Form {
+ …
+ }
+ .toolbar {
+ Button("Share") {
+ Task {
+ await withErrorReporting {
+ sharedRecord = try await syncEngine.share(record: remindersList) { share in
+ share[CKShare.SystemFieldKey.title] = "Join '\(remindersList.title)'!"
+ }
+ }
+ }
+ }
+ }
+ .sheet(item: $sharedRecord) { sharedRecord in
+ CloudSharingView(sharedRecord: sharedRecord)
+ }
+ }
+}
+```
+
+When the "Share" button is tapped, a ``SharedRecord`` will be generated and stored as local state in
+the view. That will cause a ``CloudSharingView`` sheet to be presented where the user can configure
+how they want to share the record. A record can be _unshared_ by presenting the same
+``CloudSharingView`` to the user so that they can tap the "Stop sharing" button in the UI.
+
+If you would like to provide a custom sharing experience outside of what `UICloudSharingController`
+offers, you can find more info in [Apple's documentation].
+
+[Apple's documentation]: https://developer.apple.com/documentation/cloudkit/shared-records
+
+## Accepting shared records
+
+Extra steps must be taken to allow a user to _accept_ a shared record. Once the user taps on the
+share link sent to them (whether that is by text, email, etc.), the app will be launched with
+special options provided or a special delegate method will be invoked in the app's scene delegate.
+You must implement these delegate methods and invoke the ``SyncEngine/acceptShare(metadata:)``
+method.
+
+As a simplified example, a `UIWindowSceneDelegate` subclass can implement the delegate method like
+so:
+
+```swift
+class SceneDelegate: UIResponder, UIWindowSceneDelegate {
+ @Dependency(\.defaultSyncEngine) var syncEngine
+ var window: UIWindow?
+
+ func windowScene(
+ _ windowScene: UIWindowScene,
+ userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata
+ ) {
+ Task {
+ try await syncEngine.acceptShare(metadata: cloudKitShareMetadata)
+ }
+ }
+
+ func scene(
+ _ scene: UIScene,
+ willConnectTo session: UISceneSession,
+ options connectionOptions: UIScene.ConnectionOptions
+ ) {
+ guard let cloudKitShareMetadata = connectionOptions.cloudKitShareMetadata
+ else {
+ return
+ }
+ Task {
+ try await syncEngine.acceptShare(metadata: cloudKitShareMetadata)
+ }
+ }
+}
+```
+
+The unstructured task is necessary because the delegate method does not work with an async context,
+and the `acceptShare` method is async.
+
+## Diving deeper into sharing
+
+The above gives a broad overview of how one shares a record with a user, and how a user accepts a
+shared record. There is, however, a lot more to know about sharing. There are important restrictions
+placed on what kind of records you are allowed to share, and what associations of those records are
+shared.
+
+In a nutshell, only "root" records can be directly shared, _i.e._ records with no foreign keys.
+Further, an association of a root record can only be shared if it has only one foreign key pointing
+to the root record. And this last rule applies recursively: a leaf association is shared only if
+it has exactly one foreign key pointing to a record that also satisfies this property.
+
+For more in-depth information, keep reading.
+
+### Sharing root records
+
+> Important: It is only possible to share "root" records, _i.e._ records with no foreign keys.
+
+A record can be shared only if it is a "root" record. That means it cannot have any
+foreign keys whatsoever. As an example, the following `RemindersList` table is a root record because
+it does not have any fields pointing to other tables:
+
+```swift
+@Table
+struct RemindersList: Identifiable {
+ let id: UUID
+ var title = ""
+}
+```
+
+On the other hand, a `Reminder` table with a foreign key pointing to the `RemindersList` is _not_
+a root record:
+
+```swift
+@Table
+struct Reminder: Identifiable {
+ let id: UUID
+ var title = ""
+ var isCompleted = false
+ var remindersListID: RemindersList.ID
+}
+```
+
+Such records cannot be shared because it is not appropriate to also share the parent record (_i.e._
+the reminders list).
+
+For example, suppose you have a list named "Personal" with a reminder "Get milk". If you share this
+reminder with someone, then it becomes difficult to figure out what to do when they make certain
+changes to the reminder:
+
+ * If they decide to reassign the reminder to their personal "Life" list, what should
+ happen? Should their "Life" list suddenly be synchronized to your device?
+ * Or what if they delete the list? Would you want that to delete your list and all of the
+ reminders in the list?
+
+For these reasons, and more, it is not possible to share non-root records, like reminders. Instead,
+you can share root records, like reminders lists. If you do invoke
+``SyncEngine/share(record:configure:)`` with a non-root record, an error will be thrown.
+
+> Note: A reminder can still be shared as an association to a shared reminders list, as discussed
+> [in the next section](). However, a single
+> reminder cannot be shared on its own.
+
+For a more complex example, consider the following diagrammatic schema for a reminders app:
+
+@Image(source: "sync-diagram-root-record.png") {
+ The green node represents a "root" record, i.e. a record with no foreign key relationships.
+}
+
+In this schema, a `RemindersList` can have many `Reminder`s, can have a `CoverImage`, and a
+`Reminder` can have multiple `Tag`s, and vice-versa. The only table in this diagram that constitutes
+a "root" is `RemindersList`. It is the only one with no foreign key relationships. None of
+`Reminder`, `CoverImage`, `Tag` or `ReminderTag` can be directly shared on their own because they
+are not root tables.
+
+#### Sharing foreign key relationships
+
+> Important: Foreign key relationships are automatically synchronized, but only if the related
+> record has a single foreign key. Records with multiple foreign keys cannot be synchronized.
+
+Relationships between models will automatically be shared when sharing a root record, but with some
+limitations. An associated record of a shared record will only be shared if it has exactly one
+foreign key pointing to the root shared record, whether directly or indirectly through other records
+satisfying this property.
+
+Below we describe some of the most common types of relationships in SQL databases, as well as
+which are possible to synchronize, which cannot be synchronized, and which can be adapted to
+play nicely with synchronization.
+
+##### One-to-many relationships
+
+One-to-many relationships are the simplest to share with other users. As an example, consider a
+`RemindersList` table that can have many `Reminder`s associated with it:
+
+```swift
+@Table
+struct RemindersList: Identifiable {
+ let id: UUID
+ var title = ""
+}
+
+@Table
+struct Reminder: Identifiable {
+ let id: UUID
+ var title = ""
+ var isCompleted = false
+ var remindersListID: RemindersList.ID
+}
+```
+
+Since `RemindersList` is a [root record](#Sharing-root-records) it can be shared, and since
+`Reminder` has only one foreign key pointing to `RemindersList`, it too will be shared.
+
+Further, suppose there was a `ChildReminder` table that had a single foreign key pointing to a
+`Reminder`:
+
+```swift
+@Table
+struct ChildReminder: Identifiable {
+ let id: UUID
+ var title = ""
+ var isCompleted = false
+ var parentReminderID: Reminders.ID
+}
+```
+
+This too will be shared because it has one single foreign key pointing to a table that also has one
+single foreign key pointing to the root record being shared.
+
+As a more complex example, consider the following diagrammatic schema:
+
+@Image(source: "sync-diagram-one-to-many.png") {
+ The green node is a shareable root record, and all blue records are relationships that will also
+ be shared when the root is shared.
+}
+
+In this schema, a `RemindersList` can have many `Reminder`s and a `CoverImage`, and a `Reminder` can
+have many `ChildReminder`s. Sharing a `RemindersList` will share all associated reminders, cover
+image, and even child reminders. The child reminders are synchronized because it has a single
+foreign key pointing to a table that also has a single foreign key pointing to the root record.
+
+##### Many-to-many relationships
+
+Many-to-many relationships pose a significant problem to sharing and cannot be supported. If a table
+has multiple foreign keys, then it will not be shared even if one of those foreign keys points to
+the shared record.
+
+As an example, suppose we had a many-to-many association of a `Tag` table to `Reminder` via a
+`ReminderTag` join table:
+
+```swift
+@Table
+struct Tag: Identifiable {
+ let id: UUID
+ var title = ""
+}
+@Table
+struct ReminderTag: Identifiable {
+ let id: UUID
+ var reminderID: Reminder.ID
+ var tagID: Tag.ID
+}
+```
+
+In diagrammatic form, this schema looks like the following:
+
+@Image(source: sync-diagram-many-to-many.png) {
+ The green record is a shareable record, the blue record will be shared when the root is shared,
+ and the light purple records cannot be shared.
+}
+
+The `ReminderTag` records will _not_ be shared because it has two foreign key relationships,
+represented by the two arrows leaving the `ReminderTag` node. As a consequence, the `Tag` records
+will also not be shared. Sharing these records cannot be done in a consistent and logical manner.
+
+> Note: `CKShare` in CloudKit, which is what our tools are built on, does not support sharing
+> many-to-many relationships. This is also how the Reminders app works on Apple's platforms. Sharing
+> a list of reminders with another use does not share its tags with that user.
+
+To see why this is an acceptable limitation, suppose you share a "Personal" list with someone, which
+holds a "Get milk" reminder, and that reminder has a "weekend" tag associated with it. If the tag
+were shared with your friend, then what happens when they delete the tag? Would it be appropriate to
+delete that tag from all of your reminders, even the ones that were not shared? For this reason,
+and more, records with multiple foreign keys cannot be shared with a record.
+
+If you want to support many tags associated with a single reminder, you will have no choice
+but to turn it into a one-to-many relationship so that each tag belongs to exactly one reminder:
+
+```swift
+@Table
+struct Tag: Identifiable {
+ let id: UUID
+ var title = ""
+ var reminderID: Reminder.ID
+}
+```
+
+In diagrammatic form this schema now looks like the following:
+
+@Image(source: sync-diagram-many-to-many-refactor.png) {
+ The green record is a shareable root record, and the blue records will be shared when the root is
+ shared.
+}
+
+This kind of relationship will now be synchronized automatically. Sharing a `RemindersList` will
+automatically share all of its `Reminder`s, which will subsequently also share all of their
+`Tag`s.
+
+But, this does now mean it's possible to have multiple `Tag` rows in the database that have the
+same title and thus represent the same tag. You wil have to put extra care in your queries and
+application logic to properly aggregate these tags together, but luckily this is something that SQL
+excels at.
+
+##### One-to-"at most one" relationships
+
+One-to-"at most one" relationships in SQLite allow you to associate zero or one records with
+another record. For an example of this, suppose we wanted to hold onto a cover image for reminders
+lists (see for more information on synchronizing assets such as images). It
+is perfectly fine to hold onto large binary data in SQLite, such as image data, but typically one
+should put this data in a separate table.
+
+The way to model this kind of relationship in SQLite is by making a foreign key point from the image
+table to the reminders list table, _and_ to make that foreign key the primary key of the table. That
+enforces that at most one image is associated with a reminders list.
+
+In diagrammatic form, it looks like this:
+
+
+
+
+Here the `CoverImage` table has a foreign key pointing to the root table `RemindersList`, but since
+it is also the primary key of the table it enforces that at most one cover image belongs to a list.
+
+## Sharing permissions
+
+CloudKit sharing supports permissions so that you can give read-only or read-write access to the
+data you share with other users. These permissions are automatically observed by the library and
+enforced when writing to your database. If your application tries to write to a record that it
+does not have permission for, a `DatabaseError` will be emitted.
+
+To check for this error you can catch `DatabaseError` and compare its message to
+``SyncEngine/writePermissionError``:
+
+```swift
+do {
+ try await database.write { db in
+ Reminder.find(id)
+ .update { $0.title = "Personal" }
+ .execute(db)
+ }
+} catch let error as DatabaseError where error.message == SyncEngine.writePermissionError {
+ // User does not have permission to write to this record.
+}
+```
+
+See for more information on accessing the metadata
+associated with your user's data.
+
+Ideally your app would not allow the user to write to records that they do not have permissions for.
+To check their permissions for a record, you can join the root record table to ``SyncMetadata`` and
+select the ``SyncMetadata/share`` value:
+
+```swift
+let share = try await database.read { db in
+ RemindersList
+ .metadata(for: id)
+ .select(\.share)
+ .fetchOne(db)
+ ?? nil
+}
+guard
+ share?.currentUserParticipant?.permission == .readWrite
+ || share?.permission == .readWrite
+else {
+ // User does not have permissions to write to record.
+ return
+}
+```
+
+This allows you to determine the sharing permissions for a root record.
+
+## Controlling what data is shared
+
+It is possible to specify that certain associations that are shareable not be shared. For example,
+suppose that you want reminders lists to be sorted by your user, and so add a `position` column to
+the table:
+
+```swift
+@Table
+struct RemindersList: Identifiable {
+ let id: UUID
+ var position = 0
+ var title = ""
+}
+```
+
+Sharing this record will mean also sharing the position of the list. That means when one user
+reorders their local lists, even ones that are private to them, it will reorder the lists for
+everyone shared. This is probably not what you want.
+
+So, private and non-shareable information about this record can be stored in a separate table, and
+we can use the trick mentioned in by making
+the foreign key of the table also be the table's primary key:
+
+```swift
+@Table
+struct RemindersList: Identifiable {
+ let id: UUID
+ var title = ""
+}
+@Table
+struct RemindersListPrivate: Identifiable {
+ @Column(primaryKey: true)
+ let remindersListID: RemindersList.ID
+ var position = 0
+}
+```
+
+And then when creating the ``SyncEngine`` we can specifically ask it to not share this record when
+the reminders list is shared by specifying the `privateTables` argument:
+
+```swift
+@main
+struct MyApp: App {
+ init() {
+ try! prepareDependencies {
+ $0.defaultDatabase = try appDatabase()
+ $0.defaultSyncEngine = try SyncEngine(
+ for: $0.defaultDatabase,
+ tables: RemindersList.self, Reminder.self,
+ privateTables: RemindersListPrivate.self
+ )
+ }
+ }
+
+ …
+}
+```
+
+This table will still be synchronized across all of a single user's devices, but if that user
+shares a list with a friend, it will _not_ share the private table, allowing each user to have
+their own personal ordering of lists.
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md
similarity index 80%
rename from Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md
rename to Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md
index 9cac0d12..3f2eadfb 100644
--- a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md
+++ b/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md
@@ -1,10 +1,10 @@
# Comparison with SwiftData
-Learn how SharingGRDB compares to SwiftData when solving a variety of problems.
+Learn how SQLiteData compares to SwiftData when solving a variety of problems.
## Overview
-The SharingGRDB library can replace SwiftData for many kinds of apps, and provide additional
+The SQLiteData library can replace SwiftData for many kinds of apps, and provide additional
benefits such as direct access to the underlying SQLite schema, and better integration outside of
SwiftUI views (including UIKit, `@Observable` models, _etc._). This article describes how the two
approaches compare in a variety of situations, such as setting up the data store, fetching data,
@@ -21,12 +21,13 @@ associations, and more.
* [Migrations](#Migrations)
* [Lightweight migrations](#Lightweight-migrations)
* [Manual migrations](#Manual-migrations)
+ * [CloudKit](#CloudKit)
* [Supported Apple platforms](#Supported-Apple-platforms)
### Defining your schema
-Both SharingGRDB and SwiftData come with tools to expose your data types' fields to the compiler
-so that type-safe and schema-safe queries can be written. SharingGRDB uses another library of ours
+Both SQLiteData and SwiftData come with tools to expose your data types' fields to the compiler
+so that type-safe and schema-safe queries can be written. SQLiteData uses another library of ours
to provide these tools, called [StructuredQueries][sq-gh], and its `@Table` macro works similarly
to SwiftData's `@Model` macro:
@@ -35,12 +36,12 @@ to SwiftData's `@Model` macro:
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
@Table
struct Item {
- let id: Int
+ let id: UUID
var title = ""
- var isInStock = true
+ var isInStock = true
var notes = ""
}
```
@@ -54,8 +55,8 @@ to SwiftData's `@Model` macro:
var isInStock: Bool
var notes: String
init(
- title: String = "",
- isInStock: Bool = true,
+ title: String = "",
+ isInStock: Bool = true,
notes: String = ""
) {
self.title = title
@@ -81,15 +82,15 @@ to define your schema.
### Setting up external storage
-Both SharingGRDB and SwiftData require some work to be done at the entry point of the app in order
-to set up the external storage system that will be used throughout the app. In SharingGRDB we use
+Both SQLiteData and SwiftData require some work to be done at the entry point of the app in order
+to set up the external storage system that will be used throughout the app. In SQLiteData we use
the `prepareDependencies` function to set up the default database used, and in SwiftUI you construct
a `ModelContainer` and propagate it through the environment:
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
@main
struct MyApp: App {
init() {
@@ -108,11 +109,11 @@ a `ModelContainer` and propagate it through the environment:
// SwiftData
@main
struct MyApp: App {
- let container = {
+ let container = {
// Create/configure a container
try! ModelContainer(/* ... */)
}()
-
+
var body: some Scene {
WindowGroup {
ContentView()
@@ -125,21 +126,21 @@ a `ModelContainer` and propagate it through the environment:
}
See for more advice on the various ways you will want to create and
-configure your SQLite database for use with SharingGRDB.
+configure your SQLite database for use with SQLiteData.
### Fetching data for a view
-To fetch data from a SQLite database you use the `@FetchAll` property wrapper in SharingGRDB,
+To fetch data from a SQLite database you use the `@FetchAll` property wrapper in SQLiteData,
whereas you use the `@Query` macro with SwiftData:
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
struct ItemsView: View {
@FetchAll(Item.order(by: \.title))
var items
-
+
var body: some View {
ForEach(items) { item in
Text(item.name)
@@ -154,7 +155,7 @@ whereas you use the `@Query` macro with SwiftData:
struct ItemsView: View {
@Query(sort: \Item.title)
var items: [Item]
-
+
var body: some View {
ForEach(items) { item in
Text(item.name)
@@ -165,8 +166,8 @@ whereas you use the `@Query` macro with SwiftData:
}
}
-The `@FetchAll` property wrapper takes a variety of options and allows you to write queries using a
-type-safe and schema-safe builder syntax, or you can write safe SQL strings that are schema-safe and
+The `@FetchAll` property wrapper takes a variety of options and allows you to write queries using a
+type-safe and schema-safe builder syntax, or you can write safe SQL strings that are schema-safe and
protect you from SQL injection.
The library also ships a few other property wrappres that have no equivalent in SwiftData. For
@@ -186,8 +187,8 @@ data from your database using the tools of this library.
### Fetching data for an @Observable model
There are many reasons one may want to move logic out of the view and into an `@Observable` model,
-such as allowing to unit test your feature's logic, and making it possible to deep link in your
-app. The `@FetchAll` property warpper, and other [data fetching tools]() work just as
+such as allowing to unit test your feature's logic, and making it possible to deep link in your
+app. The `@FetchAll` property warpper, and other [data fetching tools]() work just as
well in an `@Observable` model as they do in a SwiftUI view. The state held in the property wrapper
automatically updates when changes are made to the database.
@@ -198,7 +199,7 @@ its functionality from scratch:
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
@Observable
class FeatureModel {
@ObservationIgnored
@@ -227,11 +228,11 @@ its functionality from scratch:
}
fetchItems()
}
-
+
deinit {
NotificationCenter.default.removeObserver(observer)
}
-
+
func fetchItems() {
do {
items = try modelContext.fetch(
@@ -247,7 +248,7 @@ its functionality from scratch:
}
}
-> Note: It is necessary to annotate `@FetchAll` with `@ObservationIgnored` when using the
+> Note: It is necessary to annotate `@FetchAll` with `@ObservationIgnored` when using the
> `@Observable` macro due to how macros interact with property wrappers. However, `@FetchAll`
> handles its own observation, and so state will still be observed when accessed in a view.
@@ -260,11 +261,11 @@ search for rows in a table:
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
struct ItemsView: View {
@State var searchText = ""
@FetchAll var items: [Item]
-
+
var body: some View {
ForEach(items) { item in
Text(item.name)
@@ -274,7 +275,7 @@ search for rows in a table:
await updateSearchQuery()
}
}
-
+
func updateSearchQuery() {
await $items.load(
.fetchAll(
@@ -292,7 +293,7 @@ search for rows in a table:
// SwiftData
struct ItemsView: View {
@State var searchText = ""
-
+
var body: some View {
SearchResultsView(
searchText: searchText
@@ -300,18 +301,18 @@ search for rows in a table:
.searchable(text: $searchText)
}
}
-
+
struct SearchResultsView: View {
@Query var items: [Item]
-
+
init(searchText: String) {
_items = Query(
- filter: #Predicate- {
- $0.title.contains(searchText)
+ filter: #Predicate
- {
+ $0.title.contains(searchText)
}
)
}
-
+
var body: some View {
ForEach(items) { item in
Text(item.name)
@@ -322,10 +323,10 @@ search for rows in a table:
}
}
-Note that the SwiftData version of this code must have two views. The outer view, `ItemsView`,
+Note that the SwiftData version of this code must have two views. The outer view, `ItemsView`,
holds onto the `searchText` state that the user can change and uses the `searchable` SwiftUI view
modifier. Then, the inner view, `SearchResultsView`, holds onto the `@Query` state so that it can
-initialize with a dynamic predicate based on the `searchText`. These two views are necessary
+initialize with a dynamic predicate based on the `searchText`. These two views are necessary
because `@Query` state is not mutable after it is initialized. The only way to change `@Query`
state is if the view holding it is reinitialized, which requires a parent view to recreate the
child view.
@@ -347,7 +348,7 @@ For example, to get access to `defaultDatabase`, you use the `@Dependency` prope
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
@Dependency(\.defaultDatabase) var database
```
}
@@ -359,14 +360,14 @@ For example, to get access to `defaultDatabase`, you use the `@Dependency` prope
}
}
-Then, to create a new row in a table you use the `write` and `insert` methods from SharingGRDB:
+Then, to create a new row in a table you use the `write` and `insert` methods from SQLiteData:
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
@Dependency(\.defaultDatabase) var database
-
+
try database.write { db in
try Item.insert(Item(/* ... */))
.execute(db)
@@ -377,7 +378,7 @@ Then, to create a new row in a table you use the `write` and `insert` methods fr
```swift
// SwiftData
@Environment(\.modelContext) var modelContext
-
+
let newItem = Item(/* ... */)
modelContext.insert(newItem)
try modelContext.save()
@@ -385,14 +386,14 @@ Then, to create a new row in a table you use the `write` and `insert` methods fr
}
}
-To update an existing row you can use the `write` and `update` methods from SharingGRDB:
+To update an existing row you can use the `write` and `update` methods from SQLiteData:
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
@Dependency(\.defaultDatabase) var database
-
+
existingItem.title = "Computer"
try database.write { db in
try Item.update(existingItem).execute(db)
@@ -403,21 +404,21 @@ To update an existing row you can use the `write` and `update` methods from Shar
```swift
// SwiftData
@Environment(\.modelContext) var modelContext
-
+
existingItem.title = "Computer"
try modelContext.save()
```
}
}
-And to delete an existing row, you can use the `write` and `delete` methods from SharingGRDB:
+And to delete an existing row, you can use the `write` and `delete` methods from SQLiteData:
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
@Dependency(\.defaultDatabase) var database
-
+
try database.write { db in
try Item.delete(existingItem).execute(db)
}
@@ -427,7 +428,7 @@ And to delete an existing row, you can use the `write` and `delete` methods from
```swift
// SwiftData
@Environment(\.modelContext) var modelContext
-
+
modelContext.delete(existingItem))
try modelContext.save()
```
@@ -436,8 +437,8 @@ And to delete an existing row, you can use the `write` and `delete` methods from
### Associations
-The biggest difference between SwiftData and SharingGRDB is that SwiftData provides tools for an
-Object Relational Mapping (ORM), whereas SharingGRDB is largely just a nice API for interacting with SQLite
+The biggest difference between SwiftData and SQLiteData is that SwiftData provides tools for an
+Object Relational Mapping (ORM), whereas SQLiteData is largely just a nice API for interacting with SQLite
directly.
For example, SwiftData allows you to model a `Sport` type that belongs to many `Team`s like
@@ -469,11 +470,11 @@ mechanism to work is for `Team` and `Sport` to be classes, and the `@Model` macr
Second, because the SQLite execution is so abstracted from us, it makes it easy to execute many,
_many_ queries, leading to inefficient code. In this case, we are first executing a query to
get all sports, and then executing a query for each sport to get the number of teams in each
-sport. And on top of that, we are loading every team into memory just to compute the number of
+sport. And on top of that, we are loading every team into memory just to compute the number of
teams. We don't actually need any data from the team, only their aggregate count.
-SharingGRDB does not provide these kinds of tools, and for good reason. Instead, if you know you
-want to fetch all of the teams with their corresponding sport, you can simply perform a single
+SQLiteData does not provide these kinds of tools, and for good reason. Instead, if you know you
+want to fetch all of the teams with their corresponding sport, you can simply perform a single
query that joins the two tables together:
```swift
@@ -498,12 +499,12 @@ If either of the "sports" or "teams" tables change, this query will be executed
state will update to the freshest values.
This style of handling associations does require you to be knowledgable in SQL to wield it
-correctly, but that is a benefit! SQL (and SQLite) are some of the most proven pieces of
+correctly, but that is a benefit! SQL (and SQLite) are some of the most proven pieces of
technologies in the history of computers, and knowing how to wield their powers is a huge benefit.
### Booleans and enums
-While it may be hard to believe at first, SwiftData does not fully support boolean or enum values
+While it may be hard to believe at first, SwiftData does not fully support boolean or enum values
for fields of a model. Take for example this following model:
```swift
@@ -512,11 +513,11 @@ class Reminder {
var isCompleted = false
var priority: Priority?
init(isCompleted: Bool = false, priority: Priority? = nil) {
- self.isCompleted = isCompleted
+ self.isCompleted = isCompleted
self.priority = priority
}
- enum Priority: Int, Codable {
+ enum Priority: Int, Codable {
case low, medium, high
}
}
@@ -548,7 +549,7 @@ class Reminder {
var isCompleted = 0
var priority: Int?
init(isCompleted: Int = 0, priority: Int? = nil) {
- self.isCompleted = isCompleted
+ self.isCompleted = isCompleted
self.priority = priority
}
}
@@ -563,14 +564,14 @@ var highPriorityReminders: [Reminder]
This will now work, but of course these fields can now hold over 9 quintillion possible values when
only a few values are valid.
-On the other hand, booleans and enums work just fine in SharingGRDB:
+On the other hand, booleans and enums work just fine in SQLiteData:
```swift
@Table
struct Reminder {
var isCompleted = false
var priority: Priority?
- enum Priority: Int, QueryBindable {
+ enum Priority: Int, QueryBindable {
case low, medium, high
}
}
@@ -594,7 +595,7 @@ can even leave off the type annotation for `reminders` because it is inferred fr
explicit where you make direct changes to the schemas in your database. This includes creating
tables, adding, removing or altering columns, adding or removing indices, and more.
-Whereas SwiftData has two flavors of migrations. The simplest, "lightweight" migrations, work
+Whereas SwiftData has two flavors of migrations. The simplest, "lightweight" migrations, work
implicitly by comparing your data types to the database schema and updating the schema accordingly.
That cannot always work, and so there are "manual" migrations where you explicitly describe how
to change the database schema.
@@ -606,14 +607,14 @@ Lightweight migrations in SwiftData work for simple situations, such as adding a
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
@Table
struct Item {
- let id: Int
+ let id: UUID
var title = ""
var isInStock = true
}
-
+
migrator.registerMigration("Create 'items' table") { db in
try #sql(
"""
@@ -640,7 +641,7 @@ Lightweight migrations in SwiftData work for simple situations, such as adding a
}
}
-Note that in GRDB we must explicitly create the table, specify its columns, as well as its
+Note that in GRDB we must explicitly create the table, specify its columns, as well as its
constraints, such as if it is nullable or has a default value.
Similarly, adding a column to a data type is also a lightweight migration in SwiftData, such as
@@ -651,16 +652,16 @@ adding a `description` field to the `Item` type:
```swift
@Table
struct Item {
- let id: Int
+ let id: UUID
var title = ""
var description = ""
var isInStock = true
}
-
+
migrator.registerMigration("Add 'description' column to 'items'") { db in
try #sql(
"""
- ALTER TABLE "items"
+ ALTER TABLE "items"
ADD COLUMN "description" TEXT
"""
)
@@ -681,21 +682,21 @@ adding a `description` field to the `Item` type:
}
}
-In each of these cases, the lightweight migration of SwiftData is less code and the actual
+In each of these cases, the lightweight migration of SwiftData is less code and the actual
migration logic is implicit and hidden away from you.
#### Manual migrations
However, unfortunately, not all migrations can be "lightweight". In fact, from our experience,
-real world apps tend to require complex logic when performing most migrations. Something as simple
+real world apps tend to require complex logic when performing most migrations. Something as simple
as changing an optional field to be a non-optional field cannot be done as a lightweight migration
-since SwiftData does not know what value to insert into the database for any rows with a NULL
+since SwiftData does not know what value to insert into the database for any rows with a NULL
value. Even adding a unique index to a column is not possible because that may introduce constraint
errors if two rows have the same value.
-For the times that a lightweight migration is not possible in SwiftData, one must turn to
+For the times that a lightweight migration is not possible in SwiftData, one must turn to
"manual" migrations via the `VersionedSchema` protocol. As an example, consider adding a unique
-index on the "title" column of the "items" table.
+index on the "title" column of the "items" table.
In GRDB this is a simple two-step process:
@@ -704,7 +705,7 @@ In GRDB this is a simple two-step process:
incorporate a "#" suffix to differentiate between items with the same name.
1. Add the unique index.
-In SwiftData this is a much more involved process since migrations are implicitly tied to the
+In SwiftData this is a much more involved process since migrations are implicitly tied to the
structure of your data types. The overall steps to follow are as such:
1. Create a type that conforms to the `VersionedSchema` protocol, which represents the current
@@ -714,7 +715,7 @@ structure of your data types. The overall steps to follow are as such:
1. Duplicate the entire `@Model` data type so that you can specify the unique index. This type
will need a new name so as to not conflict with the current, and so often it is nested in
the type created in the previous step.
- 1. Because you now have different data types representing `Item` it is customary to add a
+ 1. Because you now have different data types representing `Item` it is customary to add a
type alias that represents the most "current" version of the `Item`.
1. Create a type that conforms to the `SchemaMigrationPlan` which allows you to specify the
"stages" that will be executed when a migration is performed.
@@ -726,7 +727,7 @@ structure of your data types. The overall steps to follow are as such:
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
migrator.registerMigration("Make 'title' unique") { db in
// 1️⃣ Delete all items that have duplicate title, keeping the first created one:
try Item
@@ -742,8 +743,8 @@ structure of your data types. The overall steps to follow are as such:
// 2️⃣ Create unique index
try #sql(
"""
- CREATE UNIQUE INDEX
- "items_title" ON "items" ("title")
+ CREATE UNIQUE INDEX
+ "items_title" ON "items" ("title")
"""
)
.execute(db)
@@ -763,7 +764,7 @@ structure of your data types. The overall steps to follow are as such:
var isInStock = true
}
}
-
+
// 2️⃣ Create type to conform to VersionedSchema:
enum Schema2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
@@ -777,19 +778,19 @@ structure of your data types. The overall steps to follow are as such:
var isInStock = true
}
}
-
+
// 4️⃣ Create a type alias for the newest Item schema:
typealias Item = Schema2.Item
-
+
// 5️⃣ Create a type to conform to the SchemaMigrationPlan protocol:
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[
- Schema1.self,
+ Schema1.self,
Schema2.self
]
}
-
+
// 6️⃣ Create MigrationStage values to implement the logic for migration from one schema
// to the next:
static var stages: [MigrationStage] {
@@ -816,12 +817,12 @@ structure of your data types. The overall steps to follow are as such:
}
}
try context.save()
- } didMigrate: { _ in
+ } didMigrate: { _ in
}
]
}
}
-
+
// 7️⃣ Create ModelContainer with migration plan in entry point of app:
@main
struct MyApp: App {
@@ -844,27 +845,109 @@ Some things to note about the above comparison:
with a duplicate title (keeping the first) by using a subquery.
* The SwiftData migration is many, many times longer than the equivalent SQLite version involving
many intricate steps that are hard to remember and easy to get wrong.
- * Because database schemas are tightly coupled to type definitions we have no choice but to
+ * Because database schemas are tightly coupled to type definitions we have no choice but to
duplicate our data type so that we can apply the `@Attribute(.unique)` macro.
- * Further, we will need to move all helper methods and computed properties from the previous
+ * Further, we will need to move all helper methods and computed properties from the previous
version of the data type to the new version.
* The work in step #6 that deletes items if they have a duplicate titles is very inefficient, but
it's not possible to make much more efficient. SwiftData does not provide us with tools to run
- raw SQL on the tables, and so we have no choice but to load all of the items into memory and
- manually check for unique titles. This is memory intensive and CPU intensive work and may
- require extra attention if there are thousands of items in the table. On the other hand, SQLite
- can perform this work efficiently on millions of rows without ever loading a single `Item` into
+ raw SQL on the tables, and so we have no choice but to load all of the items into memory and
+ manually check for unique titles. This is memory intensive and CPU intensive work and may
+ require extra attention if there are thousands of items in the table. On the other hand, SQLite
+ can perform this work efficiently on millions of rows without ever loading a single `Item` into
memory.
-So, while lightweight migrations are one of the "magical" features of SwiftData, we feel that
+So, while lightweight migrations are one of the "magical" features of SwiftData, we feel that
complex "manual" migrations are common enough that one should optimize for them rather than the
other way around.
+### CloudKit
+
+Both SQLiteData and SwiftData support basic synchronization of models to CloudKit so that data
+can be made available on all of a user's devices. However, SQLiteData also supports sharing records
+with other iCloud users, and it exposes the underlying CloudKit data types (e.g. `CKRecord`) so
+that you can interact directly with CloudKit if needed.
+
+Setting up a database and sync engine in SQLiteData isn't much different from setting up a
+SwiftData stack with CloudKit. The main difference is that one must explicitly provide the
+container identifier in SQLiteData because SwiftData has been privileged in being able to
+inspect the Entitlements.plist in order to automatically extract that information:
+
+@Row {
+ @Column {
+ ```swift
+ // SQLiteData
+ @main
+ struct MyApp: App {
+ init() {
+ try! prepareDependencies {
+ $0.defaultDatabase = try appDatabase()
+ $0.defaultSyncEngine = try SyncEngine(
+ for: $0.defaultDatabase,
+ tables: RemindersList.self, Reminder.self
+ )
+ }
+ }
+
+ …
+ }
+ ```
+ }
+ @Column {
+ ```swift
+ // SwiftData
+ @main
+ struct MyApp: App {
+ let modelContainer: ModelContainer
+ init() {
+ let schema = Schema([
+ Reminder.self,
+ RemindersList.self,
+ ])
+ let modelConfiguration = ModelConfiguration(schema: schema)
+ modelContainer = try! ModelContainer(
+ for: schema,
+ configurations: [modelConfiguration]
+ )
+ }
+
+ …
+ }
+ ```
+ }
+}
+
+Once this initial set up is performed, all insertions, updates and deletions from the database
+will be automatically synchronized to CloudKit.
+
+SwiftData also has a few limitations in what features you are allowed to use in your schema:
+
+* Unique constraints are not allowed on columns.
+* All properties on a model must be optional or have a default value.
+* All relationships must be optional.
+
+SQLiteData has only one of these limitations:
+
+* Unique constraints on columns (except for the primary key) cannot be upheld on a distributed
+schema. For example, if you have a `Tag` table with a unique `title` column, then what
+are you to do if two different devices create a tag with the title "family" at the same time?
+See for more information.
+* Columns on freshly created tables do not need to have default values or be nullable. Only
+newly added columns to existing tables need to either be nullable or have a default. See
+ for more info.
+* Relationships on freshly created do not need to be nullable. Only newly added columns to
+existing tables need to be nullable. See for more info.
+
+For more information about requirements of your schema in order to use CloudKit synchronization,
+see and
+, and for more general
+information about CloudKit synchronization, see .
+
### Supported Apple platforms
-SwiftData and the `@Query` macro require iOS 17, macOS 14, tvOS 17, watchOS 10 and higher, and
+SwiftData and the `@Query` macro require iOS 17, macOS 14, tvOS 17, watchOS 10 and higher, and
some newer features require even more recent versions of iOS.
-Meanwhile, SharingGRDB has a broad set of deployment targets supporting all the way back to iOS 13,
-macOS 10.15, tvOS 13, and watchOS 6. This means you can use these tools on essentially any
+Meanwhile, SQLiteData has a broad set of deployment targets supporting all the way back to iOS 13,
+macOS 10.15, tvOS 13, and watchOS 6. This means you can use these tools on essentially any
application today with no restrictions.
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/DynamicQueries.md b/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md
similarity index 90%
rename from Sources/SharingGRDBCore/Documentation.docc/Articles/DynamicQueries.md
rename to Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md
index 84322a82..9d4a7dca 100644
--- a/Sources/SharingGRDBCore/Documentation.docc/Articles/DynamicQueries.md
+++ b/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md
@@ -40,11 +40,11 @@ It fetches _all_ items from the backing database into memory and then Swift does
filtering, sorting, and truncating this data before it is displayed to the user. This means if the
table contains thousands, or even hundreds of thousands of rows, every single one will be loaded
into memory and processed, which is incredibly inefficient to do. Worse, this work will be performed
-every single time `displayedItems` is evaluated, which will be at least once for each time the
+every single time `displayedItems` is evaluated, which will be at least once for each time the
view's body is computed, but could also be more.
This kind of data processing is exactly what SQLite excels at, and so we can offload this work by
-modifying the query itself. One can do this with SharingGRDB by using the `load` method on
+modifying the query itself. One can do this with SQLiteData by using the `load` method on
``FetchAll``, ``FetchOne`` or ``Fetch`` in order to load a new key, and hence execute a new query:
```swift
@@ -87,5 +87,8 @@ struct ContentView: View {
> Important: If a parent view refreshes, a dynamically-updated query can be overwritten with the
> initial `@FetchAll`'s value, taken from the parent. To manage the state of this dynamic query
-> locally to this view, we use `@State @FetchAll`, instead, and to access the underlying
+> locally to this view, we use `@State @FetchAll`, instead, and to access the underlying
> `FetchAll` value you can use `wrappedValue`.
+>
+> This only happens when using `@FetchAll`/`@FetchOne`/`@Fetch` directly in a view, and does not
+> affect using these tools elsewhere in your application.
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/Fetching.md b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md
similarity index 85%
rename from Sources/SharingGRDBCore/Documentation.docc/Articles/Fetching.md
rename to Sources/SQLiteData/Documentation.docc/Articles/Fetching.md
index 4f65e426..e2b2d380 100644
--- a/Sources/SharingGRDBCore/Documentation.docc/Articles/Fetching.md
+++ b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md
@@ -1,6 +1,7 @@
# Fetching model data
-Learn about the various tools for fetching data from a SQLite database.
+Learn how to use the `@FetchAll`, `@FetchOne` and `@Fetch` property wrappers for performing
+SQL queries to load data from your database.
## Overview
@@ -16,17 +17,17 @@ queries in a single transaction.
### @FetchAll
The [`@FetchAll`]() property wrapper allows you to fetch a collection of results from
-your database using a SQL query. The query is created using our
+your database using a SQL query. The query is created using our
[StructuredQueries][structured-queries-gh] library, which can build type-safe queries that safely
and performantly decode into Swift data types.
-To get access to these tools you must apply the `@Table` macro to your data type that represents
+To get access to these tools you must apply the `@Table` macro to your data type that represents
your table:
```swift
@Table
struct Reminder {
- let id: Int
+ let id: UUID
var title = ""
var dueAt: Date?
var isCompleted = false
@@ -40,15 +41,15 @@ simply doing:
@FetchAll var reminders: [Reminder]
```
-If you want to execute a more complex query, such as one that sorts the results by the reminder's
-title, then you can use the various query building APIs on `Reminder`:
+If you want to execute a more complex query, such as one that sorts the results by the reminder's
+title, then you can use the various query building APIs on `Reminder`:
```swift
@FetchAll(Reminder.order(by: \.title))
var reminders
```
-Or if you want to only select the completed reminders, sorted by their titles in a descending
+Or if you want to only select the completed reminders, sorted by their titles in a descending
fashion:
```swift
@@ -68,7 +69,7 @@ You can even execute a SQL string to populate the data in your features:
var completedReminders: [Reminder]
```
-This uses the `#sql` macro for constructing [safe SQL strings][sq-safe-sql-strings]. You are
+This uses the `#sql` macro for constructing [safe SQL strings][sq-safe-sql-strings]. You are
automatically protected from SQL injection attacks, and it is even possible to use the static
description of your schema to prevent accidental typos:
@@ -97,7 +98,7 @@ exactly one list:
```swift
@Table
struct Reminder {
- let id: Int
+ let id: UUID
var title = ""
var dueAt: Date?
var isCompleted = false
@@ -105,7 +106,7 @@ struct Reminder {
}
@Table
struct RemindersList: Identifiable {
- let id: Int
+ let id: UUID
var title = ""
}
```
@@ -123,7 +124,7 @@ struct Record {
}
```
-And then we construct a query that joins the `Reminder` table to the `RemindersList` table and
+And then we construct a query that joins the `Reminder` table to the `RemindersList` table and
selects the titles from each table:
```swift
@@ -132,7 +133,7 @@ selects the titles from each table:
.join(RemindersList.all) { $0.remindersListID.eq($1.id) }
.select {
Record.Columns(
- reminderTitle: $0.title,
+ reminderTitle: $0.title,
remindersListTitle: $1.title
)
}
@@ -151,9 +152,9 @@ you must construct a dedicated `FetchDescriptor` value and set its `propertiesTo
### @FetchOne
The [`@FetchOne`]() property wrapper works similarly to `@FetchAll`, but fetches
-only a single record from the database and you must provide a default for when no record is found.
-This tool can be handy for computing aggregate data, such as the number of reminders in the
-database:
+only a single record from the database and you must provide a default for when no record is found or
+use an optional value. This tool can be handy for computing aggregate data, such as the number of
+reminders in the database:
```swift
@FetchOne(Reminder.count())
@@ -179,9 +180,9 @@ var completedRemindersCount = 0
It is also possible to execute multiple database queries to fetch data for your features. This can
be useful for performing several queries in a single database transaction:
-Each instance of `@FetchAll` in a feature executes their queries in a separate transaction. So, if
-we wanted to query for all completed reminders, along with a total count of reminders (completed and
-uncompleted), we could do so like this:
+Each instance of `@FetchAll` and `@FetchOne` executes their queries in a separate transaction and
+manage separate observations of the database. So, if we wanted to query for all completed reminders,
+along with a total count of reminders (completed and uncompleted), we could do so like this:
```swift
@FetchOne(Reminder.count())
@@ -191,13 +192,13 @@ var remindersCount = 0
var completedReminders
```
-…this is technically 2 queries run in 2 separate database transactions.
+…this is technically 2 separate database transactions with 2 separate observations.
Often this can be just fine, but if you have multiple queries that tend to change at the same time
(_e.g._, when reminders are created or deleted, `remindersCount` and `completedReminders` will
change at the same time), then you can bundle these two queries into a single transaction.
-To do this, one simply defines a conformance to our ``FetchKeyRequest`` protocol, and in that
+To do this, one defines a conformance to our ``FetchKeyRequest`` protocol, and in that
conformance one can use the builder tools to query the database:
```swift
@@ -219,9 +220,9 @@ Here we have defined a ``FetchKeyRequest/Value`` type inside the conformance tha
data we want to query for in a single transaction, and then we can construct it and return it from
the ``FetchKeyRequest/fetch(_:)`` method.
-With this conformance defined we can use the
+With this conformance defined we can use the
[`@Fetch`]() property wrapper to execute the query specified by
-the `Reminders` type, and we can access the `completedReminders` and `remindersCount` properties
+the `Reminders` type, and we can access the `completedReminders` and `remindersCount` properties
to get to the queried data:
```swift
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/Observing.md b/Sources/SQLiteData/Documentation.docc/Articles/Observing.md
similarity index 87%
rename from Sources/SharingGRDBCore/Documentation.docc/Articles/Observing.md
rename to Sources/SQLiteData/Documentation.docc/Articles/Observing.md
index 0f70c837..f1ce70e5 100644
--- a/Sources/SharingGRDBCore/Documentation.docc/Articles/Observing.md
+++ b/Sources/SQLiteData/Documentation.docc/Articles/Observing.md
@@ -1,6 +1,7 @@
# Observing changes to model data
-Learn about the various tools for observing changes to the database in SwiftUI, UIKit, and more.
+Learn how to observe changes to your database in SwiftUI views, UIKit view controllers, and
+more.
## Overview
@@ -10,9 +11,9 @@ macro from SwiftData.
### SwiftUI
-The [`@FetchAll`](), [`@FetchOne`](), and [`@Fetch`]()
-property wrappers work in SwiftUI views similarly to how the `@Query` macro does from SwiftData.
-You simply add a property to the view that is annotated with one of the various ways of
+The [`@FetchAll`](), [`@FetchOne`](), and [`@Fetch`]()
+property wrappers work in SwiftUI views similarly to how the `@Query` macro does from SwiftData.
+You simply add a property to the view that is annotated with one of the various ways of
[querying your database]():
```swift
@@ -54,7 +55,7 @@ struct ItemsView: View {
```
> Note: Due to how macros work in Swift, property wrappers must be annotated with
-> `@ObservationIgnored`, but this does not affect observation as SharingGRDB handles its own
+> `@ObservationIgnored`, but this does not affect observation as SQLiteData handles its own
> observation.
### UIKit
@@ -66,15 +67,15 @@ then you can do roughly the following:
```swift
class ItemsViewController: UICollectionViewController {
@FetchAll var items: [Item]
-
+
override func viewDidLoad() {
// Set up data source and cell registration...
-
+
// Observe changes to items in order to update data source:
- $items.publisher.sink { items in
+ $items.publisher.sink { items in
guard let self else { return }
dataSource.apply(
- NSDiffableDataSourceSnapshot(items: items),
+ NSDiffableDataSourceSnapshot(items: items),
animatingDifferences: true
)
}
@@ -86,20 +87,20 @@ class ItemsViewController: UICollectionViewController {
This uses the `publisher` property that is available on every fetched value to update the collection
view's data source whenever the `items` change.
-> Tip: There is an alternative way to observe changes to `items`. If you are already depending on
-> our [Swift Navigation][swift-nav-gh] library to make use of powerful navigation APIs for SwiftUI
+> Tip: There is an alternative way to observe changes to `items`. If you are already depending on
+> our [Swift Navigation][swift-nav-gh] library to make use of powerful navigation APIs for SwiftUI
> and UIKitNavigation, then you can use the [`observe`][observe-docs] tool to update the database
> without using Combine:
->
+>
> ```swift
> override func viewDidLoad() {
> // Set up data source and cell registration...
->
+>
> // Observe changes to items in order to update data source:
> observe { [weak self] in
> guard let self else { return }
> dataSource.apply(
-> NSDiffableDataSourceSnapshot(items: items),
+> NSDiffableDataSourceSnapshot(items: items),
> animatingDifferences: true
> )
> }
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md
similarity index 60%
rename from Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md
rename to Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md
index 098db6ed..eadbdac1 100644
--- a/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md
+++ b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md
@@ -1,10 +1,11 @@
# Preparing a SQLite database
-Learn how to create and configure the SQLite database that holds your application's data.
+Learn how to create, configure and migrate the SQLite database that holds your application’s
+data.
## Overview
-Before you can use any of the tools of this library you must create and configure the SQLite
+Before you can use any of the tools of this library you must create and configure the SQLite
database that will be used throughout the app. There are a few steps to getting this right, and
a few optional steps you can perform to make the database you provision work well for testing
and Xcode previews.
@@ -14,6 +15,7 @@ and Xcode previews.
* [Step 3: Create database connection](#Step-3-Create-database-connection)
* [Step 4: Migrate database](#Step-4-Migrate-database)
* [Step 5: Set database connection in entry point](#Step-5-Set-database-connection-in-entry-point)
+* [(Optional) Step 6: Set up CloudKit SyncEngine](#Optional-Step-6-Set-up-CloudKit-SyncEngine)
### Step 1: App database connection
@@ -35,36 +37,28 @@ func appDatabase() -> any DatabaseWriter {
### Step 2: Create configuration
Inside this static variable we can create a [`Configuration`][config-docs] value that is used to
-configure the database. We highly recommend always turning on
-[foreign key](https://www.sqlite.org/foreignkeys.html) constraints to protect the integrity of your
-data:
+configure the database if there is any custom configuration you want to perform. This is an
+optional step:
```diff
func appDatabase() -> any DatabaseWriter {
+ var configuration = Configuration()
-+ configuration.foreignKeysEnabled = true
}
```
-This will prevent you from deleting rows that leave other rows with invalid associations. For
-example, if a "reminders" table had an association to a "remindersLists" table, you would not be
-allowed to delete a list row unless there were no reminders associated with it, or if you had
-specified a cascading action (such as delete).
-
-We further recommend that you enable query tracing to log queries that are executed in your
-application. This can be handy for tracking down long-running queries, or when more queries execute
-than you expect. We also recommend only doing this in debug builds to avoid leaking sensitive
-information when the app is running on a user's device, and we further recommned using OSLog
-when running your app in the simulator/device and using `Swift.print` in previews:
+One configuration you may want to enable is query tracing in order to log queries that are executed
+in your application. This can be handy for tracking down long-running queries, or when more queries
+execute than you expect. We also recommend only doing this in debug builds to avoid leaking
+sensitive information when the app is running on a user's device, and we further recommend using
+OSLog when running your app in the simulator/device and using `Swift.print` in previews:
```diff
import OSLog
- import SharingGRDB
+ import SQLiteData
func appDatabase() -> any DatabaseWriter {
+ @Dependency(\.context) var context
var configuration = Configuration()
- configuration.foreignKeysEnabled = true
+ #if DEBUG
+ configuration.prepareDatabase { db in
+ db.trace(options: .profile) {
@@ -85,15 +79,14 @@ when running your app in the simulator/device and using `Swift.print` in preview
> sensitive data that you may not want to leak. In this case we feel it is OK because everything
> is surrounded in `#if DEBUG`, but it is something to be careful of in your own apps.
-
> Tip: `@Dependency(\.context)` comes from the [Swift Dependencies][swift-dependencies-gh] library,
-> which SharingGRDB uses to share its database connection across fetch keys. It allows you to
+> which SQLiteData uses to share its database connection across fetch keys. It allows you to
> inspect the context your app is running in: live, preview or test.
[swift-dependencies-gh]: https://github.com/pointfreeco/swift-dependencies
-For more information on configuring tracing, see [GRDB's documentation][trace-docs] on the
-matter.
+For more information on configuring the database connection, see [GRDB's documentation][config-docs]
+on the matter.
[config-docs]: https://swiftpackageindex.com/groue/grdb.swift/master/documentation/grdb/configuration
[trace-docs]: https://swiftpackageindex.com/groue/grdb.swift/master/documentation/grdb/database/trace(options:_:)
@@ -101,14 +94,14 @@ matter.
### Step 3: Create database connection
Once a `Configuration` value is set up we can construct the actual database connection. The simplest
-way to do this is to construct the database connection for a path on the file system like so:
+way to do this is to construct the database connection using the
+n``defaultDatabase(path:configuration:)`` function:
```diff
-func appDatabase() -> any DatabaseWriter {
+func appDatabase() throws -> any DatabaseWriter {
@Dependency(\.context) var context
var configuration = Configuration()
- configuration.foreignKeysEnabled = true
#if DEBUG
configuration.prepareDatabase { db in
db.trace(options: .profile) {
@@ -120,59 +113,18 @@ way to do this is to construct the database connection for a path on the file sy
}
}
#endif
-+ let path = URL.documentsDirectory.appending(component: "db.sqlite").path()
-+ logger.info("open \(path)")
-+ let database = try DatabasePool(path: path, configuration: configuration)
++ let database = try defaultDatabase(configuration: configuration)
++ logger.info("open '\(database.path)'")
+ return database
}
```
-However, this can be improved. First, this code will crash if it is executed in Xcode previews
-because SQLite is unable to form a connection to a database on disk in a preview context. And
-second, in tests we should write this databadse to the temporary directoy on disk with a unique
-name so that each test gets a fresh database and so that multiple tests can run in parallel.
-
-To fix this we can use `@Dependency(\.context)` to determine if we are in a "live" application
-context or if we're in a preview or test.
-
-```diff
- func appDatabase() -> any DatabaseWriter {
- @Dependency(\.context) var context
- var configuration = Configuration()
- configuration.foreignKeysEnabled = true
- #if DEBUG
- configuration.prepareDatabase { db in
- db.trace(options: .profile) {
- if context == .preview {
- print("\($0.expandedDescription)")
- } else {
- logger.debug("\($0.expandedDescription)")
- }
- }
- }
- #endif
-- let path = URL.documentsDirectory.appending(component: "db.sqlite").path()
-- logger.info("open \(path)")
-- let database = try DatabasePool(path: path, configuration: configuration)
-+ @Dependency(\.context) var context
-+ let database: any DatabaseWriter
-+ if context == .live {
-+ let path = URL.documentsDirectory.appending(component: "db.sqlite").path()
-+ logger.info("open \(path)")
-+ database = try DatabasePool(path: path, configuration: configuration)
-+ } else if context == .test {
-+ let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path()
-+ database = try DatabasePool(path: path, configuration: configuration)
-+ } else {
-+ database = try DatabaseQueue(configuration: configuration)
-+ }
- return database
- }
-```
+This function provisions a context-dependent database for you, _e.g._ in previews and tests it
+will provision unique, temporary databases that won't conflict with your live app's database.
### Step 4: Migrate database
-Now that the database connection is created we can migrate the database. GRDB provides all the
+Now that the database connection is created we can migrate the database. GRDB provides all the
tools necessary to perform [database migrations][grdb-migration-docs], but the basics include
creating a `DatabaseMigrator`, registering migrations with it, and then using it to migrate the
database connection:
@@ -181,7 +133,6 @@ database connection:
func appDatabase() throws -> any DatabaseWriter {
@Dependency(\.context) var context
var configuration = Configuration()
- configuration.foreignKeysEnabled = true
#if DEBUG
configuration.prepareDatabase { db in
db.trace(options: .profile) {
@@ -193,17 +144,8 @@ database connection:
}
}
#endif
- let database: any DatabaseWriter
- if context == .live {
- let path = URL.documentsDirectory.appending(component: "db.sqlite").path()
- logger.info("open \(path)")
- database = try DatabasePool(path: path, configuration: configuration)
- } else if context == .test {
- let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path()
- database = try DatabasePool(path: path, configuration: configuration)
- } else {
- database = try DatabaseQueue(configuration: configuration)
- }
+ let database = try defaultDatabase(configuration: configuration)
+ logger.info("open '\(database.path)'")
+ var migrator = DatabaseMigrator()
+ #if DEBUG
+ migrator.eraseDatabaseOnSchemaChange = true
@@ -218,9 +160,9 @@ database connection:
As your application evolves you will register more and more migrations with the migrator.
-It is up to you how you want to actually execute the SQL that creates your tables. There are
-[APIs in the community][grdb-table-definition] for building table definition statements using Swift
-code, but we personally feel that it is simpler, more flexible and more powerful to use
+It is up to you how you want to actually execute the SQL that creates your tables. There are
+[APIs in the community][grdb-table-definition] for building table definition statements using Swift
+code, but we personally feel that it is simpler, more flexible and more powerful to use
[plain SQL strings][table-definition-tools]:
[grdb-table-definition]: https://swiftpackageindex.com/groue/grdb.swift/v7.6.1/documentation/grdb/database/create(table:options:body:)
@@ -250,7 +192,7 @@ migrator.registerMigration("Create tables") { db in
It may seem counterintuitive that we recommend using SQL strings for table definitions when so much
of the library provides type-safe and schema-safe tools for executing SQL. But table definition SQL
is fundamentally different from other SQL as it is frozen in time and should never be edited
-after it has been deployed to users. Read [this article][table-definition-tools] from our
+after it has been deployed to users. Read [this article][table-definition-tools] from our
StructuredQueries library to learn more about this decision.
[table-definition-tools]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/main/documentation/structuredqueriescore/definingyourschema#Table-definition-tools
@@ -260,34 +202,24 @@ we have just written in one snippet:
```swift
import OSLog
-import SharingGRDB
+import SQLiteData
func appDatabase() throws -> any DatabaseWriter {
- @Dependency(\.context) var context
- var configuration = Configuration()
- configuration.foreignKeysEnabled = true
- #if DEBUG
- configuration.prepareDatabase { db in
- db.trace(options: .profile) {
- if context == .preview {
- print("\($0.expandedDescription)")
- } else {
- logger.debug("\($0.expandedDescription)")
- }
- }
- }
- #endif
- let database: any DatabaseWriter
- if context == .live {
- let path = URL.documentsDirectory.appending(component: "db.sqlite").path()
- logger.info("open \(path)")
- database = try DatabasePool(path: path, configuration: configuration)
- } else if context == .test {
- let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path()
- database = try DatabasePool(path: path, configuration: configuration)
- } else {
- database = try DatabaseQueue(configuration: configuration)
- }
+ @Dependency(\.context) var context
+ var configuration = Configuration()
+ #if DEBUG
+ configuration.prepareDatabase { db in
+ db.trace(options: .profile) {
+ if context == .preview {
+ print("\($0.expandedDescription)")
+ } else {
+ logger.debug("\($0.expandedDescription)")
+ }
+ }
+ }
+ #endif
+ let database = try defaultDatabase(configuration: configuration)
+ logger.info("open '\(database.path)'")
var migrator = DatabaseMigrator()
#if DEBUG
migrator.eraseDatabaseOnSchemaChange = true
@@ -311,13 +243,13 @@ it as the `defaultDatabase` for your app in its entry point. This can be in done
`prepareDependencies` in the `init` of your `App` conformance:
```swift
-import SharingGRDB
+import SQLiteData
import SwiftUI
@main
struct MyApp: App {
init() {
- prepareDependencies {
+ prepareDependencies {
$0.defaultDatabase = try! appDatabase()
}
}
@@ -332,7 +264,7 @@ If using app or scene delegates, then you can prepare the `defaultDatabase` in o
conformances:
```swift
-import SharingGRDB
+import SQLiteData
import UIKit
class AppDelegate: NSObject, UIApplicationDelegate {
@@ -352,7 +284,7 @@ It is also important to prepare the database in Xcode previews. This can be done
```swift
#Preview {
- let _ = prepareDependencies {
+ let _ = prepareDependencies {
$0.defaultDatabase = try! appDatabase()
}
// ...
@@ -367,3 +299,9 @@ func feature() {
// ...
}
```
+
+### (Optional) Step 6: Set up CloudKit SyncEngine
+
+If you plan on synchronizing your local database to CloudKit so that your user's data is available
+on all of their devices, there is an additional step you must take. See
+ for more information.
diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/Database.md b/Sources/SQLiteData/Documentation.docc/Extensions/Database.md
new file mode 100644
index 00000000..8c8f2316
--- /dev/null
+++ b/Sources/SQLiteData/Documentation.docc/Extensions/Database.md
@@ -0,0 +1,16 @@
+# ``GRDB/Database``
+
+## Topics
+
+### Seeding model data
+
+- ``seed(_:)``
+
+### User-defined functions
+
+- ``add(function:)``
+- ``remove(function:)``
+
+### Querying CloudKit metadata
+
+- ``attachMetadatabase(containerIdentifier:)``
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/Fetch.md b/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md
similarity index 80%
rename from Sources/SharingGRDBCore/Documentation.docc/Extensions/Fetch.md
rename to Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md
index d6a51b81..0657e8f9 100644
--- a/Sources/SharingGRDBCore/Documentation.docc/Extensions/Fetch.md
+++ b/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md
@@ -1,4 +1,4 @@
-# ``SharingGRDBCore/Fetch``
+# ``SQLiteData/Fetch``
## Overview
@@ -8,8 +8,6 @@
- ``FetchKeyRequest``
- ``init(wrappedValue:_:database:)``
-- ``init(_:database:)``
-- ``init(database:)``
- ``init(wrappedValue:)``
- ``load(_:database:)``
@@ -34,9 +32,7 @@
- ``init(wrappedValue:_:database:scheduler:)``
- ``load(_:database:scheduler:)``
-### Sharing infrastructure
+### Sharing integration
- ``sharedReader``
- ``subscript(dynamicMember:)``
-- ``FetchKey``
-- ``FetchKeyID``
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchAll.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md
similarity index 95%
rename from Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchAll.md
rename to Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md
index 457b6e99..14c32601 100644
--- a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchAll.md
+++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md
@@ -1,4 +1,4 @@
-# ``SharingGRDBCore/FetchAll``
+# ``SQLiteData/FetchAll``
## Overview
diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchCursor.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchCursor.md
new file mode 100644
index 00000000..1142cc2e
--- /dev/null
+++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchCursor.md
@@ -0,0 +1,7 @@
+# ``StructuredQueriesCore/Statement/fetchCursor(_:)``
+
+## Topics
+
+### Iterating over rows
+
+- ``QueryCursor``
diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md
new file mode 100644
index 00000000..732a804b
--- /dev/null
+++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md
@@ -0,0 +1,7 @@
+# ``SQLiteData/FetchKeyRequest``
+
+## Topics
+
+### Error handling
+
+- ``NotFound``
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchOne.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchOne.md
similarity index 92%
rename from Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchOne.md
rename to Sources/SQLiteData/Documentation.docc/Extensions/FetchOne.md
index d001456e..787d2914 100644
--- a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchOne.md
+++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchOne.md
@@ -1,4 +1,4 @@
-# ``SharingGRDBCore/FetchOne``
+# ``SQLiteData/FetchOne``
## Overview
@@ -7,7 +7,6 @@
### Fetching data
- ``init(wrappedValue:database:)``
-- ``init(database:)``
- ``init(wrappedValue:_:database:)``
- ``load(_:database:)``
diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/IdentifierStringConvertible.md b/Sources/SQLiteData/Documentation.docc/Extensions/IdentifierStringConvertible.md
new file mode 100644
index 00000000..15411423
--- /dev/null
+++ b/Sources/SQLiteData/Documentation.docc/Extensions/IdentifierStringConvertible.md
@@ -0,0 +1,29 @@
+# ``IdentifierStringConvertible``
+
+## Topics
+
+### Conformances
+
+- ``Swift/Bool``
+- ``Swift/Character``
+- ``Swift/Double``
+- ``Swift/Float``
+- ``Swift/Float16``
+- ``Swift/Int``
+- ``Swift/Int128``
+- ``Swift/Int16``
+- ``Swift/Int32``
+- ``Swift/Int64``
+- ``Swift/Int8``
+- ``Swift/String``
+- ``Swift/Substring``
+- ``Swift/UInt``
+- ``Swift/UInt128``
+- ``Swift/UInt16``
+- ``Swift/UInt32``
+- ``Swift/UInt64``
+- ``Swift/UInt8``
+- ``Swift/Optional``
+- ``Swift/Unicode/Scalar``
+- ``Foundation/UUID``
+- ``Tagged/Tagged``
diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/SystemFieldsRepresentation.md b/Sources/SQLiteData/Documentation.docc/Extensions/SystemFieldsRepresentation.md
new file mode 100644
index 00000000..ccf259bf
--- /dev/null
+++ b/Sources/SQLiteData/Documentation.docc/Extensions/SystemFieldsRepresentation.md
@@ -0,0 +1 @@
+# ``CloudKit/CKRecord/SystemFieldsRepresentation``
diff --git a/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png
new file mode 100644
index 00000000..6b05e402
Binary files /dev/null and b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png differ
diff --git a/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many.png
new file mode 100644
index 00000000..aab886d3
Binary files /dev/null and b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many.png differ
diff --git a/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png
new file mode 100644
index 00000000..7807c602
Binary files /dev/null and b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png differ
diff --git a/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-many.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-many.png
new file mode 100644
index 00000000..24c74221
Binary files /dev/null and b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-many.png differ
diff --git a/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-root-record.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-root-record.png
new file mode 100644
index 00000000..2e8a1a69
Binary files /dev/null and b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-root-record.png differ
diff --git a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md
similarity index 57%
rename from Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md
rename to Sources/SQLiteData/Documentation.docc/SQLiteData.md
index eec45bf1..c9a3bc32 100644
--- a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md
+++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md
@@ -1,23 +1,24 @@
-# ``SharingGRDBCore``
+# ``SQLiteData``
-A fast, lightweight replacement for SwiftData, powered by SQL. This module is automatically imported
-when you `import SharingGRDB`.
+A fast, lightweight replacement for SwiftData, powered by SQL and supporting CloudKit
+synchronization.
## Overview
-SharingGRDB is a [fast](#Performance), lightweight replacement for SwiftData that deploys all the
-way back to the iOS 13 generation of targets.
+SQLiteData is a [fast](#Performance), lightweight replacement for SwiftData, supporting CloudKit
+synchronization (and even CloudKit sharing), that deploys all the way back to the iOS 13 generation
+of targets.
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
@FetchAll
var items: [Item]
@Table
struct Item {
- let id: Int
+ let id: UUID
var title = ""
var isInStock = true
var notes = ""
@@ -51,15 +52,14 @@ way back to the iOS 13 generation of targets.
Both of the above examples fetch items from an external data store using Swift data types, and both
are automatically observed by SwiftUI so that views are recomputed when the external data changes,
-but SharingGRDB is powered directly by SQLite using [Sharing](#What-is-Sharing),
-[StructuredQueries](#What-is-StructuredQueries), and [GRDB](#What-is-GRDB), and is usable from
-anywhere, including UIKit, `@Observable` models, and more.
+but SQLiteData is powered directly by SQLite and is usable from anywhere, including UIKit,
+`@Observable` models, and more.
-> Note: For more information on SharingGRDB's querying capabilities, see .
+> Note: For more information on SQLiteData's querying capabilities, see .
## Quick start
-Before SharingGRDB's property wrappers can fetch data from SQLite, you need to provide---at
+Before SQLiteData's property wrappers can fetch data from SQLite, you need to provide---at
runtime---the default database it should use. This is typically done as early as possible in your
app's lifetime, like the app entry point in SwiftUI, and is analogous to configuring model storage
in SwiftData:
@@ -67,7 +67,7 @@ in SwiftData:
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
@main
struct MyApp: App {
init() {
@@ -86,11 +86,11 @@ in SwiftData:
// SwiftData
@main
struct MyApp: App {
- let container = {
+ let container = {
// Create/configure a container
try! ModelContainer(/* ... */)
}()
-
+
var body: some Scene {
WindowGroup {
ContentView()
@@ -104,9 +104,8 @@ in SwiftData:
> Note: For more information on preparing a SQLite database, see .
-This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies, like
-[`@FetchAll`](), which are similar to SwiftData's
-`@Query` macro, but more powerful:
+This `defaultDatabase` connection is used implicitly by SQLiteData's property wrappers, like
+``FetchAll``, which are similar to SwiftData's `@Query` macro, but more powerful:
@Row {
@Column {
@@ -120,8 +119,13 @@ This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies
@FetchAll(Item.where(\.isInStock))
var items
+
+
+ @FetchAll(Item.order(by: \.isInStock))
+ var items
+
@FetchOne(Item.count())
- var inStockItemsCount = 0
+ var itemsCount = 0
```
}
@Column {
@@ -132,8 +136,13 @@ This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies
@Query(sort: [SortDescriptor(\.title)])
var items: [Item]
- // No @Query equivalent of filtering
- // by 'isInStock: Bool'
+ @Query(filter: #Predicate
- {
+ $0.isInStock
+ })
+ var items: [Item]
+
+ // No @Query equivalent of ordering
+ // by boolean column.
// No @Query equivalent of counting
// entries in database without loading
@@ -148,12 +157,12 @@ a model context, via a property wrapper:
@Row {
@Column {
```swift
- // SharingGRDB
+ // SQLiteData
@Dependency(\.defaultDatabase) var database
-
+
try database.write { db in
- try Item.insert(Item(/* ... */))
- .execute(db)
+ try Item.insert { Item(/* ... */) }
+ .execute(db)
}
```
}
@@ -161,7 +170,7 @@ a model context, via a property wrapper:
```swift
// SwiftData
@Environment(\.modelContext) var modelContext
-
+
let newItem = Item(/* ... */)
modelContext.insert(newItem)
try modelContext.save()
@@ -169,15 +178,42 @@ a model context, via a property wrapper:
}
}
-> Note: For more information on how SharingGRDB compares to SwiftData, see
-> .
+> Important: SQLiteData uses [GRDB] under the hood for interacting with SQLite, and you will use
+> its tools for creating transactions for writing to the database, such as the `database.write`
+> method above.
+
+[GRDB]: https://github.com/groue/GRDB.swift
+
+For more information on how SQLiteData compares to SwiftData, see .
+
+Further, if you want to synchronize the local database to CloudKit so that it is available on
+all your user's devices, simply configure a `SyncEngine` in the entry point of the app:
+
+```swift
+@main
+struct MyApp: App {
+ init() {
+ prepareDependencies {
+ $0.defaultDatabase = try! appDatabase()
+ $0.defaultSyncEngine = SyncEngine(
+ for: $0.defaultDatabase,
+ tables: /* ... */
+ )
+ }
+ }
+ // ...
+}
+```
+
+> For more information on synchronizing the database to CloudKit and sharing records with iCloud
+> users, see .
-This is all you need to know to get started with SharingGRDB, but there's much more to learn. Read
+This is all you need to know to get started with SQLiteData, but there's much more to learn. Read
the [articles](#Essentials) below to learn how to best utilize this library.
## Performance
-SharingGRDB leverages high-performance decoding from
+SQLiteData leverages high-performance decoding from
[StructuredQueries](https://github.com/pointfreeco/swift-structured-queries) to turn fetched data
into your Swift domain types, and has a performance profile similar to invoking SQLite's C APIs
directly.
@@ -187,19 +223,21 @@ See the following benchmarks against
taste of how it compares:
```
-Orders.fetchAll setup rampup duration
- SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183
- Lighter (1.4.10) 0 0.164 8.059
- SharingGRDB (0.2.0) 0 0.172 8.511
- GRDB (7.4.1, manual decoding) 0 0.376 18.819
- SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994
- SQLite.swift (0.15.3, Codable) 0 0.863 43.261
- GRDB (7.4.1, Codable) 0.002 1.07 53.326
+Orders.fetchAll setup rampup duration
+ SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183
+ Lighter (1.4.10) 0 0.164 8.059
+┌──────────────────────────────────────────────────────────────────┐
+│ SQLiteData (1.0.0) 0 0.172 8.511 │
+└──────────────────────────────────────────────────────────────────┘
+ GRDB (7.4.1, manual decoding) 0 0.376 18.819
+ SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994
+ SQLite.swift (0.15.3, Codable) 0 0.863 43.261
+ GRDB (7.4.1, Codable) 0.002 1.07 53.326
```
## SQLite knowledge required
-SQLite is one of the
+SQLite is one of the
[most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of
software in the history of software. Knowledge of SQLite is a great skill for any app developer to
have, and this library does not want to conceal it from you. So, we feel that to best wield this
@@ -212,19 +250,6 @@ for data and keep your views up-to-date when data in the database changes, and y
either using its type-safe, discoverable query building APIs, or using its `#sql` macro for writing
safe SQL strings.
-## What is Sharing?
-
-[Sharing](https://github.com/pointfreeco/swift-sharing) is a universal and extensible solution for
-sharing your app's model data across features and with external systems, such as user defaults,
-the file system, and more. This library builds upon the tools from Sharing in order to allow for
-the [fetching]() and [observing]() of data in a SQLite database.
-
-This is all you need to know about Sharing to hit the ground running with SharingGRDB, but it only
-scratches the surface of what the library makes possible. It can also act as a replacement to
-SwiftUI's `@AppStorage` that works with UIKit and `@Observable` models, and can be integrated
-with custom persistence strategies. To learn more, check out
-[the documentation](https://swiftpackageindex.com/pointfreeco/swift-sharing/~/documentation/sharing/).
-
## What is StructuredQueries?
[StructuredQueries](https://github.com/pointfreeco/swift-structured-queries) is a library for
@@ -232,7 +257,7 @@ building SQL in a safe, expressive, and composable manner, and decoding results
performance. Learn more about designing schemas and building queries with the library by seeing its
[documentation](https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore/).
-SharingGRDB contains an official StructuredQueries driver that connects it to SQLite _via_ GRDB,
+SQLiteData contains an official StructuredQueries driver that connects it to SQLite _via_ GRDB,
though its query builder and decoder are general purpose tools that can interface with other
databases (MySQL, Postgres, _etc._) and database libraries.
@@ -240,15 +265,15 @@ databases (MySQL, Postgres, _etc._) and database libraries.
[GRDB](https://github.com/groue/GRDB.swift) is a popular Swift interface to SQLite with a rich
feature set and
-[extensive documentation](https://swiftpackageindex.com/groue/GRDB.swift/documentation/grdb).
-This library leverages GRDB's' observation APIs to keep the `@FetchAll`, `@FetchOne`, and `@Fetch`
+[extensive documentation](https://swiftpackageindex.com/groue/GRDB.swift/documentation/grdb). This
+library leverages GRDB's observation APIs to keep the `@FetchAll`, `@FetchOne`, and `@Fetch`
property wrappers in sync with the database and update SwiftUI views.
If you're already familiar with SQLite, GRDB provides thin APIs that can be leveraged with raw SQL
in short order. If you're new to SQLite, GRDB offers a great introduction to a highly portable
database engine. We recommend
([as does GRDB](https://github.com/groue/GRDB.swift?tab=readme-ov-file#documentation)) a familiarity
-with SQLite to take full advantage of GRDB and SharingGRDB.
+with SQLite to take full advantage of GRDB and SQLiteData.
## Topics
@@ -259,18 +284,32 @@ with SQLite to take full advantage of GRDB and SharingGRDB.
-
-
-
--
### Database configuration and access
+- ``defaultDatabase(path:configuration:)``
+- ``GRDB/Database``
- ``Dependencies/DependencyValues/defaultDatabase``
-### Fetch and observing queries
+### Querying model data
+
+- ``StructuredQueriesCore/Statement``
+- ``StructuredQueriesCore/SelectStatement``
+- ``QueryCursor``
+
+### Observing model data
- ``FetchAll``
- ``FetchOne``
- ``Fetch``
-### Deprecated interfaces
+### CloudKit synchronization and sharing
--
+-
+-
+- ``SyncEngine``
+- ``Dependencies/DependencyValues/defaultSyncEngine``
+- ``IdentifierStringConvertible``
+- ``SyncMetadata``
+- ``StructuredQueriesCore/PrimaryKeyedTableDefinition/hasMetadata(in:)``
+- ``SharedRecord``
diff --git a/Sources/SharingGRDBCore/Fetch.swift b/Sources/SQLiteData/Fetch.swift
similarity index 99%
rename from Sources/SharingGRDBCore/Fetch.swift
rename to Sources/SQLiteData/Fetch.swift
index dd40d9d8..5f916314 100644
--- a/Sources/SharingGRDBCore/Fetch.swift
+++ b/Sources/SQLiteData/Fetch.swift
@@ -1,3 +1,5 @@
+import Sharing
+
#if canImport(Combine)
import Combine
#endif
diff --git a/Sources/SharingGRDBCore/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift
similarity index 94%
rename from Sources/SharingGRDBCore/FetchAll.swift
rename to Sources/SQLiteData/FetchAll.swift
index 4a1ba03b..18239778 100644
--- a/Sources/SharingGRDBCore/FetchAll.swift
+++ b/Sources/SQLiteData/FetchAll.swift
@@ -1,3 +1,5 @@
+import Sharing
+
#if canImport(Combine)
import Combine
#endif
@@ -70,6 +72,11 @@ public struct FetchAll: Sendable {
#endif
/// Initializes this property with a query that fetches every row from a table.
+ ///
+ /// - Parameters:
+ /// - wrappedValue: A default collection to associate with this property.
+ /// - database: The database to read from. A value of `nil` will use the default database
+ /// (`@Dependency(\.defaultDatabase)`).
public init(
wrappedValue: [Element] = [],
database: (any DatabaseReader)? = nil
@@ -88,6 +95,7 @@ public struct FetchAll: Sendable {
/// Initializes this property with a query associated with the wrapped value.
///
/// - Parameters:
+ /// - wrappedValue: A default collection to associate with this property.
/// - statement: A query associated with the wrapped value.
/// - database: The database to read from. A value of `nil` will use the default database
/// (`@Dependency(\.defaultDatabase)`).
@@ -109,6 +117,7 @@ public struct FetchAll: Sendable {
/// Initializes this property with a query associated with the wrapped value.
///
/// - Parameters:
+ /// - wrappedValue: A default collection to associate with this property.
/// - statement: A query associated with the wrapped value.
/// - database: The database to read from. A value of `nil` will use the default database
/// (`@Dependency(\.defaultDatabase)`).
@@ -133,6 +142,7 @@ public struct FetchAll: Sendable {
/// Initializes this property with a query associated with the wrapped value.
///
/// - Parameters:
+ /// - wrappedValue: A default collection to associate with this property.
/// - statement: A query associated with the wrapped value.
/// - database: The database to read from. A value of `nil` will use the default database
/// (`@Dependency(\.defaultDatabase)`).
@@ -201,6 +211,7 @@ extension FetchAll {
/// Initializes this property with a query that fetches every row from a table.
///
/// - Parameters:
+ /// - wrappedValue: A default collection to associate with this property.
/// - database: The database to read from. A value of `nil` will use the default database
/// (`@Dependency(\.defaultDatabase)`).
/// - scheduler: The scheduler to observe from. By default, database observation is performed
@@ -218,6 +229,7 @@ extension FetchAll {
/// Initializes this property with a query associated with the wrapped value.
///
/// - Parameters:
+ /// - wrappedValue: A default collection to associate with this property.
/// - statement: A query associated with the wrapped value.
/// - database: The database to read from. A value of `nil` will use the default database
/// (`@Dependency(\.defaultDatabase)`).
@@ -242,6 +254,7 @@ extension FetchAll {
/// Initializes this property with a query associated with the wrapped value.
///
/// - Parameters:
+ /// - wrappedValue: A default collection to associate with this property.
/// - statement: A query associated with the wrapped value.
/// - database: The database to read from. A value of `nil` will use the default database
/// (`@Dependency(\.defaultDatabase)`).
@@ -270,6 +283,7 @@ extension FetchAll {
/// Initializes this property with a query associated with the wrapped value.
///
/// - Parameters:
+ /// - wrappedValue: A default collection to associate with this property.
/// - statement: A query associated with the wrapped value.
/// - database: The database to read from. A value of `nil` will use the default database
/// (`@Dependency(\.defaultDatabase)`).
@@ -366,6 +380,7 @@ extension FetchAll: Equatable where Element: Equatable {
/// Initializes this property with a query that fetches every row from a table.
///
/// - Parameters:
+ /// - wrappedValue: A default collection to associate with this property.
/// - database: The database to read from. A value of `nil` will use the default database
/// (`@Dependency(\.defaultDatabase)`).
/// - animation: The animation to use for user interface changes that result from changes to
@@ -383,6 +398,7 @@ extension FetchAll: Equatable where Element: Equatable {
/// Initializes this property with a query associated with the wrapped value.
///
/// - Parameters:
+ /// - wrappedValue: A default collection to associate with this property.
/// - statement: A query associated with the wrapped value.
/// - database: The database to read from. A value of `nil` will use the default database
/// (`@Dependency(\.defaultDatabase)`).
@@ -412,6 +428,7 @@ extension FetchAll: Equatable where Element: Equatable {
/// Initializes this property with a query associated with the wrapped value.
///
/// - Parameters:
+ /// - wrappedValue: A default collection to associate with this property.
/// - statement: A query associated with the wrapped value.
/// - database: The database to read from. A value of `nil` will use the default database
/// (`@Dependency(\.defaultDatabase)`).
@@ -439,6 +456,7 @@ extension FetchAll: Equatable where Element: Equatable {
/// Initializes this property with a query associated with the wrapped value.
///
/// - Parameters:
+ /// - wrappedValue: A default collection to associate with this property.
/// - statement: A query associated with the wrapped value.
/// - database: The database to read from. A value of `nil` will use the default database
/// (`@Dependency(\.defaultDatabase)`).
diff --git a/Sources/SharingGRDBCore/FetchKeyRequest.swift b/Sources/SQLiteData/FetchKeyRequest.swift
similarity index 99%
rename from Sources/SharingGRDBCore/FetchKeyRequest.swift
rename to Sources/SQLiteData/FetchKeyRequest.swift
index cd89d074..17f713a9 100644
--- a/Sources/SharingGRDBCore/FetchKeyRequest.swift
+++ b/Sources/SQLiteData/FetchKeyRequest.swift
@@ -1,5 +1,3 @@
-import GRDB
-
/// A type that can request a value from a database.
///
/// This type can be used to describe a transaction to read data from SQLite:
diff --git a/Sources/SharingGRDBCore/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift
similarity index 99%
rename from Sources/SharingGRDBCore/FetchOne.swift
rename to Sources/SQLiteData/FetchOne.swift
index 9313a850..e5e58a25 100644
--- a/Sources/SharingGRDBCore/FetchOne.swift
+++ b/Sources/SQLiteData/FetchOne.swift
@@ -1,3 +1,5 @@
+import Sharing
+
#if canImport(Combine)
import Combine
#endif
diff --git a/Sources/SQLiteData/Internal/DataManager.swift b/Sources/SQLiteData/Internal/DataManager.swift
new file mode 100644
index 00000000..68b43ffe
--- /dev/null
+++ b/Sources/SQLiteData/Internal/DataManager.swift
@@ -0,0 +1,61 @@
+import Dependencies
+import Foundation
+
+package protocol DataManager: Sendable {
+ func load(_ url: URL) throws -> Data
+ func save(_ data: Data, to url: URL) throws
+ var temporaryDirectory: URL { get }
+}
+
+struct LiveDataManager: DataManager {
+ func load(_ url: URL) throws -> Data {
+ try Data(contentsOf: url)
+ }
+ func save(_ data: Data, to url: URL) throws {
+ try data.write(to: url)
+ }
+ var temporaryDirectory: URL {
+ URL(fileURLWithPath: NSTemporaryDirectory())
+ }
+}
+
+package struct InMemoryDataManager: DataManager {
+ package let storage = LockIsolated<[URL: Data]>([:])
+
+ package init() {}
+
+ package func load(_ url: URL) throws -> Data {
+ try storage.withValue { storage in
+ guard let data = storage[url]
+ else {
+ struct FileNotFound: Error {}
+ throw FileNotFound()
+ }
+ return data
+ }
+ }
+
+ package func save(_ data: Data, to url: URL) throws {
+ storage.withValue { $0[url] = data }
+ }
+
+ package var temporaryDirectory: URL {
+ URL(fileURLWithPath: "/")
+ }
+}
+
+private enum DataManagerKey: DependencyKey {
+ static var liveValue: any DataManager {
+ LiveDataManager()
+ }
+ static var testValue: any DataManager {
+ InMemoryDataManager()
+ }
+}
+
+extension DependencyValues {
+ package var dataManager: DataManager {
+ get { self[DataManagerKey.self] }
+ set { self[DataManagerKey.self] = newValue }
+ }
+}
diff --git a/Sources/SQLiteData/Internal/Exports.swift b/Sources/SQLiteData/Internal/Exports.swift
new file mode 100644
index 00000000..e59b61a1
--- /dev/null
+++ b/Sources/SQLiteData/Internal/Exports.swift
@@ -0,0 +1,12 @@
+@_exported import Dependencies
+@_exported import StructuredQueriesSQLite
+
+@_exported import struct GRDB.Configuration
+@_exported import class GRDB.Database
+@_exported import struct GRDB.DatabaseError
+@_exported import struct GRDB.DatabaseMigrator
+@_exported import class GRDB.DatabasePool
+@_exported import class GRDB.DatabaseQueue
+@_exported import protocol GRDB.DatabaseReader
+@_exported import protocol GRDB.DatabaseWriter
+@_exported import protocol GRDB.ValueObservationScheduler
diff --git a/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift b/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift
new file mode 100644
index 00000000..83abd437
--- /dev/null
+++ b/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift
@@ -0,0 +1,47 @@
+#if canImport(SwiftUI)
+ import GRDB
+ import Sharing
+ import SwiftUI
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension SharedReaderKey {
+ static func fetch(
+ _ request: some FetchKeyRequest,
+ database: (any DatabaseReader)? = nil,
+ animation: Animation
+ ) -> Self
+ where Self == FetchKey {
+ .fetch(request, database: database, scheduler: .animation(animation))
+ }
+
+ static func fetch(
+ _ request: some FetchKeyRequest,
+ database: (any DatabaseReader)? = nil,
+ animation: Animation
+ ) -> Self
+ where Self == FetchKey.Default {
+ .fetch(request, database: database, scheduler: .animation(animation))
+ }
+ }
+
+ package struct AnimatedScheduler: ValueObservationScheduler, Equatable {
+ let animation: Animation
+ package func immediateInitialValue() -> Bool { true }
+ package func schedule(_ action: @escaping @Sendable () -> Void) {
+ DispatchQueue.main.async {
+ withAnimation(animation) {
+ action()
+ }
+ }
+ }
+ }
+
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ extension AnimatedScheduler: Hashable {}
+
+ extension ValueObservationScheduler where Self == AnimatedScheduler {
+ package static func animation(_ animation: Animation) -> Self {
+ AnimatedScheduler(animation: animation)
+ }
+ }
+#endif
diff --git a/Sources/SQLiteData/Internal/FetchKey.swift b/Sources/SQLiteData/Internal/FetchKey.swift
new file mode 100644
index 00000000..7761d728
--- /dev/null
+++ b/Sources/SQLiteData/Internal/FetchKey.swift
@@ -0,0 +1,190 @@
+import Dependencies
+import Dispatch
+import Foundation
+import GRDB
+import Sharing
+
+#if canImport(Combine)
+ @preconcurrency import Combine
+#endif
+
+extension SharedReaderKey {
+ static func fetch(
+ _ request: some FetchKeyRequest,
+ database: (any DatabaseReader)? = nil
+ ) -> Self
+ where Self == FetchKey {
+ FetchKey(request: request, database: database, scheduler: nil)
+ }
+
+ static func fetch(
+ _ request: some FetchKeyRequest,
+ database: (any DatabaseReader)? = nil
+ ) -> Self
+ where Self == FetchKey.Default {
+ Self[.fetch(request, database: database), default: Value()]
+ }
+}
+
+extension SharedReaderKey {
+ static func fetch(
+ _ request: some FetchKeyRequest,
+ database: (any DatabaseReader)? = nil,
+ scheduler: some ValueObservationScheduler & Hashable
+ ) -> Self
+ where Self == FetchKey {
+ FetchKey(request: request, database: database, scheduler: scheduler)
+ }
+
+ static func fetch(
+ _ request: some FetchKeyRequest,
+ database: (any DatabaseReader)? = nil,
+ scheduler: some ValueObservationScheduler & Hashable
+ ) -> Self
+ where Self == FetchKey.Default {
+ Self[.fetch(request, database: database, scheduler: scheduler), default: Value()]
+ }
+}
+
+struct FetchKey: SharedReaderKey {
+ let database: any DatabaseReader
+ let request: any FetchKeyRequest
+ let scheduler: (any ValueObservationScheduler & Hashable)?
+ #if DEBUG
+ let isDefaultDatabase: Bool
+ #endif
+
+ public typealias ID = FetchKeyID
+
+ public var id: ID {
+ ID(database: database, request: request, scheduler: scheduler)
+ }
+
+ init(
+ request: some FetchKeyRequest,
+ database: (any DatabaseReader)? = nil,
+ scheduler: (any ValueObservationScheduler & Hashable)?
+ ) {
+ @Dependency(\.defaultDatabase) var defaultDatabase
+ self.scheduler = scheduler
+ self.database = database ?? defaultDatabase
+ self.request = request
+ #if DEBUG
+ self.isDefaultDatabase = self.database.configuration.label == .defaultDatabaseLabel
+ #endif
+ }
+
+ public func load(context: LoadContext, continuation: LoadContinuation) {
+ #if DEBUG
+ guard !isDefaultDatabase else {
+ continuation.resumeReturningInitialValue()
+ return
+ }
+ #endif
+ guard case .userInitiated = context else {
+ continuation.resumeReturningInitialValue()
+ return
+ }
+ let scheduler: any ValueObservationScheduler = scheduler ?? ImmediateScheduler()
+ database.asyncRead { dbResult in
+ let result = dbResult.flatMap { db in
+ Result {
+ try request.fetch(db)
+ }
+ }
+ scheduler.schedule {
+ switch result {
+ case let .success(value):
+ continuation.resume(returning: value)
+ case let .failure(error):
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+ }
+
+ public func subscribe(
+ context: LoadContext, subscriber: SharedSubscriber
+ ) -> SharedSubscription {
+ #if DEBUG
+ guard !isDefaultDatabase else {
+ return SharedSubscription {}
+ }
+ #endif
+ let observation = ValueObservation.tracking { db in
+ Result { try request.fetch(db) }
+ }
+
+ let scheduler: any ValueObservationScheduler = scheduler ?? ImmediateScheduler()
+ #if canImport(Combine)
+ let dropFirst =
+ switch context {
+ case .initialValue: false
+ case .userInitiated: true
+ }
+ let cancellable = observation.publisher(in: database, scheduling: scheduler)
+ .dropFirst(dropFirst ? 1 : 0)
+ .sink { completion in
+ switch completion {
+ case let .failure(error):
+ subscriber.yield(throwing: error)
+ case .finished:
+ break
+ }
+ } receiveValue: { newValue in
+ switch newValue {
+ case let .success(value):
+ subscriber.yield(value)
+ case let .failure(error):
+ subscriber.yield(throwing: error)
+ }
+ }
+ return SharedSubscription {
+ cancellable.cancel()
+ }
+ #else
+ let cancellable = observation.start(in: database, scheduling: scheduler) { error in
+ subscriber.yield(throwing: error)
+ } onChange: { newValue in
+ switch newValue {
+ case let .success(value):
+ subscriber.yield(value)
+ case let .failure(error):
+ subscriber.yield(throwing: error)
+ }
+ }
+ return SharedSubscription {
+ cancellable.cancel()
+ }
+ #endif
+ }
+}
+
+struct FetchKeyID: Hashable {
+ fileprivate let databaseID: ObjectIdentifier
+ fileprivate let request: AnyHashableSendable
+ fileprivate let requestTypeID: ObjectIdentifier
+ fileprivate let scheduler: AnyHashableSendable?
+
+ fileprivate init(
+ database: any DatabaseReader,
+ request: some FetchKeyRequest,
+ scheduler: (any ValueObservationScheduler & Hashable)?
+ ) {
+ self.databaseID = ObjectIdentifier(database)
+ self.request = AnyHashableSendable(request)
+ self.requestTypeID = ObjectIdentifier(type(of: request))
+ self.scheduler = scheduler.map { AnyHashableSendable($0) }
+ }
+}
+
+public struct NotFound: Error {
+ public init() {}
+}
+
+private struct ImmediateScheduler: ValueObservationScheduler, Hashable {
+ func immediateInitialValue() -> Bool { true }
+ func schedule(_ action: @escaping @Sendable () -> Void) {
+ action()
+ }
+}
diff --git a/Sources/StructuredQueriesGRDBCore/Internal/ISO8601.swift b/Sources/SQLiteData/Internal/ISO8601.swift
similarity index 100%
rename from Sources/StructuredQueriesGRDBCore/Internal/ISO8601.swift
rename to Sources/SQLiteData/Internal/ISO8601.swift
diff --git a/Sources/SharingGRDBCore/Internal/StatementKey.swift b/Sources/SQLiteData/Internal/StatementKey.swift
similarity index 100%
rename from Sources/SharingGRDBCore/Internal/StatementKey.swift
rename to Sources/SQLiteData/Internal/StatementKey.swift
diff --git a/Sources/SQLiteData/Internal/UserDatabase.swift b/Sources/SQLiteData/Internal/UserDatabase.swift
new file mode 100644
index 00000000..520e8601
--- /dev/null
+++ b/Sources/SQLiteData/Internal/UserDatabase.swift
@@ -0,0 +1,55 @@
+import Dependencies
+import GRDB
+
+package struct UserDatabase {
+ package let database: any DatabaseWriter
+ package init(database: any DatabaseWriter) {
+ self.database = database
+ }
+
+ var path: String {
+ database.path
+ }
+
+ var configuration: Configuration {
+ database.configuration
+ }
+
+ package func write(
+ _ updates: @Sendable (Database) throws -> T
+ ) async throws -> T {
+ try await database.write { db in
+ try $_isSynchronizingChanges.withValue(true) {
+ try updates(db)
+ }
+ }
+ }
+
+ package func read(
+ _ updates: @Sendable (Database) throws -> T
+ ) async throws -> T {
+ try await database.read { db in
+ try updates(db)
+ }
+ }
+
+ @_disfavoredOverload
+ package func write(
+ _ updates: (Database) throws -> T
+ ) throws -> T {
+ try database.write { db in
+ try $_isSynchronizingChanges.withValue(true) {
+ try updates(db)
+ }
+ }
+ }
+
+ @_disfavoredOverload
+ package func read(
+ _ updates: (Database) throws -> T
+ ) throws -> T {
+ try database.read { db in
+ try updates(db)
+ }
+ }
+}
diff --git a/Sources/StructuredQueriesGRDBCore/CustomFunctions.swift b/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift
similarity index 100%
rename from Sources/StructuredQueriesGRDBCore/CustomFunctions.swift
rename to Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift
diff --git a/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift
similarity index 61%
rename from Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift
rename to Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift
index b495710e..fb3a044e 100644
--- a/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift
+++ b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift
@@ -1,6 +1,50 @@
import Dependencies
+import Foundation
import GRDB
+/// Prepares a context-sensitive database writer.
+///
+/// * In a live app context (e.g. simulator, device), a database pool is provisioned in the app
+/// container (unless explicitly overridden with the `path` parameter).
+/// * In an Xcode preview context, an in-memory database is provisioned.
+/// * In a test context, a database pool is provisioned at a temporary file.
+///
+/// - Parameters:
+/// - path: A path to the database. If `nil`, a path to a file in the application support
+/// directory will be used.
+/// - configuration: A database configuration.
+/// - Returns: A context-sensitive database writer.
+public func defaultDatabase(
+ path: String? = nil,
+ configuration: Configuration = Configuration()
+) throws -> any DatabaseWriter {
+ let database: any DatabaseWriter
+ @Dependency(\.context) var context
+ switch context {
+ case .live:
+ var defaultPath: String {
+ get throws {
+ let applicationSupportDirectory = try FileManager.default.url(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask,
+ appropriateFor: nil,
+ create: true
+ )
+ return applicationSupportDirectory.appendingPathComponent("SQLiteData.db").absoluteString
+ }
+ }
+ database = try DatabasePool(path: path ?? defaultPath, configuration: configuration)
+ case .preview:
+ database = try DatabaseQueue(configuration: configuration)
+ case .test:
+ database = try DatabasePool(
+ path: "\(NSTemporaryDirectory())\(UUID().uuidString).db",
+ configuration: configuration
+ )
+ }
+ return database
+}
+
extension DependencyValues {
/// The default database used by `fetchAll`, `fetchOne`, and `fetch`.
///
@@ -8,7 +52,7 @@ extension DependencyValues {
/// SwiftUI, using `prepareDependencies`:
///
/// ```swift
- /// import SharingGRDB
+ /// import SQLiteData
/// import SwiftUI
///
/// @main
@@ -52,7 +96,7 @@ extension DependencyValues {
case .live:
return """
A blank, in-memory database is being used. To set the database that is used by \
- 'SharingGRDB', use the 'prepareDependencies' tool as early as possible in the lifetime \
+ 'SQLiteData', use the 'prepareDependencies' tool as early as possible in the lifetime \
of your app, such as in your app or scene delegate in UIKit, or the app entry point in \
SwiftUI:
@@ -70,7 +114,7 @@ extension DependencyValues {
case .preview:
return """
A blank, in-memory database is being used. To set the database that is used by \
- 'SharingGRDB' in a preview, use a tool like 'prepareDependencies':
+ 'SQLiteData' in a preview, use a tool like 'prepareDependencies':
#Preview {
let _ = prepareDependencies {
@@ -83,7 +127,7 @@ extension DependencyValues {
case .test:
return """
A blank, in-memory database is being used. To set the database that is used by \
- 'SharingGRDB' in a test, use a tool like the 'dependency' trait from \
+ 'SQLiteData' in a test, use a tool like the 'dependency' trait from \
'DependenciesTestSupport':
import DependenciesTestSupport
@@ -109,6 +153,6 @@ extension DependencyValues {
#if DEBUG
extension String {
- package static let defaultDatabaseLabel = "co.pointfree.SharingGRDB.testValue"
+ package static let defaultDatabaseLabel = "co.pointfree.SQLiteData.testValue"
}
#endif
diff --git a/Sources/StructuredQueriesGRDBCore/QueryCursor.swift b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift
similarity index 96%
rename from Sources/StructuredQueriesGRDBCore/QueryCursor.swift
rename to Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift
index 9bb396b2..6fe5c4fc 100644
--- a/Sources/StructuredQueriesGRDBCore/QueryCursor.swift
+++ b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift
@@ -55,7 +55,7 @@ final class QueryValueCursor: QueryCursor {
typealias Element = ()
// NB: Required to workaround a "Legacy previews execution" bug
- // https://github.com/pointfreeco/sharing-grdb/pull/60
+ // https://github.com/pointfreeco/sqlite-data/pull/60
@usableFromInline
override init(db: Database, query: QueryFragment) throws {
try super.init(db: db, query: query)
diff --git a/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift
similarity index 100%
rename from Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift
rename to Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift
diff --git a/Sources/StructuredQueriesGRDBCore/Seed.swift b/Sources/SQLiteData/StructuredQueries+GRDB/Seed.swift
similarity index 100%
rename from Sources/StructuredQueriesGRDBCore/Seed.swift
rename to Sources/SQLiteData/StructuredQueries+GRDB/Seed.swift
diff --git a/Sources/StructuredQueriesGRDBCore/Statement+GRDB.swift b/Sources/SQLiteData/StructuredQueries+GRDB/Statement+GRDB.swift
similarity index 100%
rename from Sources/StructuredQueriesGRDBCore/Statement+GRDB.swift
rename to Sources/SQLiteData/StructuredQueries+GRDB/Statement+GRDB.swift
diff --git a/Sources/SQLiteData/Traits/Tagged.swift b/Sources/SQLiteData/Traits/Tagged.swift
new file mode 100644
index 00000000..c7fe36bb
--- /dev/null
+++ b/Sources/SQLiteData/Traits/Tagged.swift
@@ -0,0 +1,14 @@
+#if SQLiteDataTagged
+ import Tagged
+
+ extension Tagged: IdentifierStringConvertible where RawValue: IdentifierStringConvertible {
+ public init?(rawIdentifier: String) {
+ guard let rawValue = RawValue(rawIdentifier: rawIdentifier) else { return nil }
+ self.init(rawValue)
+ }
+
+ public var rawIdentifier: String {
+ rawValue.rawIdentifier
+ }
+ }
+#endif
diff --git a/Sources/SharingGRDBTestSupport/AssertQuery.swift b/Sources/SQLiteDataTestSupport/AssertQuery.swift
similarity index 98%
rename from Sources/SharingGRDBTestSupport/AssertQuery.swift
rename to Sources/SQLiteDataTestSupport/AssertQuery.swift
index 86ca41f7..3009db47 100644
--- a/Sources/SharingGRDBTestSupport/AssertQuery.swift
+++ b/Sources/SQLiteDataTestSupport/AssertQuery.swift
@@ -3,8 +3,8 @@ import Dependencies
import Foundation
import GRDB
import InlineSnapshotTesting
+import SQLiteData
import StructuredQueriesCore
-import StructuredQueriesGRDBCore
import StructuredQueriesTestSupport
/// An end-to-end snapshot testing helper for database content.
@@ -47,7 +47,9 @@ import StructuredQueriesTestSupport
/// - column: The source `#column` associated with the assertion.
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@_disfavoredOverload
-public func assertQuery>(
+public func assertQuery<
+ each V: QueryRepresentable, S: StructuredQueriesCore.Statement<(repeat each V)>
+>(
includeSQL: Bool = false,
_ query: S,
database: (any DatabaseWriter)? = nil,
diff --git a/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md b/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md
deleted file mode 100644
index 416a0572..00000000
--- a/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# ``SharingGRDB``
-
-A fast, lightweight replacement for SwiftData, powered by SQL.
-
-## Overview
-
-The core functionality of this library is defined in
-[`SharingGRDBCore`](sharinggrdbcore) and [`StructuredQueriesGRDBCore`](structuredquereisgrdbcore),
-which this module automatically exports.
-
-> Note: This module also exports `StructuredQueries`, which provides the `@Table` macro for building
-> and decoding queries. If you are using [GRDB][]'s built-in tools instead of
-> [StructuredQueries][], consider depending on `SharingGRDBCore`, instead.
-
-See [`SharingGRDBCore`](sharinggrdbcore) for documentation on the integration with the
-`@FetchAll` property wrapper, which is equivalent to SwiftData's `@Query`.
-
-See [`StructuredQueriesGRDBCore`](sharinggrdbcore) for documentation on the integration between
-[StructuredQueries][] and [GRDB][].
-
-> Tip: SharingGRDB's primary product is the `SharingGRDB` module, which includes all of the
-> library's functionality, including the `@Fetch` family of property wrappers, the `@Table` macro,
-> and tools for driving StructuredQueries using GRDB. This is the module that most library users
-> should depend on.
->
-> If you are a library author that wishes to extend SharingGRDB with additional functionality, you
-> may want to depend on a different module:
->
-> * [`SharingGRDBCore`](sharinggrdbcore): This product includes everything in `SharingGRDB`
-> _except_ the macros (`@Table`, `#sql`, _etc._). This module can be imported to extend
-> SharingGRDB with additional functionality without forcing the heavyweight dependency of
-> SwiftSyntax on your users.
-> * `StructuredQueriesGRDB`: This product includes everything in `SharingGRDB` _except_ the
-> `@Fetch` family of property wrappers. It can be imported if you want to extend
-> StructuredQueries' GRDB driver but do not need access to observation tools provided by
-> Sharing.
-> * [`StructuredQueriesGRDBCore`](sharinggrdbcore): This product includes everything in
-> `StructuredQueriesGRDB` _except_ the macros. This module can be imported to extend
-> StructuredQueries' GRDB driver with additional functionality without forcing the heavyweight
-> dependency of SwiftSyntax on your users.
-
-[GRDB]: https://github.com/groue/GRDB.swift
-[StructuredQueries]: https://github.com/pointfreeco/swift-structured-queries
diff --git a/Sources/SharingGRDB/Exports.swift b/Sources/SharingGRDB/Exports.swift
deleted file mode 100644
index 07a5c659..00000000
--- a/Sources/SharingGRDB/Exports.swift
+++ /dev/null
@@ -1,2 +0,0 @@
-@_exported import SharingGRDBCore
-@_exported import StructuredQueriesGRDB
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/Deprecations.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/Deprecations.md
deleted file mode 100644
index b2eb37c8..00000000
--- a/Sources/SharingGRDBCore/Documentation.docc/Articles/Deprecations.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# Deprecations
-
-Review unsupported APIs and their replacements.
-
-## Overview
-
-Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use
-instead.
-
-## Topics
-
-### Sharing extensions
-
-- ``Sharing``
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides.md
deleted file mode 100644
index 38e793af..00000000
--- a/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# Migration guides
-
-Learn how to upgrade your application to the newest version of SharingGRDB.
-
-## Overview
-
-SharingGRDB is under constant development, and we are always looking for ways to
-simplify the library, and make it more powerful. As such, we often need to deprecate certain APIs
-in favor of newer ones. We recommend people update their code as quickly as possible to the newest
-APIs, and these guides contain tips to do so.
-
-> Important: Before following any particular migration guide be sure you have followed all the
-> preceding migration guides.
-
-## Topics
-
--
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md
deleted file mode 100644
index 9495bcbc..00000000
--- a/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md
+++ /dev/null
@@ -1,70 +0,0 @@
-# Migrating to 0.2
-
-Update your code to make use of powerful new querying capabilities.
-
-## Overview
-
-SharingGRDB is under constant development, and we are always looking for ways to
-simplify the library, and make it more powerful. As such, we often need to deprecate certain APIs
-in favor of newer ones. We recommend people update their code as quickly as possible to the newest
-APIs, and these guides contain tips to do so.
-
-* [@FetchAll, @FetchOne, @Fetch](#)
-* [fetchAll, fetchOne, fetch: soft-deprecated](#)
-* [Avoiding the cost of macros](#)
-
-## @FetchAll, @FetchOne, @Fetch
-
-SharingGRDB 0.2.0 comes with 3 brand new property wrappers that largely replace the need for
-SwiftData and its `@Query` macro. In 0.1.0, one would perform queries as either a hard coded SQL
-string:
-
-```swift
-@SharedReader(.fetchAll(sql: "SELECT * FROM reminders WHERE isCompleted ORDER BY title"))
-var completedReminders: [Reminder]
-```
-
-Or by defining a ``FetchKeyRequest`` conformance to perform a query using GRDB's query builder:
-
-```swift
-struct CompletedReminders: FetchKeyRequest {
- func fetch(_ db: Database) throws -> [Reminder] {
- Reminder.all()
- .where(Column("isCompleted"))
- .order(Column("title"))
- }
-}
-
-@SharedReader(.fetch(CompletedReminders()))
-var completedReminders
-```
-
-Each of these are cumbersome, and version 0.2.0 of SharingGRDB fixes things thanks to our newly
-released [StructuredQueries][] library. You can now describe the query for your data in a type-safe
-manner, and directly inline:
-
-```swift
-@FetchAll(Reminder.where(\.isCompleted).order(by: \.title))
-var completedReminders: [Reminder]
-```
-
-Read for more information on how to use these new property wrappers.
-
-[StructuredQueries]: http://github.com/pointfreeco/swift-structured-queries
-
-## fetchAll, fetchOne, fetch: soft-deprecated
-
-The [`.fetchAll`](),
-[`.fetchOne`](),
-and [`.fetch`]() APIs have been soft-deprecated
-in favor of the more modern tools described above and in . They will be hard
-deprecated in a future release of SharingGRDB, and removed in 1.0.
-
-## Avoiding the cost of macros
-
-SharingGRDB introduces a macro in version 0.2.0 (in particular, the `@Table` macro), and
-unfortunately macros currently come with an unfortunate cost in that you have to compile SwiftSyntax
-from scratch, which can take time. If the cost of macros is too high for you, then you can depend
-on the SharingGRDBCore module instead of the full SharingGRDB module. This will give you access to
-only a subset of tools provided by SharingGRDB, but you will have access to all tools that were
-available in version 0.1.0 of the library.
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKey.md b/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKey.md
deleted file mode 100644
index 49ed8509..00000000
--- a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKey.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# ``SharingGRDBCore/FetchKey``
-
-## Topics
-
-### Key identity
-
-- ``FetchKeyID``
-- ``ID-swift.typealias``
diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKeyRequest.md b/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKeyRequest.md
deleted file mode 100644
index 501238a2..00000000
--- a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKeyRequest.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# ``SharingGRDBCore/FetchKeyRequest``
-
-## Topics
-
-### Fetch keys
-
-- ``FetchKey``
-
-### Error handling
-
-- ``NotFound``
diff --git a/Sources/SharingGRDBCore/FetchKey+SwiftUI.swift b/Sources/SharingGRDBCore/FetchKey+SwiftUI.swift
deleted file mode 100644
index 9ca42d0d..00000000
--- a/Sources/SharingGRDBCore/FetchKey+SwiftUI.swift
+++ /dev/null
@@ -1,138 +0,0 @@
-#if canImport(SwiftUI)
- import GRDB
- import Sharing
- import SwiftUI
-
- extension SharedReaderKey {
- /// A key that can query for data in a SQLite database.
- ///
- /// A version of `fetch` that can be configured with a SwiftUI animation.
- ///
- /// - Parameters:
- /// - request: A request describing the data to fetch.
- /// - database: The database to read from. A value of `nil` will use the default database.
- /// - animation: The animation to use for user interface changes that result from changes to
- /// the fetched results.
- /// - Returns: A key that can be passed to the `@SharedReader` property wrapper.
- @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead")
- @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead")
- @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead")
- @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead")
- @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
- public static func fetch(
- _ request: some FetchKeyRequest
|