• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / supabase-swift / 25488993349

07 May 2026 09:58AM UTC coverage: 79.768%. First build
25488993349

Pull #991

github

web-flow
Merge 0eefab23c into 6fa2a16c0
Pull Request #991: feat(storage): add StorageTransferTask, MultipartUploadEngine, and upload API

307 of 422 new or added lines in 6 files covered. (72.75%)

7282 of 9129 relevant lines covered (79.77%)

29.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.34
/Sources/Storage/StorageTransferTask.swift
1
//
2
//  StorageTransferTask.swift
3
//  Storage
4
//
5
//  Created by Guilherme Souza on 04/05/26.
6
//
7

8
import Foundation
9

10
/// A handle to an in-flight upload or download.
11
///
12
/// ## Usage patterns
13
///
14
/// **Fire and forget**
15
/// ```swift
16
/// storage.from("avatars").upload("user.jpg", data: imageData)
17
/// ```
18
///
19
/// **Await the result**
20
/// ```swift
21
/// let response = try await storage.from("avatars")
22
///   .upload("user.jpg", data: imageData)
23
///   .value
24
/// ```
25
///
26
/// **Observe progress**
27
/// ```swift
28
/// let task = storage.from("avatars").upload("user.jpg", data: imageData)
29
/// for await event in task.events {
30
///   switch event {
31
///   case .progress(let p):
32
///     updateProgressBar(p.fractionCompleted)
33
///   case .completed(let response):
34
///     print("Uploaded to \(response.fullPath)")
35
///   case .failed(let error):
36
///     print("Upload failed: \(error.message)")
37
///   }
38
/// }
39
/// ```
40
///
41
/// **Pause, resume, and cancel (TUS uploads only)**
42
/// ```swift
43
/// // TUS (resumable) upload — use method: .resumable to force TUS
44
/// let upload = storage.from("videos").upload("clip.mp4", fileURL: fileURL, method: .resumable)
45
/// await upload.pause()   // suspend mid-upload
46
/// await upload.resume()  // continue from where it left off
47
/// await upload.cancel()  // abort entirely
48
///
49
/// // Download
50
/// let download = storage.from("videos").download(path: "clip.mp4")
51
/// await download.pause()   // suspend; captures resume data if server supports range requests
52
/// await download.resume()  // continue from last byte, or restart if no resume data
53
/// await download.cancel()  // abort entirely
54
/// ```
55
///
56
/// Both ``events`` and ``value`` are independent: consuming one does not affect the other.
57
public final class StorageTransferTask<Success: Sendable>: Sendable {
58

59
  /// A stream of transfer lifecycle events.
60
  ///
61
  /// Emits ``TransferEvent/progress(_:)`` events while the transfer is active, then terminates
62
  /// with either ``TransferEvent/completed(_:)`` or ``TransferEvent/failed(_:)``.
63
  public let events: AsyncStream<TransferEvent<Success>>
64

65
  private let _resultTask: Task<Success, any Error>
66
  private let _pause: @Sendable () async -> Void
67
  private let _resume: @Sendable () async -> Void
68
  private let _cancel: @Sendable () async -> Void
69

70
  init(
71
    events: AsyncStream<TransferEvent<Success>>,
72
    resultTask: Task<Success, any Error>,
73
    pause: @Sendable @escaping () async -> Void,
74
    resume: @Sendable @escaping () async -> Void,
75
    cancel: @Sendable @escaping () async -> Void
76
  ) {
26✔
77
    self.events = events
26✔
78
    self._resultTask = resultTask
26✔
79
    self._pause = pause
26✔
80
    self._resume = resume
26✔
81
    self._cancel = cancel
26✔
82
  }
26✔
83

84
  /// The transfer outcome as a `Result`. Never throws — inspect `.success` / `.failure` directly.
85
  public var result: Result<Success, any Error> {
86
    get async { await _resultTask.result }
18✔
87
  }
88

89
  /// Awaits the success value. Throws `StorageError` on failure or cancellation.
90
  public var value: Success {
91
    get async throws { try await result.get() }
18✔
92
  }
93

94
  /// Suspends the transfer.
95
  ///
96
  /// Only supported for TUS (resumable) uploads. For multipart uploads this is a no-op —
97
  /// use ``cancel()`` and re-upload from scratch if you need to stop a multipart transfer.
98
  /// For TUS uploads the current in-flight chunk is drained before the task suspends.
NEW
99
  public func pause() async { await _pause() }
×
100

101
  /// Resumes a previously paused transfer.
102
  ///
103
  /// Only supported for TUS (resumable) uploads. For multipart uploads this is a no-op.
104
  /// For TUS uploads the server is HEAD-queried to re-sync the byte offset before uploading resumes.
NEW
105
  public func resume() async { await _resume() }
×
106

107
  /// Cancels the transfer immediately.
108
  ///
109
  /// Cancelling a transfer that has already completed or failed is a no-op.
110
  /// After cancellation, ``value`` throws a ``StorageError`` with code ``StorageErrorCode/cancelled``,
111
  /// and the ``events`` stream ends with a ``TransferEvent/failed(_:)`` event.
112
  public func cancel() async {
2✔
113
    // Order is load-bearing: _cancel() must run first.
2✔
114
    // It sets the engine's state to .cancelled and finishes the continuations before
2✔
115
    // Swift's structured cancellation propagates. If _resultTask.cancel() fired first,
2✔
116
    // the resulting CancellationError would reach handleRunError while the engine is
2✔
117
    // still in .uploading/.creating, causing it to call cancel() a second time and
2✔
118
    // race with this explicit cancellation path.
2✔
119
    await _cancel()
2✔
120
    _resultTask.cancel()
2✔
121
  }
2✔
122
}
123

