From 3a15a44fba8a0139a8790a27ae94e0737981e73b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 13 Nov 2025 15:43:57 -0600 Subject: [PATCH 1/9] Support tasks for loading query. --- Sources/SQLiteData/FetchAll.swift | 25 +++++++++-- Tests/SQLiteDataTests/FetchTaskTests.swift | 51 ++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 Tests/SQLiteDataTests/FetchTaskTests.swift diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index 0b41db44..cba28758 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -176,10 +176,11 @@ public struct FetchAll: Sendable { /// - 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)`). + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchTask<[Element]> where Element == S.From.QueryOutput, S.QueryValue == (), @@ -187,7 +188,7 @@ public struct FetchAll: Sendable { S.Joins == () { let statement = statement.selectStar() - try await load(statement, database: database) + return try await load(statement, database: database) } /// Replaces the wrapped value with data from the given query. @@ -196,10 +197,11 @@ public struct FetchAll: Sendable { /// - 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)`). + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchTask<[Element]> where Element == V.QueryOutput, V.QueryOutput: Sendable @@ -210,6 +212,23 @@ public struct FetchAll: Sendable { database: database ) ) + return FetchTask(sharedReader: sharedReader) + } +} + +public struct FetchTask: Sendable { + let sharedReader: SharedReader + init(sharedReader: SharedReader) { + self.sharedReader = sharedReader + } + public var task: Void { + get async throws { + try await withTaskCancellationHandler { + try await Task.never() + } onCancel: { + sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) + } + } } } diff --git a/Tests/SQLiteDataTests/FetchTaskTests.swift b/Tests/SQLiteDataTests/FetchTaskTests.swift new file mode 100644 index 00000000..a0609094 --- /dev/null +++ b/Tests/SQLiteDataTests/FetchTaskTests.swift @@ -0,0 +1,51 @@ +import DependenciesTestSupport +import Foundation +import SQLiteData +import Testing + +@Suite(.dependency(\.defaultDatabase, try .database())) struct FetchTaskTests { + @Dependency(\.defaultDatabase) var database + + @Test func stopSubscriptionWhenTaskCancelled() async throws { + @FetchAll var records: [Record] + #expect(records.count == 0) + + try await database.write { db in + try Record.insert { Record.Draft() }.execute(db) + } + try await $records.load() + #expect(records.count == 1) + + let task = Task { [$records] in + try? await $records.load(Record.all).task + } + task.cancel() + await task.value + try await database.write { db in + try Record.insert { Record.Draft() }.execute(db) + } + try await $records.load() + #expect(records.count == 1) + } +} + +@Table +private struct Record: Equatable { + let id: Int +} +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func database() throws -> DatabaseQueue { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "records" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT + ) + """ + ) + .execute(db) + } + return database + } +} From d6a789f6a7a57e7ae2544c0813b89e65608e72df Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 13 Nov 2025 16:40:31 -0800 Subject: [PATCH 2/9] wip --- .../Documentation.docc/SQLiteData.md | 1 + Sources/SQLiteData/Fetch.swift | 13 ++- Sources/SQLiteData/FetchAll.swift | 40 ++++----- Sources/SQLiteData/FetchOne.swift | 86 ++++++++++++++----- Sources/SQLiteData/FetchTask.swift | 36 ++++++++ 5 files changed, 131 insertions(+), 45 deletions(-) create mode 100644 Sources/SQLiteData/FetchTask.swift diff --git a/Sources/SQLiteData/Documentation.docc/SQLiteData.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md index 943a8fa3..2b577422 100644 --- a/Sources/SQLiteData/Documentation.docc/SQLiteData.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -307,6 +307,7 @@ with SQLite to take full advantage of GRDB and SQLiteData. - ``FetchAll`` - ``FetchOne`` - ``Fetch`` +- ``FetchTask`` ### CloudKit synchronization and sharing diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index 5f916314..950f460b 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -98,11 +98,13 @@ public struct Fetch: Sendable { /// - request: A request describing the data to fetch. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A fetch task associated with the observation. public func load( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil - ) async throws { + ) async throws -> FetchTask { try await sharedReader.load(.fetch(request, database: database)) + return FetchTask(sharedReader: sharedReader) } } @@ -136,12 +138,14 @@ extension Fetch { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A fetch task associated with the observation. public func load( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws { + ) async throws -> FetchTask { try await sharedReader.load(.fetch(request, database: database, scheduler: scheduler)) + return FetchTask(sharedReader: sharedReader) } } @@ -193,13 +197,16 @@ extension Fetch: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A fetch task associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws { + ) async throws -> FetchTask { try await sharedReader.load(.fetch(request, database: database, animation: animation)) + return FetchTask(sharedReader: sharedReader) } } #endif diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index cba28758..4c6a4cf5 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -176,6 +176,7 @@ public struct FetchAll: Sendable { /// - 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)`). + /// - Returns: A fetch task associated with the observation. @discardableResult public func load( _ statement: S, @@ -197,6 +198,7 @@ public struct FetchAll: Sendable { /// - 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)`). + /// - Returns: A fetch task associated with the observation. @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, @@ -216,22 +218,6 @@ public struct FetchAll: Sendable { } } -public struct FetchTask: Sendable { - let sharedReader: SharedReader - init(sharedReader: SharedReader) { - self.sharedReader = sharedReader - } - public var task: Void { - get async throws { - try await withTaskCancellationHandler { - try await Task.never() - } onCancel: { - sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) - } - } - } -} - extension FetchAll { /// Initializes this property with a query that fetches every row from a table. /// @@ -342,11 +328,13 @@ extension FetchAll { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchTask<[Element]> where Element == S.From.QueryOutput, S.QueryValue == (), @@ -354,7 +342,7 @@ extension FetchAll { S.Joins == () { let statement = statement.selectStar() - try await load(statement, database: database, scheduler: scheduler) + return try await load(statement, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given query. @@ -365,11 +353,13 @@ extension FetchAll { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchTask<[Element]> where Element == V.QueryOutput, V.QueryOutput: Sendable @@ -381,6 +371,7 @@ extension FetchAll { scheduler: scheduler ) ) + return FetchTask(sharedReader: sharedReader) } } @@ -514,12 +505,14 @@ extension FetchAll: Equatable where Element: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A fetch task associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchTask<[Element]> where Element == S.From.QueryOutput, S.QueryValue == (), @@ -527,7 +520,7 @@ extension FetchAll: Equatable where Element: Equatable { S.Joins == () { let statement = statement.selectStar() - try await load(statement, database: database, animation: animation) + return try await load(statement, database: database, animation: animation) } /// Replaces the wrapped value with data from the given query. @@ -538,12 +531,14 @@ extension FetchAll: Equatable where Element: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A fetch task associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchTask<[Element]> where Element == V.QueryOutput, V.QueryOutput: Sendable @@ -555,6 +550,7 @@ extension FetchAll: Equatable where Element: Equatable { animation: animation ) ) + return FetchTask(sharedReader: sharedReader) } } #endif diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 80348bef..84adebb2 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -299,17 +299,19 @@ public struct FetchOne: Sendable { /// - 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)`). + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchTask where Value == S.From.QueryOutput, S.QueryValue == (), S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) - try await load(statement, database: database) + return try await load(statement, database: database) } /// Replaces the wrapped value with data from the given query. @@ -318,16 +320,19 @@ public struct FetchOne: Sendable { /// - 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)`). + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchTask where Value == V.QueryOutput { try await sharedReader.load( .fetch(FetchOneStatementValueRequest(statement: statement), database: database) ) + return FetchTask(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -336,16 +341,19 @@ public struct FetchOne: Sendable { /// - 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)`). + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchTask where Value == V.QueryOutput? { try await sharedReader.load( .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) ) + return FetchTask(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -354,10 +362,12 @@ public struct FetchOne: Sendable { /// - 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)`). + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchTask where Value: _OptionalProtocol, Value == S.From.QueryOutput?, @@ -368,6 +378,7 @@ public struct FetchOne: Sendable { try await sharedReader.load( .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) ) + return FetchTask(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -376,10 +387,12 @@ public struct FetchOne: Sendable { /// - 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)`). + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchTask where Value: _OptionalProtocol, S.QueryValue: QueryRepresentable, @@ -389,6 +402,7 @@ public struct FetchOne: Sendable { try await sharedReader.load( .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) ) + return FetchTask(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -397,10 +411,12 @@ public struct FetchOne: Sendable { /// - 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)`). + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchTask where Value: QueryRepresentable, Value: _OptionalProtocol, @@ -409,6 +425,7 @@ public struct FetchOne: Sendable { try await sharedReader.load( .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) ) + return FetchTask(sharedReader: sharedReader) } } @@ -679,18 +696,20 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchTask where Value == S.From.QueryOutput, S.QueryValue == (), S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) - try await load(statement, database: database, scheduler: scheduler) + return try await load(statement, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given query. @@ -701,11 +720,13 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchTask where Value == V.QueryOutput { @@ -716,6 +737,7 @@ extension FetchOne { scheduler: scheduler ) ) + return FetchTask(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -726,11 +748,13 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchTask where Value == V.QueryOutput? { @@ -741,6 +765,7 @@ extension FetchOne { scheduler: scheduler ) ) + return FetchTask(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -751,11 +776,13 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchTask where Value: _OptionalProtocol, Value == S.From.QueryOutput?, @@ -770,6 +797,7 @@ extension FetchOne { scheduler: scheduler ) ) + return FetchTask(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -780,11 +808,13 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchTask where Value: _OptionalProtocol, S.QueryValue: QueryRepresentable, @@ -798,6 +828,7 @@ extension FetchOne { scheduler: scheduler ) ) + return FetchTask(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -808,11 +839,13 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A fetch task associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchTask where Value: QueryRepresentable, Value: _OptionalProtocol, @@ -825,6 +858,7 @@ extension FetchOne { scheduler: scheduler ) ) + return FetchTask(sharedReader: sharedReader) } } @@ -1096,12 +1130,14 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A fetch task associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchTask where Value == S.From.QueryOutput, S.QueryValue == (), @@ -1118,12 +1154,14 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A fetch task associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchTask where Value == V.QueryOutput { @@ -1138,12 +1176,14 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A fetch task associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchTask where Value == V.QueryOutput? { @@ -1158,12 +1198,14 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A fetch task associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchTask where Value: _OptionalProtocol, Value == S.From.QueryOutput?, @@ -1181,12 +1223,14 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A fetch task associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchTask where Value: _OptionalProtocol, S.QueryValue: QueryRepresentable, @@ -1204,12 +1248,14 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A fetch task associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchTask where Value: QueryRepresentable, Value: _OptionalProtocol, diff --git a/Sources/SQLiteData/FetchTask.swift b/Sources/SQLiteData/FetchTask.swift new file mode 100644 index 00000000..a887ef08 --- /dev/null +++ b/Sources/SQLiteData/FetchTask.swift @@ -0,0 +1,36 @@ +import Sharing + +/// A task associated with `@FetchAll`, `@FetchOne`, and `@Fetch` observation. +/// +/// This value can be useful in associating the lifetime of observing a query to the lifetime of a +/// SwiftUI view _via_ the `task` view modifier. For example, loading a query in a view's `task` +/// will automatically cancel the observation when drilling down into a child view, and restart +/// observation when popping back to the view: +/// +/// ```swift +/// .task { +/// try? await $reminders.load(Reminder.all).task +/// } +/// ``` +public struct FetchTask: Sendable { + let sharedReader: SharedReader + + /// An async handle to the given fetch observation. + /// + /// This handle will suspend until the current task is cancelled, at which point it will terminate + /// the observation of the associated ``FetchAll``, ``FetchOne``, or ``Fetch``. + public var task: Void { + get async throws { + try await withTaskCancellationHandler { + try await Task.never() + } onCancel: { + cancel() + } + } + } + + /// Cancels the database observation of the associated ``FetchAll``, ``FetchOne``, or ``Fetch``. + public func cancel() { + sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) + } +} From 7ad522590de1ca449ed827c8989f699a55ec365d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 14 Nov 2025 10:30:32 -0800 Subject: [PATCH 3/9] wip --- .../xcshareddata/swiftpm/Package.resolved | 20 +------ Examples/Reminders/ReminderForm.swift | 2 +- Examples/Reminders/SearchReminders.swift | 1 + Package.swift | 2 + Sources/SQLiteData/Fetch.swift | 12 ++-- Sources/SQLiteData/FetchAll.swift | 18 +++--- Sources/SQLiteData/FetchOne.swift | 56 +++++++++---------- ...etchTask.swift => FetchSubscription.swift} | 20 ++++--- Tests/SQLiteDataTests/FetchTaskTests.swift | 25 +++++++++ 9 files changed, 86 insertions(+), 70 deletions(-) rename Sources/SQLiteData/{FetchTask.swift => FetchSubscription.swift} (68%) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 587d0c8d..a0d54872 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "41e7781e6c506773b6af84af513bcd6d3b1be59d635e6c4c4bd89638368e4629", + "originHash" : "c133bf7d10c8ce1e5d6506c3d2f080eac8b4c8c2827044d53a9b925e903564fd", "pins" : [ { "identity" : "combine-schedulers", @@ -73,24 +73,6 @@ "version" : "1.9.4" } }, - { - "identity" : "swift-docc-plugin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-plugin", - "state" : { - "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", - "version" : "1.4.5" - } - }, - { - "identity" : "swift-docc-symbolkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-docc-symbolkit", - "state" : { - "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", - "version" : "1.0.0" - } - }, { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index b7b70ed3..2d10fc76 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -122,7 +122,7 @@ struct ReminderFormView: View { } .task(id: reminder.remindersListID) { await withErrorReporting { - try await $remindersList.load(RemindersList.find(reminder.remindersListID)) + try await $remindersList.load(RemindersList.find(reminder.remindersListID)).task } } } diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index 3ec9522f..cafb14e9 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -107,6 +107,7 @@ class SearchRemindersModel { ), animation: .default ) + .task } } } diff --git a/Package.swift b/Package.swift index 0d607e9d..8e87eb48 100644 --- a/Package.swift +++ b/Package.swift @@ -32,6 +32,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), + .package(url: "https://github.com/pointfreeco/swift-perception", from: "2.0.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), .package( @@ -53,6 +54,7 @@ let package = Package( .product(name: "GRDB", package: "GRDB.swift"), .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "Perception", package: "swift-perception"), .product(name: "Sharing", package: "swift-sharing"), .product(name: "StructuredQueriesSQLite", package: "swift-structured-queries"), .product( diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index 950f460b..e1c62d82 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -102,9 +102,9 @@ public struct Fetch: Sendable { public func load( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil - ) async throws -> FetchTask { + ) async throws -> FetchSubscription { try await sharedReader.load(.fetch(request, database: database)) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } } @@ -143,9 +143,9 @@ extension Fetch { _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchTask { + ) async throws -> FetchSubscription { try await sharedReader.load(.fetch(request, database: database, scheduler: scheduler)) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } } @@ -204,9 +204,9 @@ extension Fetch: Equatable where Value: Equatable { _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchTask { + ) async throws -> FetchSubscription { try await sharedReader.load(.fetch(request, database: database, animation: animation)) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } } #endif diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index 4c6a4cf5..7d80106b 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -181,7 +181,7 @@ public struct FetchAll: Sendable { public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws -> FetchTask<[Element]> + ) async throws -> FetchSubscription<[Element]> where Element == S.From.QueryOutput, S.QueryValue == (), @@ -203,7 +203,7 @@ public struct FetchAll: Sendable { public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws -> FetchTask<[Element]> + ) async throws -> FetchSubscription<[Element]> where Element == V.QueryOutput, V.QueryOutput: Sendable @@ -214,7 +214,7 @@ public struct FetchAll: Sendable { database: database ) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } } @@ -334,7 +334,7 @@ extension FetchAll { _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchTask<[Element]> + ) async throws -> FetchSubscription<[Element]> where Element == S.From.QueryOutput, S.QueryValue == (), @@ -359,7 +359,7 @@ extension FetchAll { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchTask<[Element]> + ) async throws -> FetchSubscription<[Element]> where Element == V.QueryOutput, V.QueryOutput: Sendable @@ -371,7 +371,7 @@ extension FetchAll { scheduler: scheduler ) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } } @@ -512,7 +512,7 @@ extension FetchAll: Equatable where Element: Equatable { _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchTask<[Element]> + ) async throws -> FetchSubscription<[Element]> where Element == S.From.QueryOutput, S.QueryValue == (), @@ -538,7 +538,7 @@ extension FetchAll: Equatable where Element: Equatable { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchTask<[Element]> + ) async throws -> FetchSubscription<[Element]> where Element == V.QueryOutput, V.QueryOutput: Sendable @@ -550,7 +550,7 @@ extension FetchAll: Equatable where Element: Equatable { animation: animation ) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } } #endif diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 84adebb2..20489f52 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -304,7 +304,7 @@ public struct FetchOne: Sendable { public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value == S.From.QueryOutput, S.QueryValue == (), @@ -325,14 +325,14 @@ public struct FetchOne: Sendable { public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value == V.QueryOutput { try await sharedReader.load( .fetch(FetchOneStatementValueRequest(statement: statement), database: database) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -346,14 +346,14 @@ public struct FetchOne: Sendable { public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value == V.QueryOutput? { try await sharedReader.load( .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -367,7 +367,7 @@ public struct FetchOne: Sendable { public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value: _OptionalProtocol, Value == S.From.QueryOutput?, @@ -378,7 +378,7 @@ public struct FetchOne: Sendable { try await sharedReader.load( .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -392,7 +392,7 @@ public struct FetchOne: Sendable { public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value: _OptionalProtocol, S.QueryValue: QueryRepresentable, @@ -402,7 +402,7 @@ public struct FetchOne: Sendable { try await sharedReader.load( .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -416,7 +416,7 @@ public struct FetchOne: Sendable { public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value: QueryRepresentable, Value: _OptionalProtocol, @@ -425,7 +425,7 @@ public struct FetchOne: Sendable { try await sharedReader.load( .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } } @@ -702,7 +702,7 @@ extension FetchOne { _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value == S.From.QueryOutput, S.QueryValue == (), @@ -726,7 +726,7 @@ extension FetchOne { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value == V.QueryOutput { @@ -737,7 +737,7 @@ extension FetchOne { scheduler: scheduler ) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -754,7 +754,7 @@ extension FetchOne { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value == V.QueryOutput? { @@ -765,7 +765,7 @@ extension FetchOne { scheduler: scheduler ) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -782,7 +782,7 @@ extension FetchOne { _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value: _OptionalProtocol, Value == S.From.QueryOutput?, @@ -797,7 +797,7 @@ extension FetchOne { scheduler: scheduler ) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -814,7 +814,7 @@ extension FetchOne { _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value: _OptionalProtocol, S.QueryValue: QueryRepresentable, @@ -828,7 +828,7 @@ extension FetchOne { scheduler: scheduler ) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -845,7 +845,7 @@ extension FetchOne { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value: QueryRepresentable, Value: _OptionalProtocol, @@ -858,7 +858,7 @@ extension FetchOne { scheduler: scheduler ) ) - return FetchTask(sharedReader: sharedReader) + return FetchSubscription(sharedReader: sharedReader) } } @@ -1137,7 +1137,7 @@ extension FetchOne: Equatable where Value: Equatable { _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value == S.From.QueryOutput, S.QueryValue == (), @@ -1161,7 +1161,7 @@ extension FetchOne: Equatable where Value: Equatable { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value == V.QueryOutput { @@ -1183,7 +1183,7 @@ extension FetchOne: Equatable where Value: Equatable { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value == V.QueryOutput? { @@ -1205,7 +1205,7 @@ extension FetchOne: Equatable where Value: Equatable { _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value: _OptionalProtocol, Value == S.From.QueryOutput?, @@ -1230,7 +1230,7 @@ extension FetchOne: Equatable where Value: Equatable { _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value: _OptionalProtocol, S.QueryValue: QueryRepresentable, @@ -1255,7 +1255,7 @@ extension FetchOne: Equatable where Value: Equatable { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchTask + ) async throws -> FetchSubscription where Value: QueryRepresentable, Value: _OptionalProtocol, diff --git a/Sources/SQLiteData/FetchTask.swift b/Sources/SQLiteData/FetchSubscription.swift similarity index 68% rename from Sources/SQLiteData/FetchTask.swift rename to Sources/SQLiteData/FetchSubscription.swift index a887ef08..d10a61c2 100644 --- a/Sources/SQLiteData/FetchTask.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -1,3 +1,4 @@ +import Perception import Sharing /// A task associated with `@FetchAll`, `@FetchOne`, and `@Fetch` observation. @@ -12,25 +13,30 @@ import Sharing /// try? await $reminders.load(Reminder.all).task /// } /// ``` -public struct FetchTask: Sendable { +public struct FetchSubscription: Sendable { let sharedReader: SharedReader - + let cancellable = LockIsolated?>(nil) + /// An async handle to the given fetch observation. /// /// This handle will suspend until the current task is cancelled, at which point it will terminate /// the observation of the associated ``FetchAll``, ``FetchOne``, or ``Fetch``. public var task: Void { get async throws { - try await withTaskCancellationHandler { - try await Task.never() - } onCancel: { - cancel() + let task = Task { + try await withTaskCancellationHandler { + try await Task.never() + } onCancel: { + sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) + } } + cancellable.withValue { $0 = task } + try await task.cancellableValue } } /// Cancels the database observation of the associated ``FetchAll``, ``FetchOne``, or ``Fetch``. public func cancel() { - sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) + cancellable.value?.cancel() } } diff --git a/Tests/SQLiteDataTests/FetchTaskTests.swift b/Tests/SQLiteDataTests/FetchTaskTests.swift index a0609094..ec0e6e4a 100644 --- a/Tests/SQLiteDataTests/FetchTaskTests.swift +++ b/Tests/SQLiteDataTests/FetchTaskTests.swift @@ -27,6 +27,31 @@ import Testing try await $records.load() #expect(records.count == 1) } + + @Test func completeWhenTaskExplicitlyCancelled() async throws { + @FetchAll var records: [Record] + #expect(records.count == 0) + let didComplete = LockIsolated(false) + + try await database.write { db in + try Record.insert { Record.Draft() }.execute(db) + } + try await $records.load() + #expect(records.count == 1) + + let subscription = try await $records.load(Record.all) + + let task = Task { + try? await subscription.task + didComplete.withValue { $0 = true } + } + + try await Task.sleep(for: .seconds(1)) + + subscription.cancel() + await task.value + #expect(didComplete.value) + } } @Table From 1449accfc1147f8088c973e111f3a8f8bf42e000 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 14 Nov 2025 12:46:05 -0600 Subject: [PATCH 4/9] new test --- .../Documentation.docc/SQLiteData.md | 2 +- ...sts.swift => FetchSubscriptionTests.swift} | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) rename Tests/SQLiteDataTests/{FetchTaskTests.swift => FetchSubscriptionTests.swift} (69%) diff --git a/Sources/SQLiteData/Documentation.docc/SQLiteData.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md index 2b577422..ac07ac1f 100644 --- a/Sources/SQLiteData/Documentation.docc/SQLiteData.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -307,7 +307,7 @@ with SQLite to take full advantage of GRDB and SQLiteData. - ``FetchAll`` - ``FetchOne`` - ``Fetch`` -- ``FetchTask`` +- ``FetchSubscription`` ### CloudKit synchronization and sharing diff --git a/Tests/SQLiteDataTests/FetchTaskTests.swift b/Tests/SQLiteDataTests/FetchSubscriptionTests.swift similarity index 69% rename from Tests/SQLiteDataTests/FetchTaskTests.swift rename to Tests/SQLiteDataTests/FetchSubscriptionTests.swift index ec0e6e4a..e9599535 100644 --- a/Tests/SQLiteDataTests/FetchTaskTests.swift +++ b/Tests/SQLiteDataTests/FetchSubscriptionTests.swift @@ -3,7 +3,7 @@ import Foundation import SQLiteData import Testing -@Suite(.dependency(\.defaultDatabase, try .database())) struct FetchTaskTests { +@Suite(.dependency(\.defaultDatabase, try .database())) struct FetchSubscriptionTests { @Dependency(\.defaultDatabase) var database @Test func stopSubscriptionWhenTaskCancelled() async throws { @@ -52,6 +52,35 @@ import Testing await task.value #expect(didComplete.value) } + + @Test func cancellingOneFetchDoesNotCancelAnother() async throws { + @FetchAll var records1: [Record] + #expect(records1.count == 0) + let task1 = Task { [$records1] in + try? await $records1.load(Record.all).task + } + + @FetchAll var records2: [Record] + #expect(records2.count == 0) + await withUnsafeContinuation { continuation in + Task { [$records2] in + let subscription = try await $records2.load(Record.all) + continuation.resume() + try await subscription.task + } + } + + task1.cancel() + await task1.value + + try await database.write { db in + try Record.insert { Record.Draft() }.execute(db) + } + try await $records1.load() + try await $records2.load() + #expect(records1.count == 0) + #expect(records2.count == 1) + } } @Table From 402773584d9f5d1ecc27f6ed13c678664aa29fa9 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 14 Nov 2025 10:52:10 -0800 Subject: [PATCH 5/9] wip --- Sources/SQLiteData/Fetch.swift | 6 ++-- Sources/SQLiteData/FetchAll.swift | 12 ++++---- Sources/SQLiteData/FetchOne.swift | 36 +++++++++++----------- Sources/SQLiteData/FetchSubscription.swift | 2 +- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index e1c62d82..18c6f184 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -98,7 +98,7 @@ public struct Fetch: Sendable { /// - request: A request describing the data to fetch. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. public func load( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil @@ -138,7 +138,7 @@ extension Fetch { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. public func load( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, @@ -197,7 +197,7 @@ extension Fetch: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load( diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index 7d80106b..d3d3a6e3 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -176,7 +176,7 @@ public struct FetchAll: Sendable { /// - 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)`). - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: S, @@ -198,7 +198,7 @@ public struct FetchAll: Sendable { /// - 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)`). - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, @@ -328,7 +328,7 @@ extension FetchAll { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: S, @@ -353,7 +353,7 @@ extension FetchAll { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, @@ -505,7 +505,7 @@ extension FetchAll: Equatable where Element: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load( @@ -531,7 +531,7 @@ extension FetchAll: Equatable where Element: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load( diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 20489f52..ee404bfe 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -299,7 +299,7 @@ public struct FetchOne: Sendable { /// - 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)`). - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: S, @@ -320,7 +320,7 @@ public struct FetchOne: Sendable { /// - 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)`). - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, @@ -341,7 +341,7 @@ public struct FetchOne: Sendable { /// - 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)`). - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, @@ -362,7 +362,7 @@ public struct FetchOne: Sendable { /// - 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)`). - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: S, @@ -387,7 +387,7 @@ public struct FetchOne: Sendable { /// - 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)`). - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: S, @@ -411,7 +411,7 @@ public struct FetchOne: Sendable { /// - 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)`). - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, @@ -696,7 +696,7 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: S, @@ -720,7 +720,7 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, @@ -748,7 +748,7 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, @@ -776,7 +776,7 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: S, @@ -808,7 +808,7 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: S, @@ -839,7 +839,7 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, @@ -1130,7 +1130,7 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load( @@ -1154,7 +1154,7 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load( @@ -1176,7 +1176,7 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load( @@ -1198,7 +1198,7 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load( @@ -1223,7 +1223,7 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load( @@ -1248,7 +1248,7 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. - /// - Returns: A fetch task associated with the observation. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load( diff --git a/Sources/SQLiteData/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift index d10a61c2..478f7576 100644 --- a/Sources/SQLiteData/FetchSubscription.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -1,7 +1,7 @@ import Perception import Sharing -/// A task associated with `@FetchAll`, `@FetchOne`, and `@Fetch` observation. +/// A subscription associated with `@FetchAll`, `@FetchOne`, and `@Fetch` observation. /// /// This value can be useful in associating the lifetime of observing a query to the lifetime of a /// SwiftUI view _via_ the `task` view modifier. For example, loading a query in a view's `task` From 781ba9534ecf5b4ba3af075e7514fd5e34e9061b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sat, 15 Nov 2025 00:01:30 -0800 Subject: [PATCH 6/9] wip --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4bd407e..21b539b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,11 @@ jobs: name: macOS strategy: matrix: - xcode: ['16.4'] + xcode: ['26.1'] config: ['debug', 'release'] - runs-on: macos-15 + runs-on: macos-26 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: Run ${{ matrix.config }} tests @@ -28,13 +28,13 @@ jobs: name: Examples strategy: matrix: - xcode: ['16.4'] + xcode: ['26.1'] config: ['debug'] scheme: ['Reminders', 'CaseStudies', 'SyncUps'] - runs-on: macos-15 + runs-on: macos-26 continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: List devices available From 31bcb1ee28ee1ea152b4a257384ba0333da6c00b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sat, 15 Nov 2025 10:03:46 -0800 Subject: [PATCH 7/9] wip --- .../xcshareddata/swiftpm/Package.resolved | 44 +++++++++---------- Examples/Reminders/Schema.swift | 10 ++++- Examples/Reminders/SearchReminders.swift | 1 - Sources/SQLiteData/Fetch.swift | 6 +-- Sources/SQLiteData/FetchAll.swift | 12 ++--- Sources/SQLiteData/FetchOne.swift | 36 +++++++-------- Sources/SQLiteData/FetchSubscription.swift | 10 +++-- 7 files changed, 64 insertions(+), 55 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a0d54872..1f540899 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "5928286acce13def418ec36d05a001a9641086f2", - "version" : "1.0.3" + "revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6", + "version" : "1.1.0" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "8ba1bc9a96afc731a000fd4136dd13a5a46297bd", - "version" : "7.6.1" + "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d", + "version" : "7.8.0" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "9810c8d6c2914de251e072312f01d3bf80071852", - "version" : "1.7.1" + "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", + "version" : "1.7.2" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", - "version" : "1.2.1" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "a501eebe552fd23691c560adf474fca2169ad8aa", - "version" : "1.9.4" + "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", + "version" : "1.10.0" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "6b7f44d218e776bb7a5246efb940440d57c8b2cf", - "version" : "2.4.2" + "revision" : "bf498690e1f6b4af790260f542e8428a4ba10d78", + "version" : "2.6.0" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "52bc59a5c7c3f0420c6c31d643d55d64b3f8c013", - "version" : "2.0.7" + "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4", + "version" : "2.0.9" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git", "state" : { - "revision" : "d7e40607dcd6bc26543f5d9433103f06e0b28f8f", - "version" : "1.18.6" + "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b", + "version" : "1.18.7" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "3a95b70a81b7027b8a5117e7dd08188837e5f54e", - "version" : "0.24.0" + "revision" : "9c84335373bae5f5c9f7b5f0adf3ae10f2cab5b9", + "version" : "0.25.2" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", - "version" : "601.0.1" + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", - "version" : "1.6.1" + "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", + "version" : "1.7.0" } } ], diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index e9acd229..09fbfc12 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -387,8 +387,14 @@ nonisolated private let logger = Logger(subsystem: "Reminders", category: "Datab func seedSampleData() throws { @Dependency(\.date.now) var now @Dependency(\.uuid) var uuid - let remindersListIDs = (0...2).map { _ in uuid() } - let reminderIDs = (0...10).map { _ in uuid() } + var remindersListIDs: [UUID] = [] + for _ in 0...2 { + remindersListIDs.append(uuid()) + } + var reminderIDs: [UUID] = [] + for _ in 0...10 { + reminderIDs.append(uuid()) + } try seed { RemindersList( id: remindersListIDs[0], diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index cafb14e9..3ec9522f 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -107,7 +107,6 @@ class SearchRemindersModel { ), animation: .default ) - .task } } } diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index 18c6f184..a3314050 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -102,7 +102,7 @@ public struct Fetch: Sendable { public func load( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil - ) async throws -> FetchSubscription { + ) async throws -> FetchSubscription { try await sharedReader.load(.fetch(request, database: database)) return FetchSubscription(sharedReader: sharedReader) } @@ -143,7 +143,7 @@ extension Fetch { _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchSubscription { + ) async throws -> FetchSubscription { try await sharedReader.load(.fetch(request, database: database, scheduler: scheduler)) return FetchSubscription(sharedReader: sharedReader) } @@ -204,7 +204,7 @@ extension Fetch: Equatable where Value: Equatable { _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchSubscription { + ) async throws -> FetchSubscription { try await sharedReader.load(.fetch(request, database: database, animation: animation)) return FetchSubscription(sharedReader: sharedReader) } diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index d3d3a6e3..e4960c8a 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -181,7 +181,7 @@ public struct FetchAll: Sendable { public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws -> FetchSubscription<[Element]> + ) async throws -> FetchSubscription where Element == S.From.QueryOutput, S.QueryValue == (), @@ -203,7 +203,7 @@ public struct FetchAll: Sendable { public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws -> FetchSubscription<[Element]> + ) async throws -> FetchSubscription where Element == V.QueryOutput, V.QueryOutput: Sendable @@ -334,7 +334,7 @@ extension FetchAll { _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchSubscription<[Element]> + ) async throws -> FetchSubscription where Element == S.From.QueryOutput, S.QueryValue == (), @@ -359,7 +359,7 @@ extension FetchAll { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchSubscription<[Element]> + ) async throws -> FetchSubscription where Element == V.QueryOutput, V.QueryOutput: Sendable @@ -512,7 +512,7 @@ extension FetchAll: Equatable where Element: Equatable { _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchSubscription<[Element]> + ) async throws -> FetchSubscription where Element == S.From.QueryOutput, S.QueryValue == (), @@ -538,7 +538,7 @@ extension FetchAll: Equatable where Element: Equatable { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchSubscription<[Element]> + ) async throws -> FetchSubscription where Element == V.QueryOutput, V.QueryOutput: Sendable diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index ee404bfe..5f06f972 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -304,7 +304,7 @@ public struct FetchOne: Sendable { public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value == S.From.QueryOutput, S.QueryValue == (), @@ -325,7 +325,7 @@ public struct FetchOne: Sendable { public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value == V.QueryOutput { @@ -346,7 +346,7 @@ public struct FetchOne: Sendable { public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value == V.QueryOutput? { @@ -367,7 +367,7 @@ public struct FetchOne: Sendable { public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value: _OptionalProtocol, Value == S.From.QueryOutput?, @@ -392,7 +392,7 @@ public struct FetchOne: Sendable { public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value: _OptionalProtocol, S.QueryValue: QueryRepresentable, @@ -416,7 +416,7 @@ public struct FetchOne: Sendable { public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value: QueryRepresentable, Value: _OptionalProtocol, @@ -702,7 +702,7 @@ extension FetchOne { _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value == S.From.QueryOutput, S.QueryValue == (), @@ -726,7 +726,7 @@ extension FetchOne { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value == V.QueryOutput { @@ -754,7 +754,7 @@ extension FetchOne { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value == V.QueryOutput? { @@ -782,7 +782,7 @@ extension FetchOne { _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value: _OptionalProtocol, Value == S.From.QueryOutput?, @@ -814,7 +814,7 @@ extension FetchOne { _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value: _OptionalProtocol, S.QueryValue: QueryRepresentable, @@ -845,7 +845,7 @@ extension FetchOne { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value: QueryRepresentable, Value: _OptionalProtocol, @@ -1137,7 +1137,7 @@ extension FetchOne: Equatable where Value: Equatable { _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value == S.From.QueryOutput, S.QueryValue == (), @@ -1161,7 +1161,7 @@ extension FetchOne: Equatable where Value: Equatable { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value == V.QueryOutput { @@ -1183,7 +1183,7 @@ extension FetchOne: Equatable where Value: Equatable { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value == V.QueryOutput? { @@ -1205,7 +1205,7 @@ extension FetchOne: Equatable where Value: Equatable { _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value: _OptionalProtocol, Value == S.From.QueryOutput?, @@ -1230,7 +1230,7 @@ extension FetchOne: Equatable where Value: Equatable { _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value: _OptionalProtocol, S.QueryValue: QueryRepresentable, @@ -1255,7 +1255,7 @@ extension FetchOne: Equatable where Value: Equatable { _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws -> FetchSubscription + ) async throws -> FetchSubscription where Value: QueryRepresentable, Value: _OptionalProtocol, diff --git a/Sources/SQLiteData/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift index 478f7576..10565f14 100644 --- a/Sources/SQLiteData/FetchSubscription.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -13,9 +13,13 @@ import Sharing /// try? await $reminders.load(Reminder.all).task /// } /// ``` -public struct FetchSubscription: Sendable { - let sharedReader: SharedReader +public struct FetchSubscription: Sendable { let cancellable = LockIsolated?>(nil) + let onCancel: @Sendable () -> Void + + init(sharedReader: SharedReader) { + onCancel = { sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) } + } /// An async handle to the given fetch observation. /// @@ -27,7 +31,7 @@ public struct FetchSubscription: Sendable { try await withTaskCancellationHandler { try await Task.never() } onCancel: { - sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) + onCancel() } } cancellable.withValue { $0 = task } From f5414380a352a0dbc386950399d0126fc2f4f789 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 24 Nov 2025 14:12:45 -0600 Subject: [PATCH 8/9] Add @discardableResult to load functions --- Sources/SQLiteData/Fetch.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index a3314050..b705dd0f 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -99,6 +99,7 @@ public struct Fetch: Sendable { /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil @@ -139,6 +140,7 @@ extension Fetch { /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, From d440957a866fe6561b6761a95e511084ff8448c6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 24 Nov 2025 17:16:40 -0600 Subject: [PATCH 9/9] Added a migration guide. --- .../Articles/MigrationGuides.md | 17 +++++++++ .../MigrationGuides/MigratingTo1.4.md | 36 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md create mode 100644 Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo1.4.md diff --git a/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md new file mode 100644 index 00000000..5a00b735 --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md @@ -0,0 +1,17 @@ +# Migration guides + +Learn how to upgrade your application to the latest version of SQLiteData. + +## Overview + +SQLiteData 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/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo1.4.md b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo1.4.md new file mode 100644 index 00000000..54dfa1b1 --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo1.4.md @@ -0,0 +1,36 @@ +# Migrating to 1.4 + +SQLiteData 1.4 introduces a new tool for tying the lifecycle database subscriptions to the +lifecycle of the surrounding async context, but it may incidentally cause "Result of call … +is unused" warnings in your project. + +## Overview + +The `load` method defined on [`@FetchAll`]() / [`@FetchOne`]() / +[`@Fetch`]() all now return a discardable result, ``FetchSubscription``. Awaiting the +``FetchSubscription/task`` of that result ties the lifecycle of the subscription to the database +to the lifecycle of the surrounding async context, which can help views to automatically +unsubscribe from the database when they are not visible. + +However, when used with `withErrorReporting` you are likely to get the following warning: + +```swift +private func updateQuery() async { + // ⚠️ Result of call to 'withErrorReporting(_:to:fileID:filePath:line:column:isolation:catching:)' is unused + await withErrorReporting { + try await $rows.load(…) + } +} +``` + +This is happening because although `load` has a discardable result, Swift does not propagate that +to `withErrorReporting`, and so Swift thinks you have an unused value. To fix you will need to +explicitly ignore the result with `_ = `: + +```swift +private func updateQuery() async { + _ = await withErrorReporting { + try await $rows.load(…) + } +} +```