A macOS CLI tool that exports original photos and videos from the macOS Photos library using PhotoKit.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: adaptive export lanes and classification (0.5.0)

Partition a batch into local vs. iCloud work and let the caller throttle
the iCloud lane when Photos/iCloud pushes back. LadderKit supplies the
mechanism; the caller owns the policy.

- PhotosDatabase.localAvailableUUIDs(dbPath:) surfaces
ZINTERNALRESOURCE.ZLOCALAVAILABILITY = 1 so callers can partition work
before export.
- LocalAvailabilityProviding + PhotosDatabaseLocalAvailability for the
partitioning seam.
- AdaptiveConcurrencyControlling protocol (observation-only:
currentLimit / record) + ExportOutcome. No concrete controller ships
here — the policy lives with the consumer.
- ExportError.classification: other | transientCloud | permanentlyUnavailable,
with backward-compatible decoding of the legacy unavailable flag.
- PhotoExporter.export gains optional localAvailability and
adaptiveController. Local and iCloud lanes run in parallel; the iCloud
lane polls currentLimit between dispatches and reports each outcome.
AppleScript fallback now runs parallel under the same gate. Cancellation
triggers group.cancelAll() so child tasks wind down promptly.

Fully backward compatible with 0.4.x.

+412 -101
+29
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## 0.5.0 4 + 5 + Adaptive export: partition a batch into local vs. iCloud work, and let the 6 + caller throttle the iCloud lane when Photos/iCloud pushes back. LadderKit 7 + supplies the mechanism (partitioning, protocol, outcome reporting); the 8 + caller owns the policy (the actual controller implementation). 9 + 10 + - `PhotosDatabase.localAvailableUUIDs(dbPath:)` — returns the set of asset 11 + UUIDs whose original resource is cached locally (via 12 + `ZINTERNALRESOURCE.ZLOCALAVAILABILITY = 1`). 13 + - `LocalAvailabilityProviding` protocol + `PhotosDatabaseLocalAvailability` 14 + implementation backed by that query. 15 + - `AdaptiveConcurrencyControlling` protocol and `ExportOutcome` enum. The 16 + protocol is observation-only: `currentLimit()` and `record(_:)`. The 17 + exporter polls the limit between dispatches — no shared permit bookkeeping. 18 + LadderKit ships no concrete controller; plug in your own policy. 19 + - `ExportError.classification: ExportClassification` — `other`, 20 + `transientCloud`, or `permanentlyUnavailable`. Backward-compatible: legacy 21 + payloads without the field decode as `other`, or `permanentlyUnavailable` 22 + when the legacy `unavailable == true` flag is set. 23 + - `PhotoExporter.export(uuids:localAvailability:adaptiveController:)` — new 24 + optional parameters. When provided, the exporter runs two lanes in 25 + parallel: the local lane at `maxConcurrency`, the iCloud lane gated by the 26 + controller's current limit. The AppleScript fallback is now also parallel 27 + and gated by the same controller. Proper cancellation: the group is 28 + cancelled when `Task.isCancelled` trips. 29 + 30 + Fully backward compatible with 0.4.x — existing call sites keep working. 31 + 3 32 ## 0.4.0 4 33 5 34 - `AssetHandle` exposes `isShared: Bool` (from `PHAsset.sourceType == .typeCloudShared`)
+30
Sources/LadderKit/AdaptiveConcurrency.swift
··· 1 + import Foundation 2 + 3 + /// Outcome of a single iCloud-lane export attempt, fed back to an adaptive 4 + /// controller so it can tune its concurrency limit. 5 + public enum ExportOutcome: Sendable { 6 + /// Export succeeded — bytes on disk. 7 + case success 8 + /// Transient failure (iCloud throttling, network blip, silent no-file). 9 + /// The controller should treat these as congestion signals. 10 + case transientFailure 11 + /// Permanent failure (shared-album derivative unreachable). Should be 12 + /// ignored by the controller — not a signal about lane health. 13 + case permanentFailure 14 + } 15 + 16 + /// Observation-only concurrency controller. The exporter polls 17 + /// ``currentLimit()`` to decide how many iCloud tasks to run at once, and 18 + /// reports each ``ExportOutcome`` via ``record(_:)`` so the controller can 19 + /// tune itself. 20 + /// 21 + /// LadderKit does not ship a concrete controller — the policy lives with the 22 + /// consumer (e.g. AtticCore's AIMDController). Pass `nil` to run the iCloud 23 + /// lane at the exporter's static `maxConcurrency`. 24 + public protocol AdaptiveConcurrencyControlling: Sendable { 25 + /// Current concurrency cap for the iCloud lane. May change over time. 26 + func currentLimit() async -> Int 27 + 28 + /// Record the outcome of an attempt so the controller can tune its window. 29 + func record(_ outcome: ExportOutcome) async 30 + }
+28
Sources/LadderKit/LocalAvailability.swift
··· 1 + import Foundation 2 + 3 + /// Tells the exporter whether an asset's original bytes are on disk right now 4 + /// (vs. needing an iCloud download). 5 + /// 6 + /// Implementations should be cheap and side-effect free — the exporter calls 7 + /// this synchronously while partitioning a batch. 8 + public protocol LocalAvailabilityProviding: Sendable { 9 + /// Returns `true` if the asset's original resource is locally cached. 10 + /// Returns `false` if unknown or iCloud-only. 11 + func isLocallyAvailable(uuid: String) -> Bool 12 + } 13 + 14 + /// A `LocalAvailabilityProviding` backed by a precomputed set of UUIDs. 15 + /// 16 + /// Typical use: call ``PhotosDatabase/localAvailableUUIDs(dbPath:)`` once at 17 + /// the start of a backup run and wrap the result. 18 + public struct PhotosDatabaseLocalAvailability: LocalAvailabilityProviding { 19 + public let localUUIDs: Set<String> 20 + 21 + public init(localUUIDs: Set<String>) { 22 + self.localUUIDs = localUUIDs 23 + } 24 + 25 + public func isLocallyAvailable(uuid: String) -> Bool { 26 + localUUIDs.contains(uuid) 27 + } 28 + }
+46 -6
Sources/LadderKit/Models.swift
··· 26 26 } 27 27 } 28 28 29 + /// Classifies the nature of an export failure so callers can route it to the 30 + /// right retry/skip policy. 31 + public enum ExportClassification: String, Codable, Sendable { 32 + /// Unclassified / generic failure (default for legacy payloads). 33 + case other 34 + /// iCloud download failed transiently — retrying later is reasonable. 35 + /// Examples: throttling, network glitch, pending server-side processing, 36 + /// AppleScript export returning success-with-no-file. 37 + case transientCloud 38 + /// Asset's bytes cannot be retrieved from iCloud (e.g. a shared-album 39 + /// asset whose owner's derivative is unreachable). Retries are pointless. 40 + case permanentlyUnavailable 41 + } 42 + 29 43 /// Output: the full response written to stdout. 30 44 public struct ExportResponse: Codable, Sendable { 31 45 public let results: [ExportResult] ··· 40 54 public struct ExportError: Codable, Sendable { 41 55 public let uuid: String 42 56 public let message: String 43 - /// The asset cannot be downloaded from iCloud and retries are pointless 44 - /// (e.g. shared-album asset whose owner's derivative is unreachable). 57 + /// The asset cannot be downloaded from iCloud and retries are pointless. 58 + /// 59 + /// Retained for backward compatibility. New callers should prefer 60 + /// ``classification`` and treat `unavailable == true` as equivalent to 61 + /// ``ExportClassification/permanentlyUnavailable``. 45 62 public let unavailable: Bool 63 + /// Structured classification of the failure. Defaults to ``ExportClassification/other`` 64 + /// when decoding legacy payloads that predate this field; derived from 65 + /// ``unavailable`` when that flag is set on a legacy payload. 66 + public let classification: ExportClassification 46 67 47 - public init(uuid: String, message: String, unavailable: Bool = false) { 68 + public init( 69 + uuid: String, 70 + message: String, 71 + classification: ExportClassification = .other 72 + ) { 48 73 self.uuid = uuid 49 74 self.message = message 50 - self.unavailable = unavailable 75 + self.classification = classification 76 + self.unavailable = (classification == .permanentlyUnavailable) 51 77 } 52 78 53 79 private enum CodingKeys: String, CodingKey { 54 - case uuid, message, unavailable 80 + case uuid, message, unavailable, classification 55 81 } 56 82 57 83 public init(from decoder: Decoder) throws { 58 84 let c = try decoder.container(keyedBy: CodingKeys.self) 59 85 uuid = try c.decode(String.self, forKey: .uuid) 60 86 message = try c.decode(String.self, forKey: .message) 61 - unavailable = try c.decodeIfPresent(Bool.self, forKey: .unavailable) ?? false 87 + let legacyUnavailable = try c.decodeIfPresent(Bool.self, forKey: .unavailable) ?? false 88 + if let decoded = try c.decodeIfPresent(ExportClassification.self, forKey: .classification) { 89 + classification = decoded 90 + } else { 91 + classification = legacyUnavailable ? .permanentlyUnavailable : .other 92 + } 93 + unavailable = (classification == .permanentlyUnavailable) 94 + } 95 + 96 + public func encode(to encoder: Encoder) throws { 97 + var c = encoder.container(keyedBy: CodingKeys.self) 98 + try c.encode(uuid, forKey: .uuid) 99 + try c.encode(message, forKey: .message) 100 + try c.encode(unavailable, forKey: .unavailable) 101 + try c.encode(classification, forKey: .classification) 62 102 } 63 103 }
+205 -95
Sources/LadderKit/PhotoExporter.swift
··· 24 24 try await scriptExporter?.checkPermissions() 25 25 } 26 26 27 - public func export(uuids: [String]) async -> ExportResponse { 27 + /// Export the requested assets. 28 + /// 29 + /// When `localAvailability` is provided, the exporter partitions assets 30 + /// into a local lane (full `maxConcurrency`) and an iCloud lane. When 31 + /// `adaptiveController` is also provided, the iCloud lane polls its 32 + /// ``AdaptiveConcurrencyControlling/currentLimit()`` between dispatches 33 + /// and reports each outcome via ``AdaptiveConcurrencyControlling/record(_:)``. 34 + /// Passing both as `nil` preserves 0.4.x behavior. 35 + public func export( 36 + uuids: [String], 37 + localAvailability: LocalAvailabilityProviding? = nil, 38 + adaptiveController: AdaptiveConcurrencyControlling? = nil 39 + ) async -> ExportResponse { 28 40 let assets = library.fetchAssets(identifiers: uuids) 29 41 30 - // Identify missing UUIDs (PhotoKit can't find them) 42 + // UUIDs PhotoKit can't find at all → straight to AppleScript fallback 43 + // (iCloud-only by definition — invisible to PhotoKit fetch). 31 44 let missingUUIDs = uuids.filter { assets[$0] == nil } 32 45 33 - // Export found assets with bounded concurrency (unchanged) 34 - let exportResults = await withTaskGroup( 46 + // Partition found assets. Anything not known-local is treated as 47 + // cloud so a missing availability provider degrades to 0.4.x behavior. 48 + var localItems: [(String, AssetHandle)] = [] 49 + var cloudItems: [(String, AssetHandle)] = [] 50 + for (uuid, handle) in assets { 51 + if localAvailability?.isLocallyAvailable(uuid: uuid) == true { 52 + localItems.append((uuid, handle)) 53 + } else { 54 + cloudItems.append((uuid, handle)) 55 + } 56 + } 57 + 58 + async let localExec = runPhotoKitLane(items: localItems, controller: nil) 59 + async let cloudExec = runPhotoKitLane(items: cloudItems, controller: adaptiveController) 60 + 61 + let localRun = await localExec 62 + let cloudRun = await cloudExec 63 + 64 + var allResults = localRun.results + cloudRun.results 65 + var allErrors: [ExportError] = [] 66 + var photoKitFailedUUIDs: [String] = [] 67 + 68 + // Shared-album assets that fail PhotoKit go through iCloud's shared- 69 + // stream pipeline, which the AppleScript path also uses — retrying 70 + // just waits ~5min for the same server-side error. Short-circuit. 71 + for pair in localRun.failures + cloudRun.failures { 72 + if pair.isShared { 73 + allErrors.append(ExportError( 74 + uuid: pair.uuid, 75 + message: "Shared-album asset unavailable from iCloud: \(pair.message)", 76 + classification: .permanentlyUnavailable 77 + )) 78 + } else { 79 + photoKitFailedUUIDs.append(pair.uuid) 80 + } 81 + } 82 + 83 + let fallbackUUIDs = missingUUIDs + photoKitFailedUUIDs 84 + if !fallbackUUIDs.isEmpty { 85 + let fb = await exportViaAppleScript( 86 + uuids: fallbackUUIDs, 87 + controller: adaptiveController 88 + ) 89 + allResults.append(contentsOf: fb.results) 90 + allErrors.append(contentsOf: fb.errors) 91 + } 92 + 93 + return ExportResponse(results: allResults, errors: allErrors) 94 + } 95 + 96 + /// Run a PhotoKit export over `items` with bounded, optionally adaptive 97 + /// concurrency. When `controller` is non-nil, each completion polls 98 + /// `currentLimit()` and records the outcome. 99 + private func runPhotoKitLane( 100 + items: [(String, AssetHandle)], 101 + controller: AdaptiveConcurrencyControlling? 102 + ) async -> (results: [ExportResult], failures: [ExportErrorPair]) { 103 + if items.isEmpty { return ([], []) } 104 + 105 + return await withTaskGroup( 35 106 of: Result<ExportResult, ExportErrorPair>.self 36 107 ) { group in 37 - var pending = 0 38 - var iterator = assets.makeIterator() 39 - var results: [Result<ExportResult, ExportErrorPair>] = [] 108 + var iterator = items.makeIterator() 109 + var inflight = 0 40 110 41 - // Seed the group with initial tasks up to maxConcurrency 42 - while pending < maxConcurrency, let (uuid, handle) = iterator.next() { 111 + // Seed up to the current limit. 112 + var limit = await self.effectiveLimit(controller) 113 + while inflight < limit, let (uuid, handle) = iterator.next() { 43 114 group.addTask { await self.exportAsset(uuid: uuid, handle: handle) } 44 - pending += 1 115 + inflight += 1 45 116 } 46 117 47 - // As each completes, start the next (checking cancellation between assets) 118 + var results: [ExportResult] = [] 119 + var failures: [ExportErrorPair] = [] 120 + 48 121 for await result in group { 49 - results.append(result) 50 - if Task.isCancelled { break } 51 - if let (uuid, handle) = iterator.next() { 52 - group.addTask { await self.exportAsset(uuid: uuid, handle: handle) } 122 + inflight -= 1 123 + 124 + switch result { 125 + case .success(let ok): 126 + results.append(ok) 127 + await controller?.record(.success) 128 + case .failure(let pair): 129 + failures.append(pair) 130 + await controller?.record(pair.isShared ? .permanentFailure : .transientFailure) 53 131 } 54 - } 55 132 56 - return results 57 - } 133 + if Task.isCancelled { 134 + group.cancelAll() 135 + break 136 + } 58 137 59 - var allResults: [ExportResult] = [] 60 - var allErrors: [ExportError] = [] 61 - var photoKitFailedUUIDs: [String] = [] 62 - // Shared-album assets that fail PhotoKit go through iCloud's shared-stream 63 - // pipeline, which the AppleScript path also uses — retrying via AppleScript 64 - // just waits ~5min for the same server-side error. Short-circuit these. 65 - for result in exportResults { 66 - switch result { 67 - case .success(let exportResult): 68 - allResults.append(exportResult) 69 - case .failure(let pair): 70 - if pair.isShared { 71 - allErrors.append(ExportError( 72 - uuid: pair.uuid, 73 - message: "Shared-album asset unavailable from iCloud: \(pair.message)", 74 - unavailable: true, 75 - )) 76 - } else { 77 - photoKitFailedUUIDs.append(pair.uuid) 138 + // Re-poll — the controller may have adjusted between tasks. 139 + limit = await self.effectiveLimit(controller) 140 + while inflight < limit, let (uuid, handle) = iterator.next() { 141 + group.addTask { await self.exportAsset(uuid: uuid, handle: handle) } 142 + inflight += 1 78 143 } 79 144 } 80 - } 81 145 82 - // AppleScript fallback for: 83 - // 1. UUIDs that PhotoKit couldn't find at all (iCloud-only, invisible to fetchAssets) 84 - // 2. UUIDs that PhotoKit found but failed to export (e.g. iCloud download errors) 85 - let fallbackUUIDs = missingUUIDs + photoKitFailedUUIDs 86 - if !fallbackUUIDs.isEmpty { 87 - let (fallbackResults, fallbackErrors) = await exportViaAppleScript(uuids: fallbackUUIDs) 88 - allResults.append(contentsOf: fallbackResults) 89 - allErrors.append(contentsOf: fallbackErrors) 146 + return (results, failures) 90 147 } 148 + } 91 149 92 - return ExportResponse(results: allResults, errors: allErrors) 150 + private func effectiveLimit( 151 + _ controller: AdaptiveConcurrencyControlling? 152 + ) async -> Int { 153 + guard let controller else { return maxConcurrency } 154 + let limit = await controller.currentLimit() 155 + return max(1, min(maxConcurrency, limit)) 93 156 } 94 157 95 - /// Attempt AppleScript fallback for UUIDs that PhotoKit couldn't find. 158 + /// AppleScript fallback, gated the same way as the PhotoKit iCloud lane. 159 + /// All failures here are iCloud-related by construction — classify as 160 + /// ``ExportClassification/transientCloud``. 96 161 private func exportViaAppleScript( 97 - uuids: [String] 98 - ) async -> ([ExportResult], [ExportError]) { 162 + uuids: [String], 163 + controller: AdaptiveConcurrencyControlling? 164 + ) async -> (results: [ExportResult], errors: [ExportError]) { 99 165 guard let scriptExporter else { 100 - // No script exporter: report all as "not found" (original behavior) 101 - let errors = uuids.map { ExportError(uuid: $0, message: "Asset not found in Photos library") } 166 + let errors = uuids.map { 167 + ExportError(uuid: $0, message: "Asset not found in Photos library") 168 + } 102 169 return ([], errors) 103 170 } 104 171 105 - // Check disk space before starting iCloud downloads 106 172 let freeSpace = availableDiskSpace(at: stagingDir) 107 173 if freeSpace < AppleScriptRunner.minimumFreeSpace { 108 174 let gbFree = Double(freeSpace) / 1_073_741_824 ··· 118 184 return ([], errors) 119 185 } 120 186 121 - var results: [ExportResult] = [] 122 - var errors: [ExportError] = [] 187 + return await withTaskGroup( 188 + of: Result<ExportResult, ScriptFailure>.self 189 + ) { group in 190 + var iterator = uuids.makeIterator() 191 + var inflight = 0 123 192 124 - for uuid in uuids { 125 - if Task.isCancelled { break } 193 + var limit = await self.effectiveLimit(controller) 194 + while inflight < limit, let uuid = iterator.next() { 195 + group.addTask { 196 + await self.scriptExportAsset(uuid: uuid, scriptExporter: scriptExporter) 197 + } 198 + inflight += 1 199 + } 126 200 127 - do { 128 - let exportedFile = try await scriptExporter.exportAsset( 129 - identifier: uuid, 130 - to: stagingDir, 131 - timeout: AppleScriptRunner.defaultTimeout 132 - ) 201 + var results: [ExportResult] = [] 202 + var errors: [ExportError] = [] 133 203 134 - let sha256 = try FileHasher.sha256(fileAt: exportedFile) 135 - let attrs = try FileManager.default.attributesOfItem(atPath: exportedFile.path) 136 - let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 204 + for await result in group { 205 + inflight -= 1 137 206 138 - // Move from temp subdirectory to staging dir with safe naming 139 - let safeDest = try PathSafety.safeDestination( 140 - stagingDir: stagingDir, 141 - uuid: uuid, 142 - originalFilename: exportedFile.lastPathComponent 143 - ) 144 - try FileManager.default.moveItem(at: exportedFile, to: safeDest) 207 + switch result { 208 + case .success(let ok): 209 + results.append(ok) 210 + await controller?.record(.success) 211 + case .failure(let fail): 212 + errors.append(ExportError( 213 + uuid: fail.uuid, 214 + message: fail.message, 215 + classification: .transientCloud 216 + )) 217 + await controller?.record(.transientFailure) 218 + } 145 219 146 - // Clean up the per-asset temp subdirectory 147 - let tempSubdir = exportedFile.deletingLastPathComponent() 148 - if tempSubdir != stagingDir { 149 - try? FileManager.default.removeItem(at: tempSubdir) 220 + if Task.isCancelled { 221 + group.cancelAll() 222 + break 150 223 } 151 224 152 - results.append(ExportResult( 153 - uuid: uuid, 154 - path: safeDest.path, 155 - size: size, 156 - sha256: sha256 157 - )) 158 - } catch { 159 - errors.append(ExportError( 160 - uuid: uuid, 161 - message: error.localizedDescription 162 - )) 225 + limit = await self.effectiveLimit(controller) 226 + while inflight < limit, let uuid = iterator.next() { 227 + group.addTask { 228 + await self.scriptExportAsset(uuid: uuid, scriptExporter: scriptExporter) 229 + } 230 + inflight += 1 231 + } 163 232 } 233 + 234 + return (results, errors) 164 235 } 236 + } 237 + 238 + private func scriptExportAsset( 239 + uuid: String, 240 + scriptExporter: ScriptExporter 241 + ) async -> Result<ExportResult, ScriptFailure> { 242 + do { 243 + let exportedFile = try await scriptExporter.exportAsset( 244 + identifier: uuid, 245 + to: stagingDir, 246 + timeout: AppleScriptRunner.defaultTimeout 247 + ) 248 + 249 + let sha256 = try FileHasher.sha256(fileAt: exportedFile) 250 + let attrs = try FileManager.default.attributesOfItem(atPath: exportedFile.path) 251 + let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 165 252 166 - return (results, errors) 253 + let safeDest = try PathSafety.safeDestination( 254 + stagingDir: stagingDir, 255 + uuid: uuid, 256 + originalFilename: exportedFile.lastPathComponent 257 + ) 258 + try FileManager.default.moveItem(at: exportedFile, to: safeDest) 259 + 260 + let tempSubdir = exportedFile.deletingLastPathComponent() 261 + if tempSubdir != stagingDir { 262 + try? FileManager.default.removeItem(at: tempSubdir) 263 + } 264 + 265 + return .success(ExportResult( 266 + uuid: uuid, 267 + path: safeDest.path, 268 + size: size, 269 + sha256: sha256 270 + )) 271 + } catch { 272 + return .failure(ScriptFailure(uuid: uuid, message: error.localizedDescription)) 273 + } 167 274 } 168 275 169 276 private func exportAsset( ··· 181 288 return .failure(ExportErrorPair( 182 289 uuid: uuid, 183 290 message: error.localizedDescription, 184 - isShared: handle.isShared, 291 + isShared: handle.isShared 185 292 )) 186 293 } 187 294 188 - // Create empty file for writing 189 295 FileManager.default.createFile(atPath: destURL.path, contents: nil) 190 296 191 297 do { 192 - // Stream data: write to file + hash simultaneously 193 298 let hasher = StreamingHasher() 194 299 let size = try await handle.writeData( 195 300 to: destURL, ··· 205 310 sha256: sha256 206 311 )) 207 312 } catch { 208 - // Clean up the empty/partial file so AppleScript fallback can reuse the path 209 313 try? FileManager.default.removeItem(at: destURL) 210 314 return .failure(ExportErrorPair( 211 315 uuid: uuid, 212 316 message: error.localizedDescription, 213 - isShared: handle.isShared, 317 + isShared: handle.isShared 214 318 )) 215 319 } 216 320 } 217 321 } 218 322 219 - /// Internal type for passing errors through TaskGroup. 323 + /// Internal type for passing PhotoKit-lane errors through TaskGroup. 220 324 struct ExportErrorPair: Error, Sendable { 221 325 let uuid: String 222 326 let message: String 223 327 let isShared: Bool 328 + } 329 + 330 + /// Internal type for passing AppleScript-lane errors through TaskGroup. 331 + private struct ScriptFailure: Error, Sendable { 332 + let uuid: String 333 + let message: String 224 334 } 225 335 226 336 public enum ExportFailure: LocalizedError {
+35
Sources/LadderKit/PhotosDatabase.swift
··· 62 62 ) 63 63 } 64 64 65 + /// Return the set of asset UUIDs whose original resource bytes are present 66 + /// locally on disk (not iCloud-only). 67 + /// 68 + /// Queries `ZINTERNALRESOURCE.ZLOCALAVAILABILITY = 1` for the original 69 + /// resource (`ZDATASTORESUBTYPE = 1`). Used by the adaptive backup pipeline 70 + /// to partition assets into a local lane (full concurrency) and an iCloud 71 + /// lane (gated by adaptive concurrency). 72 + /// 73 + /// Returns an empty set if the database cannot be opened or the schema 74 + /// differs from what's expected. 75 + public static func localAvailableUUIDs(dbPath: String) -> Set<String> { 76 + guard let db = openDatabase(path: dbPath) else { 77 + return [] 78 + } 79 + defer { sqlite3_close(db) } 80 + 81 + var uuids: Set<String> = [] 82 + safeQuery( 83 + db: db, 84 + sql: """ 85 + SELECT a.ZUUID 86 + FROM ZASSET a 87 + JOIN ZINTERNALRESOURCE ir ON ir.ZASSET = a.Z_PK 88 + WHERE a.ZTRASHEDSTATE = 0 89 + AND ir.ZDATASTORESUBTYPE = 1 90 + AND ir.ZLOCALAVAILABILITY = 1 91 + """ 92 + ) { stmt in 93 + if let uuid = stringColumn(stmt, 0) { 94 + uuids.insert(uuid) 95 + } 96 + } 97 + return uuids 98 + } 99 + 65 100 /// Apply enrichment data to an array of assets in-place. 66 101 /// 67 102 /// Matches assets by their `uuid` property (extracted from PhotoKit's
+39
Tests/ModelsTests.swift
··· 47 47 #expect(decoded.errors[0].message == "Not found") 48 48 } 49 49 50 + @Test("ExportError decodes legacy payload without classification") 51 + func exportErrorLegacyDecode() throws { 52 + let json = #"{"uuid":"u1","message":"boom","unavailable":true}"# 53 + let err = try JSONDecoder().decode(ExportError.self, from: Data(json.utf8)) 54 + #expect(err.unavailable == true) 55 + #expect(err.classification == .permanentlyUnavailable) 56 + } 57 + 58 + @Test("ExportError decodes legacy payload without unavailable or classification") 59 + func exportErrorLegacyDecodeMinimal() throws { 60 + let json = #"{"uuid":"u1","message":"boom"}"# 61 + let err = try JSONDecoder().decode(ExportError.self, from: Data(json.utf8)) 62 + #expect(err.unavailable == false) 63 + #expect(err.classification == .other) 64 + } 65 + 66 + @Test("ExportError round-trips with classification") 67 + func exportErrorRoundTripWithClassification() throws { 68 + let original = ExportError( 69 + uuid: "u1", 70 + message: "transient", 71 + classification: .transientCloud 72 + ) 73 + let data = try JSONEncoder().encode(original) 74 + let decoded = try JSONDecoder().decode(ExportError.self, from: data) 75 + #expect(decoded.classification == .transientCloud) 76 + #expect(decoded.unavailable == false) 77 + } 78 + 79 + @Test("ExportError with permanentlyUnavailable sets legacy unavailable flag") 80 + func exportErrorPermanentlyUnavailable() throws { 81 + let err = ExportError(uuid: "u1", message: "gone", classification: .permanentlyUnavailable) 82 + #expect(err.unavailable == true) 83 + let data = try JSONEncoder().encode(err) 84 + let decoded = try JSONDecoder().decode(ExportError.self, from: data) 85 + #expect(decoded.classification == .permanentlyUnavailable) 86 + #expect(decoded.unavailable == true) 87 + } 88 + 50 89 @Test("ExportRequest decodes from expected JSON format") 51 90 func exportRequestFromJSON() throws { 52 91 let json = """