124
extension StorageTransferTask {
125
  /// Returns a new task that applies `transform` to the success value.
126
  /// Progress events pass through unchanged. Pause/resume/cancel delegate to `self`.
127
  func mapResult<NewSuccess: Sendable>(
128
    _ transform: @Sendable @escaping (Success) throws -> NewSuccess
129
  ) -> StorageTransferTask<NewSuccess> {
4✔
130
    let (newStream, newContinuation) = AsyncStream<TransferEvent<NewSuccess>>.makeStream()
4✔
131
    let (resultStream, resultContinuation) = AsyncStream<Result<NewSuccess, any Error>>.makeStream(
4✔
132
      bufferingPolicy: .bufferingNewest(1))
4✔
133

4✔
134
    let bridgeTask = Task {
4✔
135
      for await event in self.events {
7✔
136
        switch event {
7✔
137
        case .progress(let p):
7✔
138
          newContinuation.yield(.progress(p))
3✔
139
        case .completed(let value):
7✔
140
          do {
3✔
141
            let mapped = try transform(value)
3✔
142
            newContinuation.yield(.completed(mapped))
2✔
143
            newContinuation.finish()
2✔
144
            resultContinuation.yield(.success(mapped))
2✔
145
            resultContinuation.finish()
2✔
146
          } catch {
2✔
147
            let storageError = StorageError.fileSystemError(underlying: error)
1✔
148
            newContinuation.yield(.failed(storageError))
1✔
149
            newContinuation.finish()
1✔
150
            resultContinuation.yield(.failure(storageError))
1✔
151
            resultContinuation.finish()
1✔
152
          }
1✔
153
        case .failed(let error):
7✔
154
          newContinuation.yield(.failed(error))
1✔
155
          newContinuation.finish()
1✔
156
          resultContinuation.yield(.failure(error))
1✔
157
          resultContinuation.finish()
1✔
158
        }
7✔
159
      }
7✔
160
      newContinuation.finish()
4✔
161
    }
4✔
162

4✔
163
    let newResultTask = Task<NewSuccess, any Error> {
4✔
164
      for await r in resultStream { return try r.get() }
4✔
NEW
165
      throw StorageError.cancelled
×
166
    }
4✔
167

4✔
168
    return StorageTransferTask<NewSuccess>(
4✔
169
      events: newStream,
4✔
170
      resultTask: newResultTask,
4✔
171
      pause: self._pause,
4✔
172
      resume: self._resume,
4✔
173
      cancel: {
4✔
NEW
174
        await self._cancel()
×
NEW
175
        bridgeTask.cancel()
×
NEW
176
        // Ensure resultContinuation is finished so newResultTask exits via the stream
×
NEW
177
        // (yielding StorageError.cancelled) rather than via Task cancellation
×
NEW
178
        // (which would throw CancellationError), keeping the error type deterministic.
×
NEW
179
        resultContinuation.finish()
×
NEW
180
      }
×
181
    )
4✔
182
  }
4✔
183
}
184

185
/// An event emitted during a transfer.
186
public enum TransferEvent<Success: Sendable>: Sendable {
187
  /// Periodic progress update during an active transfer.
188
  case progress(TransferProgress)
189
  /// The transfer finished successfully. Terminal — the stream ends after this event.
190
  case completed(Success)
191
  /// The transfer failed or was cancelled. Terminal — the stream ends after this event.
192
  case failed(StorageError)
193
}
194

195
/// Byte-level progress for a transfer.
196
public struct TransferProgress: Sendable {
197
  /// Number of bytes sent or received so far.
198
  public let bytesTransferred: Int64
199

200
  /// Total size of the transfer in bytes.
201
  public let totalBytes: Int64
202

203
  /// Transfer completion as a value between `0.0` and `1.0`.
204
  /// Returns `0` when `totalBytes` is zero.
205
  public var fractionCompleted: Double {
6✔
206
    guard totalBytes > 0 else { return 0 }
6✔
207
    return Double(bytesTransferred) / Double(totalBytes)
4✔
208
  }
6✔
209
}
210

211
/// A handle for an upload.
212
///
213
/// The success value is a ``FileUploadResponse`` containing the storage path and object ID.
214
/// TUS uploads support ``StorageTransferTask/pause()``, ``StorageTransferTask/resume()``, and
215
/// ``StorageTransferTask/cancel()``; multipart uploads only support cancel.
216
public typealias StorageUploadTask = StorageTransferTask<FileUploadResponse>
217

218
/// A handle for a download to disk.
219
///
220
/// The success value is a `URL` pointing to a temporary file on disk.
221
/// Move or read the file before the app exits — it is not guaranteed to persist across launches.
222
public typealias StorageDownloadTask = StorageTransferTask<URL>
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc