commit 8b5fc11468326046cbc537dda25eda6be69f1701
parent c605b4e43846cc9fee5f76e3b8b856cb9a545c95
Author: triesap <tyson@radroots.org>
Date: Wed, 9 Sep 2026 08:18:43 +0000
runtime: reserve mutation admission before awaiting
- Reserve existing draft and authorization scopes before asynchronous callbacks.
- Keep upload and form admission across reentrancy and caller cancellation.
- Regenerate exact native receipts while retaining public APIs and durable IDs.
- Verify concurrent scopes, durable claims, and original admission failures.
Diffstat:
26 files changed, 1979 insertions(+), 867 deletions(-)
diff --git a/Package.swift b/Package.swift
@@ -54,6 +54,7 @@ let package = Package(
"App/TeraRemoteQualificationEvidence.swift",
"App/TeraRootShell.swift",
"Runtime/TeraAddMediaCoordinator.swift",
+ "Runtime/TeraBackgroundUploadRequest.swift",
"Runtime/TeraCheckedTime.swift",
"Runtime/TeraErrorRecovery.swift",
"Runtime/TeraGeneratedRuntimeBackend.swift",
@@ -72,6 +73,7 @@ let package = Package(
"State/TeraMediaStore.swift",
"State/TeraSessionStore.swift",
"State/TeraSupportingStores.swift",
+ "State/TeraSettingsStore.swift",
"State/TeraTodayStore.swift",
"Views/TeraAddView.swift",
"Views/TeraSupportingViews.swift",
diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj
@@ -17,6 +17,7 @@
64D49ADFD3FDD54CBA509DC5 /* TeraTodayStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794D475C70BA8ED994D1CA66 /* TeraTodayStoreTests.swift */; };
671EF50DB4EFBC25AABF26DC /* TeraLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */; };
6B0BE8E92892EF60B8E996A5 /* TeraRuntimeResourceFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A51F9AF2E0803264E07FBB /* TeraRuntimeResourceFixtures.swift */; };
+ 6F53FF88A29F8DF93F074EB4 /* TeraBackgroundUploadFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D8C0835D5A1D5C36D80020 /* TeraBackgroundUploadFixtures.swift */; };
70417FB635F06CC9829FB38C /* TeraClockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506546B0EC1792240C36F7 /* TeraClockTests.swift */; };
772A9B1AD8DE1D2E0969F285 /* TeraCalendarFFICompatibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B808E67E85CD36E49C38B6F /* TeraCalendarFFICompatibilityTests.swift */; };
79D06C6BF2B55C0C4104FB91 /* TeraSessionGenerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0836F5D5F71F9A54795C57 /* TeraSessionGenerationTests.swift */; };
@@ -30,6 +31,7 @@
B2DAAE01D16E1FAC35816DED /* RadrootsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1A9F6E32DE0C3728D4DB8E60 /* RadrootsKit */; };
B4AE86BE0A9F7FBE0DEBD9AC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = ADFD21D28C5F9E58B70E76CA /* Localizable.strings */; };
B64A836C5E84924D28CB5D47 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AB8A30A75EFD65EFD938D248 /* Assets.xcassets */; };
+ BB71614D2A6017C071A39BED /* TeraMutationAdmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F322CF98F8BEB908B93215 /* TeraMutationAdmissionTests.swift */; };
C219B5F4E24188E1116BF45B /* TeraRuntimeClientFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ED360C84532DA7088BFC6E /* TeraRuntimeClientFixtures.swift */; };
C3177E827FE62639579C3EF5 /* TeraRemoteQualificationUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32CBDAE974FB8D7408108E69 /* TeraRemoteQualificationUITests.swift */; };
CDBE38E6B521611436F28858 /* TeraMediaStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B6F0B16BFD8F046E9C163F /* TeraMediaStoreTests.swift */; };
@@ -58,6 +60,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
+ 02F322CF98F8BEB908B93215 /* TeraMutationAdmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraMutationAdmissionTests.swift; sourceTree = "<group>"; };
0CDE3D76E1EDF909C19D0506 /* TeraUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = TeraUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
226F5C2370522AAFAD9D1C3D /* TeraRuntimeResourceLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeResourceLifecycleTests.swift; sourceTree = "<group>"; };
27D9D40699A01FFB02F30B11 /* local-social-personas.v1.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "local-social-personas.v1.json"; sourceTree = "<group>"; };
@@ -70,6 +73,7 @@
5238B5F236C392C82CEF9838 /* TeraRootShellUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRootShellUITests.swift; sourceTree = "<group>"; };
57CA176D0A7566A4ADD2CEA4 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
61B6F0B16BFD8F046E9C163F /* TeraMediaStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraMediaStoreTests.swift; sourceTree = "<group>"; };
+ 61D8C0835D5A1D5C36D80020 /* TeraBackgroundUploadFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraBackgroundUploadFixtures.swift; sourceTree = "<group>"; };
67ACCE25979BA86C1EC5BFD6 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
6967B3EAC4CF1A410B26DD1A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
6B8677670E203FF68EC88983 /* TeraRootShellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRootShellTests.swift; sourceTree = "<group>"; };
@@ -155,11 +159,13 @@
isa = PBXGroup;
children = (
9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */,
+ 61D8C0835D5A1D5C36D80020 /* TeraBackgroundUploadFixtures.swift */,
3B808E67E85CD36E49C38B6F /* TeraCalendarFFICompatibilityTests.swift */,
2A506546B0EC1792240C36F7 /* TeraClockTests.swift */,
93A3358AFC23C567DB9CB02F /* TeraErrorRecoveryTests.swift */,
C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */,
61B6F0B16BFD8F046E9C163F /* TeraMediaStoreTests.swift */,
+ 02F322CF98F8BEB908B93215 /* TeraMutationAdmissionTests.swift */,
E351F52E77C74A4E99095E45 /* TeraRemoteQualificationTests.swift */,
6B8677670E203FF68EC88983 /* TeraRootShellTests.swift */,
2A4FFC0023C620B68D0EC0ED /* TeraRuntimeBoundedTaskTests.swift */,
@@ -383,11 +389,13 @@
buildActionMask = 2147483647;
files = (
A1AD2A380F09994786889AF1 /* TeraAddStoreTests.swift in Sources */,
+ 6F53FF88A29F8DF93F074EB4 /* TeraBackgroundUploadFixtures.swift in Sources */,
772A9B1AD8DE1D2E0969F285 /* TeraCalendarFFICompatibilityTests.swift in Sources */,
70417FB635F06CC9829FB38C /* TeraClockTests.swift in Sources */,
D918A664D9A4B9859A1A6503 /* TeraErrorRecoveryTests.swift in Sources */,
671EF50DB4EFBC25AABF26DC /* TeraLifecycleTests.swift in Sources */,
CDBE38E6B521611436F28858 /* TeraMediaStoreTests.swift in Sources */,
+ BB71614D2A6017C071A39BED /* TeraMutationAdmissionTests.swift in Sources */,
81AF9DDA0E64DC4EEFCEF25D /* TeraRemoteQualificationTests.swift in Sources */,
9AD968F0B37C64B5AF8D9A68 /* TeraRootShellTests.swift in Sources */,
229C9AC991E67663DA53D339 /* TeraRuntimeBoundedTaskTests.swift in Sources */,
diff --git a/Tera/Runtime/TeraAddMediaCoordinator.swift b/Tera/Runtime/TeraAddMediaCoordinator.swift
@@ -76,6 +76,9 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling {
private let preparer: RadrootsAppleMediaPreparer
private let transfer: any RadrootsBackgroundTransfer
private let clock: TeraClock
+ /// Reserve before request preparation or native callbacks. A cancelled waiter
+ /// releases this caller's admission; OS transfer state remains authoritative.
+ private var activeUploadDrafts: Set<String> = []
init(
roots: RadrootsAppleFileRoots,
@@ -193,38 +196,15 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling {
job: TeraNativeUploadJob,
media: TeraPreparedMedia
) async throws -> TeraAddBackgroundUploadReceipt {
- guard job.expectedSHA256 == media.sha256,
- job.mediaType == media.mediaType,
- job.byteSize == media.byteSize,
- let byteSize = Int(exactly: media.byteSize),
- let remoteURL = URL(string: job.remoteURL)
- else {
- throw TeraRuntimeFailure.local(
- operation: "add.media.background",
- code: "ios.add.background_upload_mismatch",
- safeMessage: "The prepared photo no longer matches the authorized upload."
- )
+ try Task.checkCancellation()
+ guard activeUploadDrafts.insert(job.draft.id).inserted else {
+ throw TeraBackgroundUploadRequest.operationInProgress
}
- let identifier = try Self.transferIdentifier(job: job)
- let prepared = try RadrootsApplePreparedImage(
- file: RadrootsStagedBlobReference(
- blobID: media.sha256,
- sizeBytes: byteSize,
- mediaType: media.mediaType,
- filenameHint: "\(media.sha256).png"
- ),
- sha256: media.sha256,
- width: media.width,
- height: media.height
- )
- let request = try await preparer.blossomUploadRequest(
- preparedImage: prepared,
- remoteURL: remoteURL,
- authorization: job.authorizationHeader,
- networkPolicy: remoteURL.scheme?.lowercased() == "https"
- ? .publicHTTPS : .simulatorLoopbackHTTP,
- identifier: identifier
+ defer { activeUploadDrafts.remove(job.draft.id) }
+ let request = try await TeraBackgroundUploadRequest.prepare(
+ job: job, media: media, preparer: preparer
)
+ let identifier = request.identifier
let persisted = try await matchingPersistedUpload(
draftID: job.draft.id,
expectedRevision: job.draft.revision,
@@ -234,7 +214,8 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling {
if let persisted {
activeIdentifier = persisted.identifier
if [.failed, .interrupted, .cancelled, .expired].contains(persisted.state) {
- let retry = try Self.replacingIdentifier(in: request, with: activeIdentifier)
+ let retry = try TeraBackgroundUploadRequest.replacingIdentifier(in: request, with: activeIdentifier)
+ try Task.checkCancellation()
_ = try await transfer.retry(retry)
}
} else {
@@ -288,7 +269,7 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling {
for snapshot in try await transfer.snapshots()
where snapshot.state == .awaitingVerification {
try Task.checkCancellation()
- guard let identity = Self.transferIdentity(snapshot.identifier),
+ guard let identity = TeraBackgroundUploadRequest.transferIdentity(snapshot.identifier),
let draft = draftsByID[identity.draftID]
else { continue }
guard draft.revision > identity.revision,
@@ -298,7 +279,7 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling {
&& $0.sha256 == snapshot.request.expectedSourceSHA256
}),
draft.media.contains(where: { $0.url == media.remoteURL && $0.stage == .verified }),
- try Self.persistedRequestMatchesMedia(snapshot.request, media: media)
+ try TeraBackgroundUploadRequest.persistedRequestMatchesMedia(snapshot.request, media: media)
else {
throw Self.failure(
code: "ios.add.background_upload_mismatch",
@@ -326,14 +307,6 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling {
)
}
- private static func transferIdentifier(
- job: TeraNativeUploadJob
- ) throws -> RadrootsBackgroundTransferIdentifier {
- try RadrootsBackgroundTransferIdentifier(
- "radroots.add.\(job.draft.id).\(job.draft.revision).\(job.operationID)"
- )
- }
-
private func matchingPersistedUpload(
draftID: String,
expectedRevision: UInt64,
@@ -344,7 +317,7 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling {
let prefix = "radroots.add.\(draftID)."
let owned = snapshots.filter { $0.identifier.rawValue.hasPrefix(prefix) }
let parsed = try owned.map { snapshot in
- guard let identity = Self.transferIdentity(snapshot.identifier),
+ guard let identity = TeraBackgroundUploadRequest.transferIdentity(snapshot.identifier),
identity.draftID == draftID,
identity.revision <= expectedRevision
else {
@@ -357,7 +330,7 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling {
}
let candidates = parsed.filter { snapshot in
snapshot.state != .completed
- || Self.persistedRequestMatches(snapshot.request, request: request)
+ || TeraBackgroundUploadRequest.persistedRequestMatches(snapshot.request, request: request)
}
try Task.checkCancellation()
let active = candidates.filter { $0.state != .completed }
@@ -368,7 +341,7 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling {
)
}
if let candidate = active.first {
- guard Self.persistedRequestMatches(candidate.request, request: request) else {
+ guard TeraBackgroundUploadRequest.persistedRequestMatches(candidate.request, request: request) else {
throw Self.failure(
code: "ios.add.background_upload_mismatch",
message: "The persisted photo upload does not match the authorized upload."
@@ -377,7 +350,7 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling {
return candidate
}
let completed = candidates.filter {
- $0.state == .completed && Self.persistedRequestMatches($0.request, request: request)
+ $0.state == .completed && TeraBackgroundUploadRequest.persistedRequestMatches($0.request, request: request)
}
guard completed.count <= 1 else {
throw Self.failure(
@@ -402,7 +375,7 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling {
message: "The background photo upload could not be recovered."
)
}
- guard Self.persistedRequestMatches(snapshot.request, request: request) else {
+ guard TeraBackgroundUploadRequest.persistedRequestMatches(snapshot.request, request: request) else {
throw Self.failure(
code: "ios.add.background_upload_mismatch",
message: "The persisted photo upload no longer matches its request."
@@ -440,79 +413,6 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling {
}
}
- private static func persistedRequestMatches(
- _ persisted: RadrootsBackgroundTransferRequest,
- request: RadrootsBackgroundTransferRequest
- ) -> Bool {
- persisted.headers.isEmpty && persisted.metadata.isEmpty
- && persisted.remoteURL == request.remoteURL
- && persisted.method == request.method
- && persisted.operation == request.operation
- && persisted.networkPolicy == request.networkPolicy
- && persisted.responsePolicy == request.responsePolicy
- && persisted.expectedSourceSHA256 == request.expectedSourceSHA256
- && persisted.maximumTransferBytes == request.maximumTransferBytes
- }
-
- private static func persistedRequestMatchesMedia(
- _ persisted: RadrootsBackgroundTransferRequest,
- media: TeraPreparedMedia
- ) throws -> Bool {
- guard let remoteURL = media.remoteURL.flatMap(URL.init(string:)),
- let byteSize = Int(exactly: media.byteSize)
- else { return false }
- let blob = try RadrootsStagedBlobReference(
- blobID: media.sha256,
- sizeBytes: byteSize,
- mediaType: media.mediaType,
- filenameHint: "\(media.sha256).png"
- )
- let responsePolicy = try RadrootsBackgroundTransferResponsePolicy.boundedJSON()
- return persisted.headers.isEmpty && persisted.metadata.isEmpty
- && persisted.remoteURL == remoteURL
- && persisted.method == .put
- && persisted.operation == .upload(source: .stagedBlob(blob))
- && persisted.networkPolicy
- == (remoteURL.scheme?.lowercased() == "https" ? .publicHTTPS : .simulatorLoopbackHTTP)
- && persisted.responsePolicy == responsePolicy
- && persisted.expectedSourceSHA256 == media.sha256
- && persisted.maximumTransferBytes
- == RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes
- }
-
- private static func replacingIdentifier(
- in request: RadrootsBackgroundTransferRequest,
- with identifier: RadrootsBackgroundTransferIdentifier
- ) throws -> RadrootsBackgroundTransferRequest {
- try RadrootsBackgroundTransferRequest(
- identifier: identifier,
- remoteURL: request.remoteURL,
- method: request.method,
- operation: request.operation,
- headers: request.headers,
- metadata: request.metadata,
- networkPolicy: request.networkPolicy,
- responsePolicy: request.responsePolicy,
- expectedSourceSHA256: request.expectedSourceSHA256,
- maximumTransferBytes: request.maximumTransferBytes
- )
- }
-
- private static func transferIdentity(
- _ identifier: RadrootsBackgroundTransferIdentifier
- ) -> (draftID: String, revision: UInt64)? {
- let components = identifier.rawValue.split(separator: ".", omittingEmptySubsequences: false)
- guard components.count == 5,
- components[0] == "radroots",
- components[1] == "add",
- components[2].range(of: "^[0-9a-f]{32}$", options: .regularExpression) != nil,
- let revision = UInt64(components[3]),
- String(revision) == components[3],
- components[4].range(of: "^[0-9a-f]{32}$", options: .regularExpression) != nil
- else { return nil }
- return (String(components[2]), revision)
- }
-
private static func failure(code: String, message: String) -> TeraRuntimeFailure {
.local(operation: "add.media.background", code: code, safeMessage: message)
}
diff --git a/Tera/Runtime/TeraBackgroundUploadRequest.swift b/Tera/Runtime/TeraBackgroundUploadRequest.swift
@@ -0,0 +1,132 @@
+import Foundation
+import RadrootsKit
+
+/// Exact request translation and matching; no transfer or task ownership.
+enum TeraBackgroundUploadRequest {
+ static let operationInProgress = TeraRuntimeFailure(
+ schemaVersion: 1, code: "operation_in_progress", category: "operation",
+ retryable: true, recoveryActions: ["retry_operation_with_same_idempotency_key"],
+ operationID: "add.media.background", capabilityID: nil,
+ safeMessage: "A photo upload is already in progress for this draft."
+ )
+
+ static func prepare(
+ job: TeraNativeUploadJob,
+ media: TeraPreparedMedia,
+ preparer: RadrootsAppleMediaPreparer
+ ) async throws -> RadrootsBackgroundTransferRequest {
+ guard job.expectedSHA256 == media.sha256,
+ job.mediaType == media.mediaType,
+ job.byteSize == media.byteSize,
+ let byteSize = Int(exactly: media.byteSize),
+ let remoteURL = URL(string: job.remoteURL)
+ else {
+ throw TeraRuntimeFailure.local(
+ operation: "add.media.background",
+ code: "ios.add.background_upload_mismatch",
+ safeMessage: "The prepared photo no longer matches the authorized upload."
+ )
+ }
+ let identifier = try TeraBackgroundUploadRequest.transferIdentifier(job: job)
+ let prepared = try RadrootsApplePreparedImage(
+ file: RadrootsStagedBlobReference(
+ blobID: media.sha256,
+ sizeBytes: byteSize,
+ mediaType: media.mediaType,
+ filenameHint: "\(media.sha256).png"
+ ),
+ sha256: media.sha256,
+ width: media.width,
+ height: media.height
+ )
+ return try await preparer.blossomUploadRequest(
+ preparedImage: prepared,
+ remoteURL: remoteURL,
+ authorization: job.authorizationHeader,
+ networkPolicy: remoteURL.scheme?.lowercased() == "https"
+ ? .publicHTTPS : .simulatorLoopbackHTTP,
+ identifier: identifier
+ )
+ }
+
+ static func transferIdentifier(
+ job: TeraNativeUploadJob
+ ) throws -> RadrootsBackgroundTransferIdentifier {
+ try RadrootsBackgroundTransferIdentifier(
+ "radroots.add.\(job.draft.id).\(job.draft.revision).\(job.operationID)"
+ )
+ }
+
+ static func persistedRequestMatches(
+ _ persisted: RadrootsBackgroundTransferRequest,
+ request: RadrootsBackgroundTransferRequest
+ ) -> Bool {
+ persisted.headers.isEmpty && persisted.metadata.isEmpty
+ && persisted.remoteURL == request.remoteURL
+ && persisted.method == request.method
+ && persisted.operation == request.operation
+ && persisted.networkPolicy == request.networkPolicy
+ && persisted.responsePolicy == request.responsePolicy
+ && persisted.expectedSourceSHA256 == request.expectedSourceSHA256
+ && persisted.maximumTransferBytes == request.maximumTransferBytes
+ }
+
+ static func persistedRequestMatchesMedia(
+ _ persisted: RadrootsBackgroundTransferRequest,
+ media: TeraPreparedMedia
+ ) throws -> Bool {
+ guard let remoteURL = media.remoteURL.flatMap(URL.init(string:)),
+ let byteSize = Int(exactly: media.byteSize)
+ else { return false }
+ let blob = try RadrootsStagedBlobReference(
+ blobID: media.sha256,
+ sizeBytes: byteSize,
+ mediaType: media.mediaType,
+ filenameHint: "\(media.sha256).png"
+ )
+ let responsePolicy = try RadrootsBackgroundTransferResponsePolicy.boundedJSON()
+ return persisted.headers.isEmpty && persisted.metadata.isEmpty
+ && persisted.remoteURL == remoteURL
+ && persisted.method == .put
+ && persisted.operation == .upload(source: .stagedBlob(blob))
+ && persisted.networkPolicy
+ == (remoteURL.scheme?.lowercased() == "https" ? .publicHTTPS : .simulatorLoopbackHTTP)
+ && persisted.responsePolicy == responsePolicy
+ && persisted.expectedSourceSHA256 == media.sha256
+ && persisted.maximumTransferBytes
+ == RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes
+ }
+
+ static func replacingIdentifier(
+ in request: RadrootsBackgroundTransferRequest,
+ with identifier: RadrootsBackgroundTransferIdentifier
+ ) throws -> RadrootsBackgroundTransferRequest {
+ try RadrootsBackgroundTransferRequest(
+ identifier: identifier,
+ remoteURL: request.remoteURL,
+ method: request.method,
+ operation: request.operation,
+ headers: request.headers,
+ metadata: request.metadata,
+ networkPolicy: request.networkPolicy,
+ responsePolicy: request.responsePolicy,
+ expectedSourceSHA256: request.expectedSourceSHA256,
+ maximumTransferBytes: request.maximumTransferBytes
+ )
+ }
+
+ static func transferIdentity(
+ _ identifier: RadrootsBackgroundTransferIdentifier
+ ) -> (draftID: String, revision: UInt64)? {
+ let components = identifier.rawValue.split(separator: ".", omittingEmptySubsequences: false)
+ guard components.count == 5,
+ components[0] == "radroots",
+ components[1] == "add",
+ components[2].range(of: "^[0-9a-f]{32}$", options: .regularExpression) != nil,
+ let revision = UInt64(components[3]),
+ String(revision) == components[3],
+ components[4].range(of: "^[0-9a-f]{32}$", options: .regularExpression) != nil
+ else { return nil }
+ return (String(components[2]), revision)
+ }
+}
diff --git a/Tera/State/TeraAddStore.swift b/Tera/State/TeraAddStore.swift
@@ -703,7 +703,7 @@ final class TeraAddStore: ObservableObject {
}
private func perform(_ operation: @escaping () async throws -> Void) async {
- guard operationTask == nil else { return }
+ guard operationTask == nil, !Task.isCancelled else { return }
generation &+= 1
let requestedGeneration = generation
operationGeneration = requestedGeneration
@@ -711,7 +711,7 @@ final class TeraAddStore: ObservableObject {
message = nil
lastFailureCode = nil
let task = Task { @MainActor [weak self] in
- guard let self else { return }
+ guard let self, !Task.isCancelled else { return }
await execute(operation, generation: requestedGeneration)
}
operationTask = task
diff --git a/Tera/State/TeraSettingsStore.swift b/Tera/State/TeraSettingsStore.swift
@@ -0,0 +1,234 @@
+import Foundation
+
+@MainActor
+final class TeraSettingsStore: ObservableObject {
+ @Published private(set) var settings: TeraMobileSettings?
+ @Published private(set) var profileStatus: TeraProfileStatus?
+ @Published private(set) var isWorking = false
+ @Published private(set) var message: String?
+ @Published private(set) var failureCode: String?
+
+ @Published var networkEnvironment: TeraSettingsNetworkEnvironment = .publicNetwork
+ @Published var relays: [TeraRelayPreference] = []
+ @Published var blossomAuthority: TeraBlossomAuthorityPreference = .publicWebPKI
+ @Published var blossomPrimaryOrigin = ""
+ @Published var blossomFallbackOrigins = ""
+ @Published var allowCellularDownloads = true
+ @Published var allowCellularUploads = true
+ @Published var allowBackgroundTransfers = true
+ @Published var mediaCacheMegabytes = 256
+ @Published var mediaCacheArtifacts = 1024
+ @Published var profileName = ""
+ @Published var profileDisplayName = ""
+ @Published var profileAbout = ""
+ @Published var profileNip05 = ""
+ @Published var profileBot = false
+
+ private let runtimeClient: TeraRuntimeClient
+ private var generation: UInt64 = 0
+ /// Presentation invalidation cannot release an active mutation's admission.
+ private var mutationInProgress = false
+
+ init(runtimeClient: TeraRuntimeClient) {
+ self.runtimeClient = runtimeClient
+ }
+
+ func load(profile: TeraProfileSummary?) async {
+ generation &+= 1
+ let requestedGeneration = generation
+ isWorking = true
+ defer {
+ if requestedGeneration == generation {
+ isWorking = false
+ }
+ }
+ do {
+ let loaded = try await runtimeClient.mobileSettings()
+ guard requestedGeneration == generation, !Task.isCancelled else { return }
+ apply(loaded)
+ if let profile {
+ profileName = profile.name ?? ""
+ profileDisplayName = profile.displayName ?? ""
+ profileAbout = profile.about ?? ""
+ profileNip05 = profile.nip05 ?? ""
+ }
+ message = nil
+ failureCode = nil
+ } catch {
+ guard requestedGeneration == generation, !Task.isCancelled else { return }
+ record(error)
+ }
+ }
+
+ func addRelay() {
+ relays.append(TeraRelayPreference(url: "", access: .readWrite))
+ }
+
+ func removeRelays(at offsets: IndexSet) {
+ relays.remove(atOffsets: offsets)
+ }
+
+ func saveSettings() async -> Bool {
+ guard let settings, reserveMutation() else { return false }
+ defer { mutationInProgress = false }
+ generation &+= 1
+ let requestedGeneration = generation
+ isWorking = true
+ defer {
+ if requestedGeneration == generation {
+ isWorking = false
+ }
+ }
+ let fallbacks = blossomFallbackOrigins
+ .components(separatedBy: CharacterSet(charactersIn: ",;\n\r"))
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ do {
+ let transition = try await runtimeClient.replaceMobileSettings(
+ input: TeraReplaceSettings(
+ expectedRevision: settings.revision,
+ networkEnvironment: networkEnvironment,
+ relays: relays,
+ blossomAuthority: blossomAuthority,
+ blossomPrimaryOrigin: blossomPrimaryOrigin,
+ blossomFallbackOrigins: fallbacks,
+ allowCellularDownloads: allowCellularDownloads,
+ allowCellularUploads: allowCellularUploads,
+ allowBackgroundTransfers: allowBackgroundTransfers,
+ mediaCacheBytes: UInt64(max(mediaCacheMegabytes, 1)) * 1_048_576,
+ mediaCacheArtifacts: UInt32(max(mediaCacheArtifacts, 1))
+ )
+ )
+ guard requestedGeneration == generation, !Task.isCancelled else { return false }
+ apply(transition.settings)
+ let effects = [
+ transition.runtimeRestartRequired ? "runtime restart" : nil,
+ transition.outboxRequeueRequired ? "outbox requeue" : nil,
+ transition.mediaCacheInvalidationRequired ? "media cache refresh" : nil,
+ ].compactMap(\.self)
+ message = effects.isEmpty
+ ? "Settings saved."
+ : "Settings saved; required changes: \(effects.joined(separator: ", "))."
+ failureCode = nil
+ return transition.runtimeRestartRequired
+ } catch {
+ guard requestedGeneration == generation, !Task.isCancelled else { return false }
+ record(error)
+ return false
+ }
+ }
+
+ func saveProfile() async {
+ guard reserveMutation() else { return }
+ defer { mutationInProgress = false }
+ generation &+= 1
+ let requestedGeneration = generation
+ isWorking = true
+ defer {
+ if requestedGeneration == generation {
+ isWorking = false
+ }
+ }
+ do {
+ let status = try await runtimeClient.saveProfileMetadata(
+ input: TeraProfileMetadataInput(
+ name: profileName,
+ displayName: optional(profileDisplayName),
+ about: optional(profileAbout),
+ picture: nil,
+ banner: nil,
+ nip05: optional(profileNip05),
+ bot: profileBot
+ )
+ )
+ guard requestedGeneration == generation, !Task.isCancelled else { return }
+ profileStatus = status
+ message = "Profile update saved to the durable outbox."
+ failureCode = nil
+ } catch {
+ guard requestedGeneration == generation, !Task.isCancelled else { return }
+ record(error)
+ }
+ }
+
+ func advanceProfile() async {
+ guard let profileStatus else { return }
+ await runProfileOperation {
+ try await self.runtimeClient.advanceProfile(operationID: profileStatus.id)
+ }
+ }
+
+ func cancelProfile() async {
+ guard let profileStatus else { return }
+ await runProfileOperation {
+ try await self.runtimeClient.cancelProfile(
+ operationID: profileStatus.id,
+ expectedRevision: profileStatus.revision
+ )
+ }
+ }
+
+ func stop() {
+ generation &+= 1
+ isWorking = false
+ }
+
+ private func runProfileOperation(
+ _ operation: @escaping () async throws -> TeraProfileStatus
+ ) async {
+ guard reserveMutation() else { return }
+ defer { mutationInProgress = false }
+ generation &+= 1
+ let requestedGeneration = generation
+ isWorking = true
+ defer {
+ if requestedGeneration == generation {
+ isWorking = false
+ }
+ }
+ do {
+ let status = try await operation()
+ guard requestedGeneration == generation, !Task.isCancelled else { return }
+ profileStatus = status
+ message = status.honestSummary
+ failureCode = nil
+ } catch {
+ guard requestedGeneration == generation, !Task.isCancelled else { return }
+ record(error)
+ }
+ }
+
+ private func reserveMutation() -> Bool {
+ guard !mutationInProgress, !Task.isCancelled else { return false }
+ mutationInProgress = true
+ return true
+ }
+
+ private func apply(_ loaded: TeraMobileSettings) {
+ settings = loaded
+ networkEnvironment = loaded.networkEnvironment
+ relays = loaded.relays
+ blossomAuthority = loaded.blossomAuthority
+ blossomPrimaryOrigin = loaded.blossomPrimaryOrigin
+ blossomFallbackOrigins = loaded.blossomFallbackOrigins.joined(separator: "\n")
+ allowCellularDownloads = loaded.allowCellularDownloads
+ allowCellularUploads = loaded.allowCellularUploads
+ allowBackgroundTransfers = loaded.allowBackgroundTransfers
+ mediaCacheMegabytes = Int(loaded.mediaCacheBytes / 1_048_576)
+ mediaCacheArtifacts = Int(loaded.mediaCacheArtifacts)
+ }
+
+ private func record(_ error: Error) {
+ if case let TeraRuntimeClientError.support(failure) = error {
+ failureCode = failure.code
+ } else {
+ failureCode = "ios.settings.operation_failed"
+ }
+ message = TeraUserMessages.text(for: error, fallback: .settingsOperationFailed)
+ }
+
+ private func optional(_ value: String) -> String? {
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return normalized.isEmpty ? nil : normalized
+ }
+}
diff --git a/Tera/State/TeraSupportingStores.swift b/Tera/State/TeraSupportingStores.swift
@@ -229,223 +229,3 @@ final class TeraMeStore: ObservableObject {
TeraUserMessages.text(for: error, fallback: .profileUnavailable)
}
}
-
-@MainActor
-final class TeraSettingsStore: ObservableObject {
- @Published private(set) var settings: TeraMobileSettings?
- @Published private(set) var profileStatus: TeraProfileStatus?
- @Published private(set) var isWorking = false
- @Published private(set) var message: String?
- @Published private(set) var failureCode: String?
-
- @Published var networkEnvironment: TeraSettingsNetworkEnvironment = .publicNetwork
- @Published var relays: [TeraRelayPreference] = []
- @Published var blossomAuthority: TeraBlossomAuthorityPreference = .publicWebPKI
- @Published var blossomPrimaryOrigin = ""
- @Published var blossomFallbackOrigins = ""
- @Published var allowCellularDownloads = true
- @Published var allowCellularUploads = true
- @Published var allowBackgroundTransfers = true
- @Published var mediaCacheMegabytes = 256
- @Published var mediaCacheArtifacts = 1024
- @Published var profileName = ""
- @Published var profileDisplayName = ""
- @Published var profileAbout = ""
- @Published var profileNip05 = ""
- @Published var profileBot = false
-
- private let runtimeClient: TeraRuntimeClient
- private var generation: UInt64 = 0
-
- init(runtimeClient: TeraRuntimeClient) {
- self.runtimeClient = runtimeClient
- }
-
- func load(profile: TeraProfileSummary?) async {
- generation &+= 1
- let requestedGeneration = generation
- isWorking = true
- defer {
- if requestedGeneration == generation {
- isWorking = false
- }
- }
- do {
- let loaded = try await runtimeClient.mobileSettings()
- guard requestedGeneration == generation, !Task.isCancelled else { return }
- apply(loaded)
- if let profile {
- profileName = profile.name ?? ""
- profileDisplayName = profile.displayName ?? ""
- profileAbout = profile.about ?? ""
- profileNip05 = profile.nip05 ?? ""
- }
- message = nil
- failureCode = nil
- } catch {
- guard requestedGeneration == generation, !Task.isCancelled else { return }
- record(error)
- }
- }
-
- func addRelay() {
- relays.append(TeraRelayPreference(url: "", access: .readWrite))
- }
-
- func removeRelays(at offsets: IndexSet) {
- relays.remove(atOffsets: offsets)
- }
-
- func saveSettings() async -> Bool {
- guard let settings else { return false }
- generation &+= 1
- let requestedGeneration = generation
- isWorking = true
- defer {
- if requestedGeneration == generation {
- isWorking = false
- }
- }
- let fallbacks = blossomFallbackOrigins
- .components(separatedBy: CharacterSet(charactersIn: ",;\n\r"))
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- do {
- let transition = try await runtimeClient.replaceMobileSettings(
- input: TeraReplaceSettings(
- expectedRevision: settings.revision,
- networkEnvironment: networkEnvironment,
- relays: relays,
- blossomAuthority: blossomAuthority,
- blossomPrimaryOrigin: blossomPrimaryOrigin,
- blossomFallbackOrigins: fallbacks,
- allowCellularDownloads: allowCellularDownloads,
- allowCellularUploads: allowCellularUploads,
- allowBackgroundTransfers: allowBackgroundTransfers,
- mediaCacheBytes: UInt64(max(mediaCacheMegabytes, 1)) * 1_048_576,
- mediaCacheArtifacts: UInt32(max(mediaCacheArtifacts, 1))
- )
- )
- guard requestedGeneration == generation, !Task.isCancelled else { return false }
- apply(transition.settings)
- let effects = [
- transition.runtimeRestartRequired ? "runtime restart" : nil,
- transition.outboxRequeueRequired ? "outbox requeue" : nil,
- transition.mediaCacheInvalidationRequired ? "media cache refresh" : nil,
- ].compactMap(\.self)
- message = effects.isEmpty
- ? "Settings saved."
- : "Settings saved; required changes: \(effects.joined(separator: ", "))."
- failureCode = nil
- return transition.runtimeRestartRequired
- } catch {
- guard requestedGeneration == generation, !Task.isCancelled else { return false }
- record(error)
- return false
- }
- }
-
- func saveProfile() async {
- generation &+= 1
- let requestedGeneration = generation
- isWorking = true
- defer {
- if requestedGeneration == generation {
- isWorking = false
- }
- }
- do {
- let status = try await runtimeClient.saveProfileMetadata(
- input: TeraProfileMetadataInput(
- name: profileName,
- displayName: optional(profileDisplayName),
- about: optional(profileAbout),
- picture: nil,
- banner: nil,
- nip05: optional(profileNip05),
- bot: profileBot
- )
- )
- guard requestedGeneration == generation, !Task.isCancelled else { return }
- profileStatus = status
- message = "Profile update saved to the durable outbox."
- failureCode = nil
- } catch {
- guard requestedGeneration == generation, !Task.isCancelled else { return }
- record(error)
- }
- }
-
- func advanceProfile() async {
- guard let profileStatus else { return }
- await runProfileOperation {
- try await self.runtimeClient.advanceProfile(operationID: profileStatus.id)
- }
- }
-
- func cancelProfile() async {
- guard let profileStatus else { return }
- await runProfileOperation {
- try await self.runtimeClient.cancelProfile(
- operationID: profileStatus.id,
- expectedRevision: profileStatus.revision
- )
- }
- }
-
- func stop() {
- generation &+= 1
- isWorking = false
- }
-
- private func runProfileOperation(
- _ operation: @escaping () async throws -> TeraProfileStatus
- ) async {
- generation &+= 1
- let requestedGeneration = generation
- isWorking = true
- defer {
- if requestedGeneration == generation {
- isWorking = false
- }
- }
- do {
- let status = try await operation()
- guard requestedGeneration == generation, !Task.isCancelled else { return }
- profileStatus = status
- message = status.honestSummary
- failureCode = nil
- } catch {
- guard requestedGeneration == generation, !Task.isCancelled else { return }
- record(error)
- }
- }
-
- private func apply(_ loaded: TeraMobileSettings) {
- settings = loaded
- networkEnvironment = loaded.networkEnvironment
- relays = loaded.relays
- blossomAuthority = loaded.blossomAuthority
- blossomPrimaryOrigin = loaded.blossomPrimaryOrigin
- blossomFallbackOrigins = loaded.blossomFallbackOrigins.joined(separator: "\n")
- allowCellularDownloads = loaded.allowCellularDownloads
- allowCellularUploads = loaded.allowCellularUploads
- allowBackgroundTransfers = loaded.allowBackgroundTransfers
- mediaCacheMegabytes = Int(loaded.mediaCacheBytes / 1_048_576)
- mediaCacheArtifacts = Int(loaded.mediaCacheArtifacts)
- }
-
- private func record(_ error: Error) {
- if case let TeraRuntimeClientError.support(failure) = error {
- failureCode = failure.code
- } else {
- failureCode = "ios.settings.operation_failed"
- }
- message = TeraUserMessages.text(for: error, fallback: .settingsOperationFailed)
- }
-
- private func optional(_ value: String) -> String? {
- let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
- return normalized.isEmpty ? nil : normalized
- }
-}
diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json
@@ -22,9 +22,9 @@
"sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c"
},
{
- "bytes": 71202536,
+ "bytes": 71298400,
"path": "TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a",
- "sha256": "0db79e66c4167aaa8bebcf6c6640c4efabb7a5f196780a5392ffedb117d562cd"
+ "sha256": "86132ffe7b61711ef73e6fe9b437b2d3e2a7d3d2b87d3960a5b9ef1698cc3fe1"
},
{
"bytes": 69176,
@@ -37,9 +37,9 @@
"sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c"
},
{
- "bytes": 71256912,
+ "bytes": 71351712,
"path": "TeraFFI.xcframework/ios-arm64/libtera_ffi.a",
- "sha256": "3c73070045461c9df31969d0a4b85f5265c848be73fca6c451d3e1920f6504be"
+ "sha256": "5f657fe4f72c79e8f42938b2318153d18370c49db2336a586fd00b9beece54e4"
},
{
"bytes": 41083,
@@ -77,34 +77,34 @@
"sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c"
},
{
- "bytes": 19815152,
+ "bytes": 19867712,
"path": "native/aarch64-apple-darwin/libtera_ffi.dylib",
- "sha256": "8db7f7fe9361a4a57a079e60cac4c807d00d40b0f91bffc02e6ebb6cc1cb6184"
+ "sha256": "ff044443ba203f8fb954fbc4fc986540f34163a6d8396ced1bde0cccfb452788"
},
{
- "bytes": 71202536,
+ "bytes": 71298400,
"path": "native/aarch64-apple-ios-sim/libtera_ffi.a",
- "sha256": "0db79e66c4167aaa8bebcf6c6640c4efabb7a5f196780a5392ffedb117d562cd"
+ "sha256": "86132ffe7b61711ef73e6fe9b437b2d3e2a7d3d2b87d3960a5b9ef1698cc3fe1"
},
{
- "bytes": 71256912,
+ "bytes": 71351712,
"path": "native/aarch64-apple-ios/libtera_ffi.a",
- "sha256": "3c73070045461c9df31969d0a4b85f5265c848be73fca6c451d3e1920f6504be"
+ "sha256": "5f657fe4f72c79e8f42938b2318153d18370c49db2336a586fd00b9beece54e4"
},
{
- "bytes": 55357,
+ "bytes": 55935,
"path": "source/aarch64-apple-darwin.json",
- "sha256": "bd0f9c9ef8f74f381bec5b3102c2103d76ab0a013ebeb1cfacfc2e46db8d28b7"
+ "sha256": "51c9de5886c1d05ef5dab20a79cee82e6772e5956d7eaf7bc606a3e0bfcd2e34"
},
{
- "bytes": 55201,
+ "bytes": 55779,
"path": "source/aarch64-apple-ios-sim.json",
- "sha256": "bc13a2c56369c028d16c766928f932ff6251b01d213676c3c44fa33a2e42f4f3"
+ "sha256": "55c6730f612e3392bd44a7d761d94cca162c9fe7fef4c36248e3b61eafc7b855"
},
{
- "bytes": 55197,
+ "bytes": 55775,
"path": "source/aarch64-apple-ios.json",
- "sha256": "c52645bf421065857e8638b5f6736a53e13f16e47a1c021c14f84ec1898d1f3f"
+ "sha256": "46c600975103c4038084f44420605930154361ed62586b70598bb05e67c22c5a"
}
],
"language": "swift",
@@ -112,7 +112,7 @@
"schema": "radroots.artifact-manifest.v2",
"source": {
"repository": "https://github.com/radrootslabs/tera",
- "tree": "ee3a2c5daa71f0fb972496b44ca3be606a887c16"
+ "tree": "f6bfeb00965c4cf264b5f74e488427092965762f"
},
"source_records": {
"aarch64-apple-darwin": "source/aarch64-apple-darwin.json",
@@ -139,9 +139,9 @@
"sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c"
},
{
- "bytes": 71202536,
+ "bytes": 71298400,
"path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a",
- "sha256": "0db79e66c4167aaa8bebcf6c6640c4efabb7a5f196780a5392ffedb117d562cd"
+ "sha256": "86132ffe7b61711ef73e6fe9b437b2d3e2a7d3d2b87d3960a5b9ef1698cc3fe1"
},
{
"bytes": 69176,
@@ -154,9 +154,9 @@
"sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c"
},
{
- "bytes": 71256912,
+ "bytes": 71351712,
"path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64/libtera_ffi.a",
- "sha256": "3c73070045461c9df31969d0a4b85f5265c848be73fca6c451d3e1920f6504be"
+ "sha256": "5f657fe4f72c79e8f42938b2318153d18370c49db2336a586fd00b9beece54e4"
},
{
"bytes": 490236,
@@ -174,19 +174,19 @@
"sha256": "37ba223303e6268d12d8581ba9a1a77a327b132122e29284bec715cca3c19369"
},
{
- "bytes": 55357,
+ "bytes": 55935,
"path": "TeraFFI/source/aarch64-apple-darwin.json",
- "sha256": "bd0f9c9ef8f74f381bec5b3102c2103d76ab0a013ebeb1cfacfc2e46db8d28b7"
+ "sha256": "51c9de5886c1d05ef5dab20a79cee82e6772e5956d7eaf7bc606a3e0bfcd2e34"
},
{
- "bytes": 55201,
+ "bytes": 55779,
"path": "TeraFFI/source/aarch64-apple-ios-sim.json",
- "sha256": "bc13a2c56369c028d16c766928f932ff6251b01d213676c3c44fa33a2e42f4f3"
+ "sha256": "55c6730f612e3392bd44a7d761d94cca162c9fe7fef4c36248e3b61eafc7b855"
},
{
- "bytes": 55197,
+ "bytes": 55775,
"path": "TeraFFI/source/aarch64-apple-ios.json",
- "sha256": "c52645bf421065857e8638b5f6736a53e13f16e47a1c021c14f84ec1898d1f3f"
+ "sha256": "46c600975103c4038084f44420605930154361ed62586b70598bb05e67c22c5a"
}
],
"schema": "tera.installed-native-artifacts.v1"
diff --git a/TeraFFI/source.lock b/TeraFFI/source.lock
@@ -1,7 +1,7 @@
schema = "tera.installed-source.v1"
repository = "https://github.com/radrootslabs/tera"
-source_tree = "ee3a2c5daa71f0fb972496b44ca3be606a887c16"
-manifest_sha256 = "d8a97dce55ee2f58e56cc6bebc5870956df106ed7715e144f13c617be94c9d2a"
+source_tree = "f6bfeb00965c4cf264b5f74e488427092965762f"
+manifest_sha256 = "6c26d7069a9864fb5c70f1bd8633b6102d2aa9cb5774c06bf6c01e542cb3315d"
source_date_epoch = 1787871027
[foundation]
diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json
@@ -613,10 +613,16 @@
"sha256": "1b0e54b4ba895b5d4f849c28abb09fb9fd4d9e8caf2a968403c310ab6da6a7cc"
},
"core/crates/tera_core/src/runtime/mod.rs": {
- "bytes": 7689,
- "git_blob": "e3864dcbc2c94b165fef2de600dc58304fce0654",
+ "bytes": 7958,
+ "git_blob": "2c7b83e791f679bd8b42a0bc74ea5dd8b21c902c",
"mode": "100644",
- "sha256": "b5d852f6c0afc97284fd322c8f1b1214533ea8e3df05fe550aaa9eaac64a0d12"
+ "sha256": "1460ac137204de4cecc3b01eb2a41142b7bd9730f5a6e8a83d2dd84cb0817ec1"
+ },
+ "core/crates/tera_core/src/runtime/mutation_admission.rs": {
+ "bytes": 2148,
+ "git_blob": "93952c82b83f05e019a994b086002ca0e3be3733",
+ "mode": "100644",
+ "sha256": "dff55e46521c26f5f0ece024453b55c15f132fa94f78e2cba3d141f123d4eca0"
},
"core/crates/tera_core/src/runtime/product_surface.rs": {
"bytes": 6019,
@@ -667,10 +673,16 @@
"sha256": "86271be5ec0ae8ef6906752272f5e091415b0581ed717809f50a973919275a1e"
},
"core/crates/tera_core/src/runtime/product_surface/outbox.rs": {
- "bytes": 163082,
- "git_blob": "48d3502d1cc1e885ba42a071cf83fa927f99fdfc",
+ "bytes": 169297,
+ "git_blob": "6daf3a457d8b6c778da035d929ba37255aef310a",
+ "mode": "100644",
+ "sha256": "6de6f9c86c1336b331ea652fe767eb6936fde126c2dee8365d2eceef931b8175"
+ },
+ "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs": {
+ "bytes": 10886,
+ "git_blob": "72891344789c379cdbe17ea97487afdfc3f62373",
"mode": "100644",
- "sha256": "297975a76245389c1b799cd9eb4c9d773d5ddd75b9c8faa07b06d6a866efeec9"
+ "sha256": "f532beab6e57b127ea644f65171fe93d617f3d584950c7322847407334aaf8dc"
},
"core/crates/tera_core/src/runtime/product_surface/projection.rs": {
"bytes": 23469,
@@ -691,10 +703,10 @@
"sha256": "18a82705b14e35ac716f4e9ae4ac39545c7ec9a8a4ded898e8c8febfa054938b"
},
"core/crates/tera_core/src/runtime/product_surface/today.rs": {
- "bytes": 134211,
- "git_blob": "03554393477cf579708bcacfe60286c058ce68a3",
+ "bytes": 134254,
+ "git_blob": "233c943ee7c79af245e2540c35750aa719585870",
"mode": "100644",
- "sha256": "bb54b4559249028ad9631f0af6a7db41a5f6062211f7f3691969da37fbc8e20e"
+ "sha256": "dcd7884d8fbda59e439551110abff68cc4069a5aae6cdc5376d57367afcd7a70"
},
"core/crates/tera_core/src/runtime/sdk.rs": {
"bytes": 20071,
@@ -745,10 +757,10 @@
"sha256": "0f19070bd3beb9dbbc871ceca44c30e590ea2d38e875339fe9dbcde749b3aa6e"
},
"core/crates/tera_ffi/src/error.rs": {
- "bytes": 18357,
- "git_blob": "5c3d8dcf8be230c033c5c9e59bc99c9d3938c986",
+ "bytes": 19532,
+ "git_blob": "5efd4b1d83573a9d458b595074d28ba78e94e036",
"mode": "100644",
- "sha256": "b9385c0bc9ca913f224efa220db65b2e78867f9a7c7afed1c7d1041300a956e6"
+ "sha256": "94298868ee6b7548b83fac0a12b7637eb9e94963168bf654e98f7c3ade2fa12f"
},
"core/crates/tera_ffi/src/lib.rs": {
"bytes": 1334,
@@ -1189,13 +1201,13 @@
"sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d"
},
"test-fixtures/legacy-identifiers.v1.json": {
- "bytes": 110621,
- "git_blob": "14d5342029968785335a2466d25832fcc8b85e9d",
+ "bytes": 112691,
+ "git_blob": "c182eb3f94b158ed66926f7216b994434697a0f8",
"mode": "100644",
- "sha256": "e45baeb16a4b77dd90962651ee9b8398040e3685b38feb298b8b5d5fdc437d67"
+ "sha256": "28f66e949f0d7335a47ff7799e077a985e5450bdd4fe0315ecf757e52b00bc32"
}
},
"policy": "staged_inputs",
- "tree": "ee3a2c5daa71f0fb972496b44ca3be606a887c16"
+ "tree": "f6bfeb00965c4cf264b5f74e488427092965762f"
}
}
diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json
@@ -609,10 +609,16 @@
"sha256": "1b0e54b4ba895b5d4f849c28abb09fb9fd4d9e8caf2a968403c310ab6da6a7cc"
},
"core/crates/tera_core/src/runtime/mod.rs": {
- "bytes": 7689,
- "git_blob": "e3864dcbc2c94b165fef2de600dc58304fce0654",
+ "bytes": 7958,
+ "git_blob": "2c7b83e791f679bd8b42a0bc74ea5dd8b21c902c",
"mode": "100644",
- "sha256": "b5d852f6c0afc97284fd322c8f1b1214533ea8e3df05fe550aaa9eaac64a0d12"
+ "sha256": "1460ac137204de4cecc3b01eb2a41142b7bd9730f5a6e8a83d2dd84cb0817ec1"
+ },
+ "core/crates/tera_core/src/runtime/mutation_admission.rs": {
+ "bytes": 2148,
+ "git_blob": "93952c82b83f05e019a994b086002ca0e3be3733",
+ "mode": "100644",
+ "sha256": "dff55e46521c26f5f0ece024453b55c15f132fa94f78e2cba3d141f123d4eca0"
},
"core/crates/tera_core/src/runtime/product_surface.rs": {
"bytes": 6019,
@@ -663,10 +669,16 @@
"sha256": "86271be5ec0ae8ef6906752272f5e091415b0581ed717809f50a973919275a1e"
},
"core/crates/tera_core/src/runtime/product_surface/outbox.rs": {
- "bytes": 163082,
- "git_blob": "48d3502d1cc1e885ba42a071cf83fa927f99fdfc",
+ "bytes": 169297,
+ "git_blob": "6daf3a457d8b6c778da035d929ba37255aef310a",
+ "mode": "100644",
+ "sha256": "6de6f9c86c1336b331ea652fe767eb6936fde126c2dee8365d2eceef931b8175"
+ },
+ "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs": {
+ "bytes": 10886,
+ "git_blob": "72891344789c379cdbe17ea97487afdfc3f62373",
"mode": "100644",
- "sha256": "297975a76245389c1b799cd9eb4c9d773d5ddd75b9c8faa07b06d6a866efeec9"
+ "sha256": "f532beab6e57b127ea644f65171fe93d617f3d584950c7322847407334aaf8dc"
},
"core/crates/tera_core/src/runtime/product_surface/projection.rs": {
"bytes": 23469,
@@ -687,10 +699,10 @@
"sha256": "18a82705b14e35ac716f4e9ae4ac39545c7ec9a8a4ded898e8c8febfa054938b"
},
"core/crates/tera_core/src/runtime/product_surface/today.rs": {
- "bytes": 134211,
- "git_blob": "03554393477cf579708bcacfe60286c058ce68a3",
+ "bytes": 134254,
+ "git_blob": "233c943ee7c79af245e2540c35750aa719585870",
"mode": "100644",
- "sha256": "bb54b4559249028ad9631f0af6a7db41a5f6062211f7f3691969da37fbc8e20e"
+ "sha256": "dcd7884d8fbda59e439551110abff68cc4069a5aae6cdc5376d57367afcd7a70"
},
"core/crates/tera_core/src/runtime/sdk.rs": {
"bytes": 20071,
@@ -741,10 +753,10 @@
"sha256": "0f19070bd3beb9dbbc871ceca44c30e590ea2d38e875339fe9dbcde749b3aa6e"
},
"core/crates/tera_ffi/src/error.rs": {
- "bytes": 18357,
- "git_blob": "5c3d8dcf8be230c033c5c9e59bc99c9d3938c986",
+ "bytes": 19532,
+ "git_blob": "5efd4b1d83573a9d458b595074d28ba78e94e036",
"mode": "100644",
- "sha256": "b9385c0bc9ca913f224efa220db65b2e78867f9a7c7afed1c7d1041300a956e6"
+ "sha256": "94298868ee6b7548b83fac0a12b7637eb9e94963168bf654e98f7c3ade2fa12f"
},
"core/crates/tera_ffi/src/lib.rs": {
"bytes": 1334,
@@ -1185,13 +1197,13 @@
"sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d"
},
"test-fixtures/legacy-identifiers.v1.json": {
- "bytes": 110621,
- "git_blob": "14d5342029968785335a2466d25832fcc8b85e9d",
+ "bytes": 112691,
+ "git_blob": "c182eb3f94b158ed66926f7216b994434697a0f8",
"mode": "100644",
- "sha256": "e45baeb16a4b77dd90962651ee9b8398040e3685b38feb298b8b5d5fdc437d67"
+ "sha256": "28f66e949f0d7335a47ff7799e077a985e5450bdd4fe0315ecf757e52b00bc32"
}
},
"policy": "staged_inputs",
- "tree": "ee3a2c5daa71f0fb972496b44ca3be606a887c16"
+ "tree": "f6bfeb00965c4cf264b5f74e488427092965762f"
}
}
diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json
@@ -609,10 +609,16 @@
"sha256": "1b0e54b4ba895b5d4f849c28abb09fb9fd4d9e8caf2a968403c310ab6da6a7cc"
},
"core/crates/tera_core/src/runtime/mod.rs": {
- "bytes": 7689,
- "git_blob": "e3864dcbc2c94b165fef2de600dc58304fce0654",
+ "bytes": 7958,
+ "git_blob": "2c7b83e791f679bd8b42a0bc74ea5dd8b21c902c",
"mode": "100644",
- "sha256": "b5d852f6c0afc97284fd322c8f1b1214533ea8e3df05fe550aaa9eaac64a0d12"
+ "sha256": "1460ac137204de4cecc3b01eb2a41142b7bd9730f5a6e8a83d2dd84cb0817ec1"
+ },
+ "core/crates/tera_core/src/runtime/mutation_admission.rs": {
+ "bytes": 2148,
+ "git_blob": "93952c82b83f05e019a994b086002ca0e3be3733",
+ "mode": "100644",
+ "sha256": "dff55e46521c26f5f0ece024453b55c15f132fa94f78e2cba3d141f123d4eca0"
},
"core/crates/tera_core/src/runtime/product_surface.rs": {
"bytes": 6019,
@@ -663,10 +669,16 @@
"sha256": "86271be5ec0ae8ef6906752272f5e091415b0581ed717809f50a973919275a1e"
},
"core/crates/tera_core/src/runtime/product_surface/outbox.rs": {
- "bytes": 163082,
- "git_blob": "48d3502d1cc1e885ba42a071cf83fa927f99fdfc",
+ "bytes": 169297,
+ "git_blob": "6daf3a457d8b6c778da035d929ba37255aef310a",
+ "mode": "100644",
+ "sha256": "6de6f9c86c1336b331ea652fe767eb6936fde126c2dee8365d2eceef931b8175"
+ },
+ "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs": {
+ "bytes": 10886,
+ "git_blob": "72891344789c379cdbe17ea97487afdfc3f62373",
"mode": "100644",
- "sha256": "297975a76245389c1b799cd9eb4c9d773d5ddd75b9c8faa07b06d6a866efeec9"
+ "sha256": "f532beab6e57b127ea644f65171fe93d617f3d584950c7322847407334aaf8dc"
},
"core/crates/tera_core/src/runtime/product_surface/projection.rs": {
"bytes": 23469,
@@ -687,10 +699,10 @@
"sha256": "18a82705b14e35ac716f4e9ae4ac39545c7ec9a8a4ded898e8c8febfa054938b"
},
"core/crates/tera_core/src/runtime/product_surface/today.rs": {
- "bytes": 134211,
- "git_blob": "03554393477cf579708bcacfe60286c058ce68a3",
+ "bytes": 134254,
+ "git_blob": "233c943ee7c79af245e2540c35750aa719585870",
"mode": "100644",
- "sha256": "bb54b4559249028ad9631f0af6a7db41a5f6062211f7f3691969da37fbc8e20e"
+ "sha256": "dcd7884d8fbda59e439551110abff68cc4069a5aae6cdc5376d57367afcd7a70"
},
"core/crates/tera_core/src/runtime/sdk.rs": {
"bytes": 20071,
@@ -741,10 +753,10 @@
"sha256": "0f19070bd3beb9dbbc871ceca44c30e590ea2d38e875339fe9dbcde749b3aa6e"
},
"core/crates/tera_ffi/src/error.rs": {
- "bytes": 18357,
- "git_blob": "5c3d8dcf8be230c033c5c9e59bc99c9d3938c986",
+ "bytes": 19532,
+ "git_blob": "5efd4b1d83573a9d458b595074d28ba78e94e036",
"mode": "100644",
- "sha256": "b9385c0bc9ca913f224efa220db65b2e78867f9a7c7afed1c7d1041300a956e6"
+ "sha256": "94298868ee6b7548b83fac0a12b7637eb9e94963168bf654e98f7c3ade2fa12f"
},
"core/crates/tera_ffi/src/lib.rs": {
"bytes": 1334,
@@ -1185,13 +1197,13 @@
"sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d"
},
"test-fixtures/legacy-identifiers.v1.json": {
- "bytes": 110621,
- "git_blob": "14d5342029968785335a2466d25832fcc8b85e9d",
+ "bytes": 112691,
+ "git_blob": "c182eb3f94b158ed66926f7216b994434697a0f8",
"mode": "100644",
- "sha256": "e45baeb16a4b77dd90962651ee9b8398040e3685b38feb298b8b5d5fdc437d67"
+ "sha256": "28f66e949f0d7335a47ff7799e077a985e5450bdd4fe0315ecf757e52b00bc32"
}
},
"policy": "staged_inputs",
- "tree": "ee3a2c5daa71f0fb972496b44ca3be606a887c16"
+ "tree": "f6bfeb00965c4cf264b5f74e488427092965762f"
}
}
diff --git a/TeraTests/TeraAddStoreTests.swift b/TeraTests/TeraAddStoreTests.swift
@@ -845,6 +845,7 @@ private enum AddDelayPhase: String, CaseIterable {
}
private actor AddBackend: TeraRuntimeBackend {
+ private let savePause: ResourceTestPause?
private let advanceOffline: Bool
private let saveFailure: TeraRuntimeFailure?
private let includeWritableRelay: Bool
@@ -860,6 +861,7 @@ private actor AddBackend: TeraRuntimeBackend {
private var closed = false
init(
+ savePause: ResourceTestPause? = nil,
advanceOffline: Bool = false,
saveFailure: TeraRuntimeFailure? = nil,
includeWritableRelay: Bool = true,
@@ -868,6 +870,7 @@ private actor AddBackend: TeraRuntimeBackend {
schemas: [TeraAddSchema] = AddBackend.schemas()
) {
self.advanceOffline = advanceOffline
+ self.savePause = savePause
self.saveFailure = saveFailure
self.includeWritableRelay = includeWritableRelay
self.delayedPhase = delayedPhase
@@ -931,103 +934,12 @@ private actor AddBackend: TeraRuntimeBackend {
schemaInventory
}
- nonisolated static func schemas() -> [TeraAddSchema] {
- let field = {
- (
- id: String,
- label: String,
- kind: TeraAddFieldKind,
- required: Bool,
- choices: [String],
- maxBytes: UInt64?,
- maxItems: UInt16?
- ) in
- TeraAddField(
- schemaVersion: 1,
- id: id,
- label: label,
- kind: kind,
- required: required,
- choices: choices,
- maxBytes: maxBytes,
- maxItems: maxItems
- )
- }
- let text = {
- (id: String, label: String, kind: TeraAddFieldKind, required: Bool, maximum: UInt64?) in
- field(id, label, kind, required, [], maximum, nil)
- }
- let media = { (required: Bool, maximum: UInt16) in
- field("media", "Photos", .media, required, [], 10 * 1024 * 1024, maximum)
- }
- return [
- TeraAddSchema(
- schemaVersion: 1,
- commandType: .createUpdate,
- label: "Update",
- fields: [text("content", "Update", .multilineText, true, 65535)]
- ),
- TeraAddSchema(
- schemaVersion: 1,
- commandType: .createPhotoUpdate,
- label: "Photo update",
- fields: [
- text("content", "Update", .multilineText, true, 65535),
- media(true, 20),
- ]
- ),
- TeraAddSchema(
- schemaVersion: 1,
- commandType: .createAsk,
- label: "Ask",
- fields: [
- text("content", "Question", .multilineText, true, 65535),
- media(false, 20),
- ]
- ),
- TeraAddSchema(
- schemaVersion: 1,
- commandType: .createEvent,
- label: "Event",
- fields: [
- text("identifier", "Identifier", .text, true, 256),
- text("title", "Title", .text, true, 256),
- text("content", "Description", .multilineText, false, 65535),
- text("event_start", "Starts", .dateTime, true, nil),
- text("event_end", "Ends", .dateTime, false, nil),
- text("location", "Location", .location, false, 256),
- media(false, 1),
- ]
- ),
- TeraAddSchema(
- schemaVersion: 1,
- commandType: .createFoodAvailability,
- label: "Food availability",
- fields: [
- text("identifier", "Identifier", .text, true, 256),
- text("title", "Food", .text, true, 256),
- text("summary", "Summary", .text, true, 256),
- text("content", "Details", .multilineText, true, 65535),
- text("location", "Pickup location", .location, true, 256),
- text("price_amount", "Price", .decimal, true, 64),
- field("currency", "Currency", .choice, true, [], 3, nil),
- field(
- "unit", "Unit", .choice, true,
- ["g", "kg", "lb", "oz", "each", "dozen", "bunch", "punnet", "bag", "basket"],
- nil, nil
- ),
- text("quantity", "Available quantity", .decimal, false, 64),
- media(false, 20),
- ]
- ),
- ]
- }
-
func saveAddIntent(
input: TeraAddRuntimeInput,
existingDraftID: String?,
expectedRevision: UInt64?
) async throws -> TeraDraftStatus {
+ await savePause?.wait()
if let saveFailure {
throw saveFailure
}
@@ -1464,321 +1376,153 @@ private actor AddSubscriptionToken: TeraRuntimeSubscriptionToken {
func cancel() {}
}
-private final class BackgroundUploadFixture: @unchecked Sendable {
- let draftID = String(repeating: "1", count: 32)
- let media: TeraPreparedMedia
- private let root: URL
- private let roots: RadrootsAppleFileRoots
-
- init() throws {
- let bytes = Data("radroots-background-upload".utf8)
- let digest = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined()
- root = FileManager.default.temporaryDirectory
- .appendingPathComponent("radroots-background-tests-\(UUID().uuidString)", isDirectory: true)
- roots = try RadrootsAppleFileRoots(
- appIdentifier: "org.radroots.background-tests",
- dataRoot: root.appendingPathComponent("data", isDirectory: true),
- cacheRoot: root.appendingPathComponent("cache", isDirectory: true),
- temporaryRoot: root.appendingPathComponent("temporary", isDirectory: true)
- )
- try FileManager.default.createDirectory(
- at: roots.stagedBlobsRoot,
- withIntermediateDirectories: true
- )
- try bytes.write(to: roots.stagedBlobsRoot.appendingPathComponent(digest))
- media = TeraPreparedMedia(
- opaqueReference: "media:\(digest)",
- remoteURL: "http://127.0.0.1:3000/\(digest).png",
- sha256: digest,
- mediaType: "image/png",
- byteSize: UInt64(bytes.count),
- width: 2,
- height: 2,
- alt: "Background upload",
- preparedAtUnixSeconds: 1_800_000_000
- )
- }
-
- func remove() {
- try? FileManager.default.removeItem(at: root)
- }
-
- func coordinator(transfer: any RadrootsBackgroundTransfer) -> TeraAddMediaCoordinator {
- TeraAddMediaCoordinator(
- roots: roots,
- picker: BackgroundMediaPicker(),
- preparer: RadrootsAppleMediaPreparer(roots: roots),
- transfer: transfer
- )
- }
-
- func job(revision: UInt64, operation: String) -> TeraNativeUploadJob {
- TeraNativeUploadJob(
- operationID: operation,
- draft: draft(revision: revision, stage: .uploading),
- remoteURL: media.remoteURL!,
- authorizationHeader: "Nostr test-authorization",
- expectedSHA256: media.sha256,
- mediaType: media.mediaType,
- byteSize: media.byteSize
- )
- }
-
- func draft(revision: UInt64, stage: TeraDraftMediaStage) -> TeraDraftStatus {
- var form = TeraAddForm.empty(.createPhotoUpdate)
- form.content = "Background transfer"
- form.media = [media]
- return TeraDraftStatus(
- id: draftID,
- revision: revision,
- authorPublicKey: String(repeating: "a", count: 64),
- kind: .add,
- commandType: .createPhotoUpdate,
- form: form,
- state: stage == .verified ? .readyToSign : .mediaUploading,
- cardID: String(repeating: "c", count: 64),
- operationID: String(repeating: "d", count: 32),
- createdAtUnixMilliseconds: 1_800_000_000_000,
- updatedAtUnixMilliseconds: 1_800_000_000_001,
- media: [
- TeraDraftMediaStatus(
- url: media.remoteURL!,
- stage: stage,
- uploadAttempts: stage == .verified ? 1 : 0,
- verifiedAtUnixMilliseconds: stage == .verified ? 1_800_000_000_001 : nil,
- possibleOrphan: false,
- orphanReasonCode: nil,
- orphanRecordedAtUnixMilliseconds: nil
- ),
- ],
- settlement: nil,
- isRevision: false
- )
- }
-
- func request(
- job: TeraNativeUploadJob,
- remoteURL: String? = nil
- ) throws -> RadrootsBackgroundTransferRequest {
- let blob = try RadrootsStagedBlobReference(
- blobID: media.sha256,
- sizeBytes: Int(media.byteSize),
- mediaType: media.mediaType,
- filenameHint: "\(media.sha256).png"
- )
- return try RadrootsBackgroundTransferRequest(
- identifier: RadrootsBackgroundTransferIdentifier(job.transferIdentifier),
- remoteURL: URL(string: remoteURL ?? job.remoteURL)!,
- method: .put,
- operation: .upload(source: .stagedBlob(blob)),
- headers: [:],
- metadata: [:],
- networkPolicy: .simulatorLoopbackHTTP,
- responsePolicy: .boundedJSON(),
- expectedSourceSHA256: media.sha256
- )
- }
-}
-
-extension TeraNativeUploadJob {
- fileprivate var transferIdentifier: String {
- "radroots.add.\(draft.id).\(draft.revision).\(operationID)"
- }
-}
-
-private struct BackgroundMediaPicker: RadrootsMediaPicker {
- func currentSupport() async throws -> RadrootsMediaPickerSupport {
- try RadrootsMediaPickerSupport(
- importAvailable: false,
- cameraCaptureAvailable: false,
- supportedImportKinds: [],
- supportedCaptureKinds: [],
- multipleSelectionSupported: false
- )
- }
-
- func importMedia(_: RadrootsMediaImportRequest) async throws -> RadrootsMediaImportResult {
- throw RadrootsCaptureIntakeError.unavailable
- }
-
- func captureMedia(_: RadrootsMediaCaptureRequest) async throws -> RadrootsMediaCaptureResult {
- throw RadrootsCaptureIntakeError.unavailable
- }
-}
-
-private actor BackgroundTransferHarness: RadrootsBackgroundTransfer {
- private var values: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot] =
- [:]
- private let enqueueState: RadrootsBackgroundTransferState
- private let pause: BackgroundTransferPause
- private var pauseReleased: Bool
- private(set) var isPaused = false
- private(set) var enqueueCount = 0
- private(set) var retryCount = 0
- private(set) var cancelCount = 0
- private(set) var acceptedSettlementCount = 0
- private(set) var snapshotCount = 0
-
- init(
- enqueueState: RadrootsBackgroundTransferState = .awaitingVerification,
- pause: BackgroundTransferPause = .none
- ) {
- self.enqueueState = enqueueState
- self.pause = pause
- pauseReleased = pause == .none
- }
-
- var state: RadrootsBackgroundTransferState? {
- values.values.first?.state
- }
-
- var counts: (enqueue: Int, retry: Int, cancel: Int, acceptedSettlement: Int) {
- (enqueueCount, retryCount, cancelCount, acceptedSettlementCount)
- }
-
- func seed(
- request: RadrootsBackgroundTransferRequest,
- state: RadrootsBackgroundTransferState
- ) throws {
- values[request.identifier] = try snapshot(request: request, state: state)
- }
-
- func setState(_ state: RadrootsBackgroundTransferState) throws {
- for (identifier, value) in values {
- values[identifier] = try snapshot(request: value.request, state: state)
- }
- }
-
- func removeAll() {
- values.removeAll()
- }
-
- func releasePause() {
- pauseReleased = true
- }
-
- func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws
- -> RadrootsBackgroundTransferHandle
- {
- enqueueCount += 1
- values[request.identifier] = try snapshot(
- request: persisted(request),
- state: enqueueState
- )
- return RadrootsBackgroundTransferHandle(request: request)
- }
-
- func retry(_ request: RadrootsBackgroundTransferRequest) async throws
- -> RadrootsBackgroundTransferHandle
- {
- retryCount += 1
- values[request.identifier] = try snapshot(
- request: persisted(request),
- state: .awaitingVerification
- )
- return RadrootsBackgroundTransferHandle(request: request)
- }
-
- func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
- cancelCount += 1
- if let value = values[identifier] {
- values[identifier] = try snapshot(request: value.request, state: .cancelled)
- }
- }
-
- func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
- if let value = values[identifier] {
- values[identifier] = try snapshot(request: value.request, state: .expired)
+private extension AddBackend {
+ nonisolated static func schemas() -> [TeraAddSchema] {
+ let field = {
+ (
+ id: String,
+ label: String,
+ kind: TeraAddFieldKind,
+ required: Bool,
+ choices: [String],
+ maxBytes: UInt64?,
+ maxItems: UInt16?
+ ) in
+ TeraAddField(
+ schemaVersion: 1,
+ id: id,
+ label: label,
+ kind: kind,
+ required: required,
+ choices: choices,
+ maxBytes: maxBytes,
+ maxItems: maxItems
+ )
}
- }
-
- func settle(
- _ identifier: RadrootsBackgroundTransferIdentifier,
- verification: RadrootsBackgroundTransferVerification
- ) async throws {
- guard let value = values[identifier] else {
- throw RadrootsBackgroundTransferError.transferFailure
+ let text = {
+ (id: String, label: String, kind: TeraAddFieldKind, required: Bool, maximum: UInt64?) in
+ field(id, label, kind, required, [], maximum, nil)
}
- switch verification {
- case .accepted:
- acceptedSettlementCount += 1
- values[identifier] = try snapshot(request: value.request, state: .completed)
- case let .rejected(failure):
- values[identifier] = try RadrootsBackgroundTransferSnapshot(
- request: value.request,
- state: .failed,
- failure: failure
- )
+ let media = { (required: Bool, maximum: UInt16) in
+ field("media", "Photos", .media, required, [], 10 * 1024 * 1024, maximum)
}
+ return [
+ TeraAddSchema(
+ schemaVersion: 1,
+ commandType: .createUpdate,
+ label: "Update",
+ fields: [text("content", "Update", .multilineText, true, 65535)]
+ ),
+ TeraAddSchema(
+ schemaVersion: 1,
+ commandType: .createPhotoUpdate,
+ label: "Photo update",
+ fields: [
+ text("content", "Update", .multilineText, true, 65535),
+ media(true, 20),
+ ]
+ ),
+ TeraAddSchema(
+ schemaVersion: 1,
+ commandType: .createAsk,
+ label: "Ask",
+ fields: [
+ text("content", "Question", .multilineText, true, 65535),
+ media(false, 20),
+ ]
+ ),
+ TeraAddSchema(
+ schemaVersion: 1,
+ commandType: .createEvent,
+ label: "Event",
+ fields: [
+ text("identifier", "Identifier", .text, true, 256),
+ text("title", "Title", .text, true, 256),
+ text("content", "Description", .multilineText, false, 65535),
+ text("event_start", "Starts", .dateTime, true, nil),
+ text("event_end", "Ends", .dateTime, false, nil),
+ text("location", "Location", .location, false, 256),
+ media(false, 1),
+ ]
+ ),
+ TeraAddSchema(
+ schemaVersion: 1,
+ commandType: .createFoodAvailability,
+ label: "Food availability",
+ fields: [
+ text("identifier", "Identifier", .text, true, 256),
+ text("title", "Food", .text, true, 256),
+ text("summary", "Summary", .text, true, 256),
+ text("content", "Details", .multilineText, true, 65535),
+ text("location", "Pickup location", .location, true, 256),
+ text("price_amount", "Price", .decimal, true, 64),
+ field("currency", "Currency", .choice, true, [], 3, nil),
+ field(
+ "unit", "Unit", .choice, true,
+ ["g", "kg", "lb", "oz", "each", "dozen", "bunch", "punnet", "bag", "basket"],
+ nil, nil
+ ),
+ text("quantity", "Available quantity", .decimal, false, 64),
+ media(false, 20),
+ ]
+ ),
+ ]
}
+}
- func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
- -> RadrootsBackgroundTransferSnapshot?
- {
- snapshotCount += 1
- try await waitIfPaused(at: .snapshot)
- return values[identifier]
- }
-
- func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
- try await waitIfPaused(at: .discovery)
- return values.values.sorted { $0.identifier < $1.identifier }
- }
-
- func handleEventsForBackgroundURLSession(
- identifier _: String,
- completionHandler: @escaping @Sendable () -> Void
- ) async {
- completionHandler()
- }
-
- private func persisted(
- _ request: RadrootsBackgroundTransferRequest
- ) throws -> RadrootsBackgroundTransferRequest {
- try RadrootsBackgroundTransferRequest(
- identifier: request.identifier,
- remoteURL: request.remoteURL,
- method: request.method,
- operation: request.operation,
- headers: [:],
- metadata: [:],
- networkPolicy: request.networkPolicy,
- responsePolicy: request.responsePolicy,
- expectedSourceSHA256: request.expectedSourceSHA256,
- maximumTransferBytes: request.maximumTransferBytes
- )
+extension TeraAddStoreTests {
+ @MainActor
+ func testMutationAdmissionKeepsOneAddOperationBeforeItsBackendWait() async throws {
+ let pause = ResourceTestPause()
+ let backend = AddBackend(savePause: pause)
+ let client = try await Self.startedClient(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ await store.configure(snapshot: backend.snapshot())
+ await store.start()
+ store.updateForm(\.content, "One admitted draft")
+ let owner = Task { await store.save() }
+ await admissionPauseEntered(pause)
+ XCTAssertTrue(store.isWorking)
+ await store.save()
+ await store.submit()
+ XCTAssertTrue(store.isWorking)
+ XCTAssertNil(store.activeDraft)
+ await pause.resume.open()
+ await owner.value
+ XCTAssertEqual(store.drafts.count, 1)
+ XCTAssertEqual(store.activeDraft?.revision, 1)
+ XCTAssertEqual(store.activeDraft?.state, .draft)
+ XCTAssertFalse(store.isWorking)
+ _ = try await client.stop()
}
- private func snapshot(
- request: RadrootsBackgroundTransferRequest,
- state: RadrootsBackgroundTransferState
- ) throws -> RadrootsBackgroundTransferSnapshot {
- try RadrootsBackgroundTransferSnapshot(
- request: request,
- state: state,
- response: [.awaitingVerification, .completed].contains(state)
- ? RadrootsBackgroundTransferResponse(
- statusCode: 200,
- mediaType: "application/json",
- body: Data("{}".utf8)
- ) : nil,
- possibleRemoteOrphan: false,
- updatedAt: Date(timeIntervalSince1970: 1_800_000_000)
- )
+ @MainActor
+ func testMutationAdmissionIgnoresAnAddCallerCancelledWhileQueued() async throws {
+ let backend = AddBackend()
+ let client = try await Self.startedClient(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ await store.configure(snapshot: backend.snapshot())
+ await store.start()
+ store.updateForm(\.content, "Only save after explicit admission")
+ let pause = ResourceTestPause()
+ let queued = Task { await pause.wait(); await store.save() }
+ await admissionPauseEntered(pause)
+ queued.cancel()
+ await pause.resume.open()
+ await queued.value
+ XCTAssertTrue(store.drafts.isEmpty)
+ XCTAssertFalse(store.isWorking)
+ await store.save()
+ XCTAssertEqual(store.drafts.count, 1)
+ _ = try await client.stop()
}
- private func waitIfPaused(at point: BackgroundTransferPause) async throws {
- guard pause == point, !pauseReleased else { return }
- isPaused = true
- defer { isPaused = false }
- while !pauseReleased {
- try await Task.sleep(nanoseconds: 1_000_000)
- }
+ @MainActor
+ private func admissionPauseEntered(_ pause: ResourceTestPause) async {
+ let entered = expectation(description: "Entered the explicit Add pause")
+ let observer = Task { await pause.entered.wait(); entered.fulfill() }
+ await fulfillment(of: [entered], timeout: 2)
+ await pause.entered.open()
+ await observer.value
}
}
-
-private enum BackgroundTransferPause: Sendable {
- case none
- case discovery
- case snapshot
-}
diff --git a/TeraTests/TeraBackgroundUploadFixtures.swift b/TeraTests/TeraBackgroundUploadFixtures.swift
@@ -0,0 +1,360 @@
+import CryptoKit
+import RadrootsKit
+@testable import TeraApp
+import XCTest
+
+final class BackgroundUploadFixture: @unchecked Sendable {
+ let draftID: String
+ let media: TeraPreparedMedia
+ private let root: URL
+ private let roots: RadrootsAppleFileRoots
+
+ init(draftID: String = String(repeating: "1", count: 32)) throws {
+ self.draftID = draftID
+ let bytes = Data("radroots-background-upload".utf8)
+ let digest = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined()
+ root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("radroots-background-tests-\(UUID().uuidString)", isDirectory: true)
+ roots = try RadrootsAppleFileRoots(
+ appIdentifier: "org.radroots.background-tests",
+ dataRoot: root.appendingPathComponent("data", isDirectory: true),
+ cacheRoot: root.appendingPathComponent("cache", isDirectory: true),
+ temporaryRoot: root.appendingPathComponent("temporary", isDirectory: true)
+ )
+ try FileManager.default.createDirectory(
+ at: roots.stagedBlobsRoot,
+ withIntermediateDirectories: true
+ )
+ try bytes.write(to: roots.stagedBlobsRoot.appendingPathComponent(digest))
+ media = TeraPreparedMedia(
+ opaqueReference: "media:\(digest)",
+ remoteURL: "http://127.0.0.1:3000/\(digest).png",
+ sha256: digest,
+ mediaType: "image/png",
+ byteSize: UInt64(bytes.count),
+ width: 2,
+ height: 2,
+ alt: "Background upload",
+ preparedAtUnixSeconds: 1_800_000_000
+ )
+ }
+
+ func remove() {
+ try? FileManager.default.removeItem(at: root)
+ }
+
+ func coordinator(transfer: any RadrootsBackgroundTransfer) -> TeraAddMediaCoordinator {
+ TeraAddMediaCoordinator(
+ roots: roots,
+ picker: BackgroundMediaPicker(),
+ preparer: RadrootsAppleMediaPreparer(roots: roots),
+ transfer: transfer
+ )
+ }
+
+ func job(revision: UInt64, operation: String) -> TeraNativeUploadJob {
+ TeraNativeUploadJob(
+ operationID: operation,
+ draft: draft(revision: revision, stage: .uploading),
+ remoteURL: media.remoteURL!,
+ authorizationHeader: "Nostr test-authorization",
+ expectedSHA256: media.sha256,
+ mediaType: media.mediaType,
+ byteSize: media.byteSize
+ )
+ }
+
+ func draft(revision: UInt64, stage: TeraDraftMediaStage) -> TeraDraftStatus {
+ var form = TeraAddForm.empty(.createPhotoUpdate)
+ form.content = "Background transfer"
+ form.media = [media]
+ return TeraDraftStatus(
+ id: draftID,
+ revision: revision,
+ authorPublicKey: String(repeating: "a", count: 64),
+ kind: .add,
+ commandType: .createPhotoUpdate,
+ form: form,
+ state: stage == .verified ? .readyToSign : .mediaUploading,
+ cardID: String(repeating: "c", count: 64),
+ operationID: String(repeating: "d", count: 32),
+ createdAtUnixMilliseconds: 1_800_000_000_000,
+ updatedAtUnixMilliseconds: 1_800_000_000_001,
+ media: [
+ TeraDraftMediaStatus(
+ url: media.remoteURL!,
+ stage: stage,
+ uploadAttempts: stage == .verified ? 1 : 0,
+ verifiedAtUnixMilliseconds: stage == .verified ? 1_800_000_000_001 : nil,
+ possibleOrphan: false,
+ orphanReasonCode: nil,
+ orphanRecordedAtUnixMilliseconds: nil
+ ),
+ ],
+ settlement: nil,
+ isRevision: false
+ )
+ }
+
+ func request(
+ job: TeraNativeUploadJob,
+ remoteURL: String? = nil
+ ) throws -> RadrootsBackgroundTransferRequest {
+ let blob = try RadrootsStagedBlobReference(
+ blobID: media.sha256,
+ sizeBytes: Int(media.byteSize),
+ mediaType: media.mediaType,
+ filenameHint: "\(media.sha256).png"
+ )
+ return try RadrootsBackgroundTransferRequest(
+ identifier: RadrootsBackgroundTransferIdentifier(job.transferIdentifier),
+ remoteURL: URL(string: remoteURL ?? job.remoteURL)!,
+ method: .put,
+ operation: .upload(source: .stagedBlob(blob)),
+ headers: [:],
+ metadata: [:],
+ networkPolicy: .simulatorLoopbackHTTP,
+ responsePolicy: .boundedJSON(),
+ expectedSourceSHA256: media.sha256
+ )
+ }
+}
+
+extension TeraNativeUploadJob {
+ var transferIdentifier: String {
+ "radroots.add.\(draft.id).\(draft.revision).\(operationID)"
+ }
+}
+
+struct BackgroundMediaPicker: RadrootsMediaPicker {
+ func currentSupport() async throws -> RadrootsMediaPickerSupport {
+ try RadrootsMediaPickerSupport(
+ importAvailable: false,
+ cameraCaptureAvailable: false,
+ supportedImportKinds: [],
+ supportedCaptureKinds: [],
+ multipleSelectionSupported: false
+ )
+ }
+
+ func importMedia(_: RadrootsMediaImportRequest) async throws -> RadrootsMediaImportResult {
+ throw RadrootsCaptureIntakeError.unavailable
+ }
+
+ func captureMedia(_: RadrootsMediaCaptureRequest) async throws -> RadrootsMediaCaptureResult {
+ throw RadrootsCaptureIntakeError.unavailable
+ }
+}
+
+actor BackgroundTransferHarness: RadrootsBackgroundTransfer {
+ private var discoveryPause: ResourceTestPause?
+ private var retryPause: ResourceTestPause?
+ private var discoveryCallback: (@Sendable () async -> Void)?
+ private(set) var discoveryCount = 0
+ private var values: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot] =
+ [:]
+ private let enqueueState: RadrootsBackgroundTransferState
+ private let pause: BackgroundTransferPause
+ private var pauseReleased: Bool
+ private(set) var isPaused = false
+ private(set) var enqueueCount = 0
+ private(set) var retryCount = 0
+ private(set) var cancelCount = 0
+ private(set) var acceptedSettlementCount = 0
+ private(set) var snapshotCount = 0
+
+ init(
+ enqueueState: RadrootsBackgroundTransferState = .awaitingVerification,
+ pause: BackgroundTransferPause = .none
+ ) {
+ self.enqueueState = enqueueState
+ self.pause = pause
+ pauseReleased = pause == .none
+ }
+
+ var state: RadrootsBackgroundTransferState? {
+ values.values.first?.state
+ }
+
+ func pauseDiscovery(_ pause: ResourceTestPause) {
+ discoveryPause = pause
+ }
+
+ func pauseRetry(_ pause: ResourceTestPause) {
+ retryPause = pause
+ }
+
+ func onDiscovery(_ callback: @escaping @Sendable () async -> Void) {
+ discoveryCallback = callback
+ }
+
+ var counts: BackgroundTransferCounts {
+ BackgroundTransferCounts(
+ enqueue: enqueueCount, retry: retryCount, cancel: cancelCount,
+ acceptedSettlement: acceptedSettlementCount
+ )
+ }
+
+ func seed(
+ request: RadrootsBackgroundTransferRequest,
+ state: RadrootsBackgroundTransferState
+ ) throws {
+ values[request.identifier] = try snapshot(request: request, state: state)
+ }
+
+ func setState(_ state: RadrootsBackgroundTransferState) throws {
+ for (identifier, value) in values {
+ values[identifier] = try snapshot(request: value.request, state: state)
+ }
+ }
+
+ func removeAll() {
+ values.removeAll()
+ }
+
+ func releasePause() {
+ pauseReleased = true
+ }
+
+ func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws
+ -> RadrootsBackgroundTransferHandle
+ {
+ enqueueCount += 1
+ values[request.identifier] = try snapshot(
+ request: persisted(request),
+ state: enqueueState
+ )
+ return RadrootsBackgroundTransferHandle(request: request)
+ }
+
+ func retry(_ request: RadrootsBackgroundTransferRequest) async throws
+ -> RadrootsBackgroundTransferHandle
+ {
+ retryCount += 1
+ let admissionPause = retryPause
+ retryPause = nil
+ await admissionPause?.wait()
+ values[request.identifier] = try snapshot(
+ request: persisted(request),
+ state: .awaitingVerification
+ )
+ return RadrootsBackgroundTransferHandle(request: request)
+ }
+
+ func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ cancelCount += 1
+ if let value = values[identifier] {
+ values[identifier] = try snapshot(request: value.request, state: .cancelled)
+ }
+ }
+
+ func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ if let value = values[identifier] {
+ values[identifier] = try snapshot(request: value.request, state: .expired)
+ }
+ }
+
+ func settle(
+ _ identifier: RadrootsBackgroundTransferIdentifier,
+ verification: RadrootsBackgroundTransferVerification
+ ) async throws {
+ guard let value = values[identifier] else {
+ throw RadrootsBackgroundTransferError.transferFailure
+ }
+ switch verification {
+ case .accepted:
+ acceptedSettlementCount += 1
+ values[identifier] = try snapshot(request: value.request, state: .completed)
+ case let .rejected(failure):
+ values[identifier] = try RadrootsBackgroundTransferSnapshot(
+ request: value.request,
+ state: .failed,
+ failure: failure
+ )
+ }
+ }
+
+ func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
+ -> RadrootsBackgroundTransferSnapshot?
+ {
+ snapshotCount += 1
+ try await waitIfPaused(at: .snapshot)
+ return values[identifier]
+ }
+
+ func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ discoveryCount += 1
+ let admissionPause = discoveryPause
+ discoveryPause = nil
+ await admissionPause?.wait()
+ let callback = discoveryCallback
+ discoveryCallback = nil
+ await callback?()
+ try await waitIfPaused(at: .discovery)
+ return values.values.sorted { $0.identifier < $1.identifier }
+ }
+
+ func handleEventsForBackgroundURLSession(
+ identifier _: String,
+ completionHandler: @escaping @Sendable () -> Void
+ ) async {
+ completionHandler()
+ }
+
+ private func persisted(
+ _ request: RadrootsBackgroundTransferRequest
+ ) throws -> RadrootsBackgroundTransferRequest {
+ try RadrootsBackgroundTransferRequest(
+ identifier: request.identifier,
+ remoteURL: request.remoteURL,
+ method: request.method,
+ operation: request.operation,
+ headers: [:],
+ metadata: [:],
+ networkPolicy: request.networkPolicy,
+ responsePolicy: request.responsePolicy,
+ expectedSourceSHA256: request.expectedSourceSHA256,
+ maximumTransferBytes: request.maximumTransferBytes
+ )
+ }
+
+ private func snapshot(
+ request: RadrootsBackgroundTransferRequest,
+ state: RadrootsBackgroundTransferState
+ ) throws -> RadrootsBackgroundTransferSnapshot {
+ try RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: state,
+ response: [.awaitingVerification, .completed].contains(state)
+ ? RadrootsBackgroundTransferResponse(
+ statusCode: 200,
+ mediaType: "application/json",
+ body: Data("{}".utf8)
+ ) : nil,
+ possibleRemoteOrphan: false,
+ updatedAt: Date(timeIntervalSince1970: 1_800_000_000)
+ )
+ }
+
+ private func waitIfPaused(at point: BackgroundTransferPause) async throws {
+ guard pause == point, !pauseReleased else { return }
+ isPaused = true
+ defer { isPaused = false }
+ while !pauseReleased {
+ try await Task.sleep(nanoseconds: 1_000_000)
+ }
+ }
+}
+
+enum BackgroundTransferPause: Sendable {
+ case none
+ case discovery
+ case snapshot
+}
+
+struct BackgroundTransferCounts: Sendable {
+ let enqueue: Int
+ let retry: Int
+ let cancel: Int
+ let acceptedSettlement: Int
+}
diff --git a/TeraTests/TeraMutationAdmissionTests.swift b/TeraTests/TeraMutationAdmissionTests.swift
@@ -0,0 +1,216 @@
+import RadrootsKit
+@testable import TeraApp
+import XCTest
+
+@MainActor
+final class TeraMutationAdmissionTests: XCTestCase {
+ func testUploadReservesBeforeDiscoveryAndKeepsOtherDraftsIndependent() async throws {
+ let fixture = try BackgroundUploadFixture()
+ let other = try BackgroundUploadFixture(draftID: String(repeating: "2", count: 32))
+ defer { fixture.remove(); other.remove() }
+ let transfer = BackgroundTransferHarness()
+ let coordinator = fixture.coordinator(transfer: transfer)
+ let pause = ResourceTestPause()
+ await transfer.pauseDiscovery(pause)
+ let job = fixture.job(revision: 2, operation: String(repeating: "a", count: 32))
+ let owner = Task { try await coordinator.uploadInBackground(job: job, media: fixture.media) }
+ await entered(pause)
+ await assertBusy(coordinator, job: job, media: fixture.media)
+ await assertBusy(
+ coordinator,
+ job: fixture.job(revision: 3, operation: String(repeating: "b", count: 32)),
+ media: fixture.media
+ )
+ let independent = try await coordinator.uploadInBackground(
+ job: other.job(revision: 2, operation: String(repeating: "c", count: 32)),
+ media: other.media
+ )
+ XCTAssertEqual(independent.draftID, other.draftID)
+ let discoveries = await transfer.discoveryCount
+ XCTAssertEqual(discoveries, 2)
+ await pause.resume.open()
+ let receipt = try await owner.value
+ XCTAssertEqual(receipt.draftID, fixture.draftID)
+ let counts = await transfer.counts
+ XCTAssertEqual(counts.enqueue, 2)
+ XCTAssertEqual(counts.retry, 0)
+ }
+
+ func testRetryRetainsAdmissionAcrossTheNativeCallback() async throws {
+ let fixture = try BackgroundUploadFixture()
+ defer { fixture.remove() }
+ let transfer = BackgroundTransferHarness()
+ let coordinator = fixture.coordinator(transfer: transfer)
+ let job = fixture.job(revision: 2, operation: String(repeating: "a", count: 32))
+ try await transfer.seed(request: fixture.request(job: job), state: .interrupted)
+ let pause = ResourceTestPause()
+ await transfer.pauseRetry(pause)
+ let owner = Task { try await coordinator.uploadInBackground(job: job, media: fixture.media) }
+ await entered(pause)
+ await assertBusy(coordinator, job: job, media: fixture.media)
+ await pause.resume.open()
+ _ = try await owner.value
+ let counts = await transfer.counts
+ XCTAssertEqual(counts.retry, 1)
+ XCTAssertEqual(counts.enqueue, 0)
+ }
+
+ func testCancellationBeforeAdmissionDoesNotEnterNativeWorkOrRetainTheScope() async throws {
+ let fixture = try BackgroundUploadFixture()
+ defer { fixture.remove() }
+ let transfer = BackgroundTransferHarness()
+ let coordinator = fixture.coordinator(transfer: transfer)
+ let job = fixture.job(revision: 2, operation: String(repeating: "a", count: 32))
+ let queued = ResourceTestPause()
+ let waiter = Task {
+ await queued.wait()
+ return try await coordinator.uploadInBackground(job: job, media: fixture.media)
+ }
+ await entered(queued)
+ waiter.cancel()
+ await queued.resume.open()
+ do {
+ _ = try await waiter.value
+ XCTFail("A cancelled queued caller must not enter native work")
+ } catch is CancellationError {}
+ let discoveries = await transfer.discoveryCount
+ XCTAssertEqual(discoveries, 0)
+ _ = try await coordinator.uploadInBackground(job: job, media: fixture.media)
+ let counts = await transfer.counts
+ XCTAssertEqual(counts.enqueue, 1)
+ }
+
+ func testCancelledOwnerKeepsAdmissionUntilItsNativeWaitReturns() async throws {
+ let fixture = try BackgroundUploadFixture()
+ defer { fixture.remove() }
+ let transfer = BackgroundTransferHarness()
+ let coordinator = fixture.coordinator(transfer: transfer)
+ let job = fixture.job(revision: 2, operation: String(repeating: "a", count: 32))
+ let pause = ResourceTestPause()
+ await transfer.pauseDiscovery(pause)
+ let owner = Task { try await coordinator.uploadInBackground(job: job, media: fixture.media) }
+ await entered(pause)
+ owner.cancel()
+ await assertBusy(coordinator, job: job, media: fixture.media)
+ await pause.resume.open()
+ do {
+ _ = try await owner.value
+ XCTFail("Expected cancellation before enqueue")
+ } catch is CancellationError {}
+ var counts = await transfer.counts
+ XCTAssertEqual(counts.enqueue, 0)
+ _ = try await coordinator.uploadInBackground(job: job, media: fixture.media)
+ counts = await transfer.counts
+ XCTAssertEqual(counts.enqueue, 1)
+ }
+
+ func testReentrantNativeCallbackCannotReadmitTheSameDraft() async throws {
+ let fixture = try BackgroundUploadFixture()
+ defer { fixture.remove() }
+ let transfer = BackgroundTransferHarness()
+ let coordinator = fixture.coordinator(transfer: transfer)
+ let job = fixture.job(revision: 2, operation: String(repeating: "a", count: 32))
+ let callback = expectation(description: "Reentrant callback returned")
+ await transfer.onDiscovery {
+ do {
+ _ = try await coordinator.uploadInBackground(job: job, media: fixture.media)
+ XCTFail("A callback must not acquire its caller's scope")
+ } catch let failure as TeraRuntimeFailure {
+ XCTAssertEqual(failure.code, "operation_in_progress")
+ } catch {
+ XCTFail("Unexpected reentrant failure")
+ }
+ callback.fulfill()
+ }
+ _ = try await coordinator.uploadInBackground(job: job, media: fixture.media)
+ await fulfillment(of: [callback], timeout: 2)
+ let counts = await transfer.counts
+ XCTAssertEqual(counts.enqueue, 1)
+ let discoveries = await transfer.discoveryCount
+ XCTAssertEqual(discoveries, 1)
+ }
+
+ private func assertBusy(
+ _ coordinator: TeraAddMediaCoordinator,
+ job: TeraNativeUploadJob,
+ media: TeraPreparedMedia
+ ) async {
+ do {
+ _ = try await coordinator.uploadInBackground(job: job, media: media)
+ XCTFail("Overlapping calls must preserve the admitted operation")
+ } catch let failure as TeraRuntimeFailure {
+ XCTAssertEqual(failure.code, "operation_in_progress")
+ XCTAssertEqual(failure.category, "operation")
+ XCTAssertTrue(failure.retryable)
+ XCTAssertEqual(failure.recoveryActions, ["retry_operation_with_same_idempotency_key"])
+ } catch {
+ XCTFail("Unexpected admission failure")
+ }
+ }
+
+ private func entered(_ pause: ResourceTestPause) async {
+ let arrived = expectation(description: "Entered the explicit native pause")
+ let observer = Task { await pause.entered.wait(); arrived.fulfill() }
+ await fulfillment(of: [arrived], timeout: 2)
+ await pause.entered.open()
+ await observer.value
+ }
+}
+
+extension TeraMutationAdmissionTests {
+ @MainActor
+ func testProfileAdmissionSurvivesStopUntilTheAdmittedCallReturns() async throws {
+ let configuration = TeraRuntimeClientTests().makeConfiguration(generation: "41")
+ let backend = ResourceTestBackend(publicKeyHex: configuration.publicKeyHex)
+ let client = TeraRuntimeClient(factory: { _ in await backend.start() })
+ _ = try await client.start(configuration: configuration)
+ let store = TeraSettingsStore(runtimeClient: client)
+ store.profileName = "Farm profile"
+ let pause = ResourceTestPause()
+ await backend.pauseProfile(pause)
+ let owner = Task { await store.saveProfile() }
+ await entered(pause)
+ store.profileName = "A conflicting profile update"
+ await store.saveProfile()
+ store.stop()
+ await store.saveProfile()
+ var count = await backend.profileMutations
+ XCTAssertEqual(count, 1)
+ await pause.resume.open()
+ await owner.value
+ XCTAssertNil(store.profileStatus)
+ await store.saveProfile()
+ count = await backend.profileMutations
+ XCTAssertEqual(count, 2)
+ XCTAssertNotNil(store.profileStatus)
+ _ = try await client.stop()
+ }
+
+ @MainActor
+ func testProfileAdvanceRejectsConflictsAndCancelledQueuedCallers() async throws {
+ let configuration = TeraRuntimeClientTests().makeConfiguration(generation: "41")
+ let backend = ResourceTestBackend(publicKeyHex: configuration.publicKeyHex)
+ let client = TeraRuntimeClient(factory: { _ in await backend.start() })
+ _ = try await client.start(configuration: configuration)
+ let store = TeraSettingsStore(runtimeClient: client)
+ await store.saveProfile()
+ let pause = ResourceTestPause()
+ await backend.pauseProfile(pause)
+ let owner = Task { await store.advanceProfile() }
+ await entered(pause)
+ await store.cancelProfile()
+ await store.saveProfile()
+ await pause.resume.open()
+ await owner.value
+ let queued = ResourceTestPause()
+ let waiter = Task { await queued.wait(); await store.saveProfile() }
+ await entered(queued)
+ waiter.cancel()
+ await queued.resume.open()
+ await waiter.value
+ let count = await backend.profileMutations
+ XCTAssertEqual(count, 2)
+ XCTAssertFalse(store.isWorking)
+ _ = try await client.stop()
+ }
+}
diff --git a/TeraTests/TeraRuntimeResourceFixtures.swift b/TeraTests/TeraRuntimeResourceFixtures.swift
@@ -49,6 +49,8 @@ actor ResourceTestBackend: TeraRuntimeBackend {
private var subscriptionPause: ResourceTestPause?
private var snapshotPause: ResourceTestPause?
private var settingsPause: ResourceTestPause?
+ private var profilePause: ResourceTestPause?
+ private(set) var profileMutations = 0
private var receive: (@Sendable (TeraRuntimeChange) async -> Void)?
init(publicKeyHex: String) {
@@ -75,6 +77,35 @@ actor ResourceTestBackend: TeraRuntimeBackend {
settingsPause = pause
}
+ func pauseProfile(_ pause: ResourceTestPause) {
+ profilePause = pause
+ }
+
+ func saveProfileMetadata(input _: TeraProfileMetadataInput) async -> TeraProfileStatus {
+ await profileMutation()
+ }
+
+ func advanceProfile(operationID _: String) async -> TeraProfileStatus {
+ await profileMutation()
+ }
+
+ func cancelProfile(operationID _: String, expectedRevision _: UInt64) async -> TeraProfileStatus {
+ await profileMutation()
+ }
+
+ private func profileMutation() async -> TeraProfileStatus {
+ profileMutations += 1
+ let pause = profilePause
+ profilePause = nil
+ await pause?.wait()
+ return TeraProfileStatus(
+ id: String(repeating: "a", count: 32), revision: UInt64(profileMutations),
+ authorPublicKey: value.identity.publicKeyHex, state: .draft, deliveryID: nil,
+ createdAtUnixMilliseconds: 1_800_000_000_000, updatedAtUnixMilliseconds: 1_800_000_000_000,
+ settlement: nil
+ )
+ }
+
func snapshot() async throws -> TeraRuntimeSnapshot {
let pause = snapshotPause
snapshotPause = nil
diff --git a/core/crates/tera_core/src/runtime/mod.rs b/core/crates/tera_core/src/runtime/mod.rs
@@ -1,6 +1,8 @@
pub mod app_info;
pub mod builder;
pub mod info;
+#[cfg(feature = "mobile-social")]
+mod mutation_admission;
pub mod product_surface;
pub mod sdk;
pub mod store;
@@ -28,6 +30,8 @@ pub struct TeraRuntime {
pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>,
pub(crate) store_public_key: Option<PublicKey>,
#[cfg(feature = "mobile-social")]
+ mutations: mutation_admission::MutationAdmission,
+ #[cfg(feature = "mobile-social")]
pub(crate) settings_lock: tokio::sync::Mutex<()>,
#[cfg(feature = "mobile-social")]
pub(crate) identity_session: tokio::sync::RwLock<Option<(u64, product_surface::IdentityState)>>,
@@ -85,6 +89,8 @@ impl TeraRuntime {
platform_app: RwLock::new(None),
store_public_key,
#[cfg(feature = "mobile-social")]
+ mutations: mutation_admission::MutationAdmission::default(),
+ #[cfg(feature = "mobile-social")]
settings_lock: tokio::sync::Mutex::new(()),
#[cfg(feature = "mobile-social")]
identity_session: tokio::sync::RwLock::new(None),
diff --git a/core/crates/tera_core/src/runtime/mutation_admission.rs b/core/crates/tera_core/src/runtime/mutation_admission.rs
@@ -0,0 +1,65 @@
+//! Runtime-local admission for existing durable authoring scopes.
+//!
+//! Reservations cover the whole caller-owned future, including signer callbacks.
+//! The mutex protects only set insertion/removal and never crosses an await.
+//! Dropping a reservation releases in-memory admission, not durable claims or
+//! external effects; recovery still uses the existing draft and signing records.
+
+use std::{collections::BTreeSet, sync::Mutex};
+
+use radroots_signing::SigningOperationId;
+use radroots_storage::authored_draft::AuthoredDraftId;
+
+use super::product_surface::Phase1DraftError;
+
+#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
+enum Scope {
+ Draft(AuthoredDraftId),
+ Authorization(SigningOperationId),
+}
+
+#[derive(Default)]
+pub(super) struct MutationAdmission {
+ active: Mutex<BTreeSet<Scope>>,
+}
+
+impl MutationAdmission {
+ pub(super) fn draft(&self, id: [u8; 16]) -> Result<MutationPermit<'_>, Phase1DraftError> {
+ let id = AuthoredDraftId::new(id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ self.reserve(Scope::Draft(id))
+ }
+
+ pub(super) fn authorization(
+ &self,
+ id: SigningOperationId,
+ ) -> Result<MutationPermit<'_>, Phase1DraftError> {
+ self.reserve(Scope::Authorization(id))
+ }
+
+ fn reserve(&self, scope: Scope) -> Result<MutationPermit<'_>, Phase1DraftError> {
+ let mut active = self
+ .active
+ .lock()
+ .map_err(|_| Phase1DraftError::OperationUnavailable)?;
+ if !active.insert(scope) {
+ return Err(Phase1DraftError::OperationInProgress);
+ }
+ Ok(MutationPermit { owner: self, scope })
+ }
+}
+
+/// Unique, non-cloneable ownership of one in-flight transition.
+pub(super) struct MutationPermit<'a> {
+ owner: &'a MutationAdmission,
+ scope: Scope,
+}
+
+impl Drop for MutationPermit<'_> {
+ fn drop(&mut self) {
+ // Poison remains fail-closed for future admissions. Cleanup does not
+ // recover a poisoned set or perform storage, signing, or network work.
+ if let Ok(mut active) = self.owner.active.lock() {
+ active.remove(&self.scope);
+ }
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox.rs b/core/crates/tera_core/src/runtime/product_surface/outbox.rs
@@ -59,6 +59,9 @@ const BLOSSOM_SIGNING_TIMEOUT_MS: u64 = 60 * 1_000;
const BLOSSOM_AUTHORIZATION_CONTENT: &str = "Upload exact Tera image";
const REVISION_RETRACTION_REASON: &str = "Replaced by a corrected Tera event";
+#[cfg(test)]
+mod mutation_admission_tests;
+
/// Product intent represented by one durable draft/outbox item.
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
@@ -981,6 +984,8 @@ pub enum Phase1DraftError {
MediaNotReady,
#[error("phase 1 authored operation is unavailable")]
OperationUnavailable,
+ #[error("an authored transition already owns this operation scope")]
+ OperationInProgress,
#[error("phase 1 draft persistence failed")]
Storage,
#[error("phase 1 draft payload is corrupt")]
@@ -1221,6 +1226,7 @@ impl TeraRuntime {
let author = self.draft_author()?;
let draft_id = AuthoredDraftId::new(phase1_random_id()?)
.map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let _admission = self.mutations.draft(*draft_id.as_bytes())?;
let plan = AuthoredEventPlan::from_profile(
command.authored(),
now_unix_ms / 1_000,
@@ -1270,6 +1276,7 @@ impl TeraRuntime {
&self,
draft_id: [u8; 16],
) -> Result<Phase1ProfileStatus, Phase1DraftError> {
+ let _admission = self.mutations.draft(draft_id)?;
let mut status = self.phase1_profile_status(draft_id).await?;
if status.draft.stage() == AuthoredDraftStage::Draft {
status = self
@@ -1344,6 +1351,7 @@ impl TeraRuntime {
draft_id: [u8; 16],
expected_revision: u64,
) -> Result<Phase1ProfileStatus, Phase1DraftError> {
+ let _admission = self.mutations.draft(draft_id)?;
let draft_id =
AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
let expected = AuthoredDraftRevision::new(expected_revision)
@@ -1507,6 +1515,7 @@ impl TeraRuntime {
&self,
intent: Phase1UploadIntent,
) -> Result<(Phase1DraftStatus, Phase1NativeUploadJob), Phase1DraftError> {
+ let admission = self.mutations.draft(*intent.draft_id.as_bytes())?;
let now_unix_ms = phase1_operation_now_unix_ms()?;
let plan = Phase1UploadPlan::derive(now_unix_ms, phase1_random_id()?, phase1_random_id()?)?;
let request = radroots_sdk::transport::BlossomUploadRequest::new(
@@ -1547,7 +1556,8 @@ impl TeraRuntime {
)
.await?;
let uploading = self
- .phase1_update_draft_media(
+ .phase1_update_draft_media_admitted(
+ &admission,
*intent.draft_id.as_bytes(),
intent.expected_revision.get(),
remote_url.as_str(),
@@ -1579,6 +1589,7 @@ impl TeraRuntime {
response_content_encoding: Option<&str>,
response_body: &[u8],
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let admission = self.mutations.draft(*intent.draft_id.as_bytes())?;
let now_unix_ms = phase1_operation_now_unix_ms()?;
let request = radroots_sdk::transport::BlossomUploadRequest::new(
intent.bytes,
@@ -1608,7 +1619,8 @@ impl TeraRuntime {
.await
{
Ok(receipt) => {
- self.phase1_complete_draft_media(
+ self.phase1_complete_draft_media_admitted(
+ &admission,
*intent.draft_id.as_bytes(),
intent.expected_revision.get(),
url.as_str(),
@@ -1618,7 +1630,8 @@ impl TeraRuntime {
.await
}
Err(error) => {
- self.phase1_fail_draft_media(
+ self.phase1_fail_draft_media_admitted(
+ &admission,
*intent.draft_id.as_bytes(),
intent.expected_revision.get(),
url.as_str(),
@@ -1656,6 +1669,7 @@ impl TeraRuntime {
}
let draft_id = AuthoredDraftId::new(phase1_random_id()?)
.map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let _admission = self.mutations.draft(*draft_id.as_bytes())?;
let plan = intent
.command
.authored_plan(authored_at_unix_s, hex::encode(author))
@@ -1739,15 +1753,20 @@ impl TeraRuntime {
&self,
replacement_draft_id: [u8; 16],
) -> Result<Phase1RevisionStatus, Phase1DraftError> {
+ let admission = self.mutations.draft(replacement_draft_id)?;
let mut status = self.phase1_revision_status(replacement_draft_id).await?;
if matches!(
status.replacement.state(),
Phase1OutboxState::Draft | Phase1OutboxState::ReadyToSign
) {
- self.phase1_queue_add_intent(Phase1QueueIntent::new(
+ let now_unix_ms = phase1_operation_now_unix_ms()?;
+ self.phase1_queue_draft_admitted(
+ &admission,
replacement_draft_id,
status.replacement.draft().revision().get(),
- )?)
+ self.active_queue_policy(now_unix_ms)?,
+ now_unix_ms,
+ )
.await?;
status = self.phase1_revision_status(replacement_draft_id).await?;
}
@@ -1757,7 +1776,8 @@ impl TeraRuntime {
| Phase1OutboxState::Retryable
| Phase1OutboxState::PartiallyDelivered
) {
- self.phase1_advance_draft(
+ self.phase1_advance_draft_admitted(
+ &admission,
replacement_draft_id,
status.replacement.draft().revision().get(),
)
@@ -1815,6 +1835,7 @@ impl TeraRuntime {
&self,
replacement_draft_id: [u8; 16],
) -> Result<Phase1RevisionStatus, Phase1DraftError> {
+ let admission = self.mutations.draft(replacement_draft_id)?;
let mut status = self.phase1_revision_status(replacement_draft_id).await?;
if !matches!(
status.replacement.state(),
@@ -1822,9 +1843,11 @@ impl TeraRuntime {
| Phase1OutboxState::Terminal
| Phase1OutboxState::Cancelled
) {
- self.phase1_cancel_add_intent(
+ self.phase1_cancel_draft_admitted(
+ &admission,
replacement_draft_id,
status.replacement.draft().revision().get(),
+ phase1_operation_now_unix_ms()?,
)
.await?;
return self.phase1_revision_status(replacement_draft_id).await;
@@ -1861,13 +1884,15 @@ impl TeraRuntime {
target: &Phase1RevisionTarget,
draft_id: [u8; 16],
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let admission = self.mutations.draft(draft_id)?;
match self.phase1_draft_status(draft_id).await {
Ok(existing) => return Ok(existing),
Err(Phase1DraftError::NotFound) => {}
Err(error) => return Err(error),
}
let now_unix_ms = phase1_operation_now_unix_ms()?;
- self.phase1_save_retraction_draft(
+ self.phase1_save_retraction_draft_admitted(
+ &admission,
draft_id,
target.command_type,
target.card_id,
@@ -1939,6 +1964,7 @@ impl TeraRuntime {
expected_revision: Option<u64>,
persisted_at_unix_ms: u64,
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let _admission = self.mutations.draft(draft_id)?;
let author = self.draft_author()?;
let draft_id =
AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
@@ -2010,6 +2036,36 @@ impl TeraRuntime {
authored_at_unix_s: u64,
persisted_at_unix_ms: u64,
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let admission = self.mutations.draft(draft_id)?;
+ self.phase1_save_retraction_draft_admitted(
+ &admission,
+ draft_id,
+ command_type,
+ target_card_id,
+ target_event_id,
+ target_kind,
+ target_address,
+ reason,
+ authored_at_unix_s,
+ persisted_at_unix_ms,
+ )
+ .await
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ async fn phase1_save_retraction_draft_admitted(
+ &self,
+ _admission: &crate::runtime::mutation_admission::MutationPermit<'_>,
+ draft_id: [u8; 16],
+ command_type: AddCommandType,
+ target_card_id: CardId,
+ target_event_id: &str,
+ target_kind: u32,
+ target_address: Option<&str>,
+ reason: &str,
+ authored_at_unix_s: u64,
+ persisted_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
let target_shape_valid = match command_type {
AddCommandType::CreateUpdate
| AddCommandType::CreatePhotoUpdate
@@ -2073,6 +2129,30 @@ impl TeraRuntime {
failure_code: Option<String>,
updated_at_unix_ms: u64,
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let admission = self.mutations.draft(draft_id)?;
+ self.phase1_update_draft_media_admitted(
+ &admission,
+ draft_id,
+ expected_revision,
+ url,
+ stage,
+ failure_code,
+ updated_at_unix_ms,
+ )
+ .await
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ async fn phase1_update_draft_media_admitted(
+ &self,
+ _admission: &crate::runtime::mutation_admission::MutationPermit<'_>,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ url: &str,
+ stage: Phase1MediaStage,
+ failure_code: Option<String>,
+ updated_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
let draft_id =
AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
let expected = AuthoredDraftRevision::new(expected_revision)
@@ -2132,6 +2212,27 @@ impl TeraRuntime {
receipt: radroots_sdk::transport::BlossomUploadReceipt,
updated_at_unix_ms: u64,
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let admission = self.mutations.draft(draft_id)?;
+ self.phase1_complete_draft_media_admitted(
+ &admission,
+ draft_id,
+ expected_revision,
+ url,
+ receipt,
+ updated_at_unix_ms,
+ )
+ .await
+ }
+
+ async fn phase1_complete_draft_media_admitted(
+ &self,
+ _admission: &crate::runtime::mutation_admission::MutationPermit<'_>,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ url: &str,
+ receipt: radroots_sdk::transport::BlossomUploadReceipt,
+ updated_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
let draft_id =
AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
let expected = AuthoredDraftRevision::new(expected_revision)
@@ -2186,6 +2287,27 @@ impl TeraRuntime {
error: &radroots_sdk::transport::BlossomError,
updated_at_unix_ms: u64,
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let admission = self.mutations.draft(draft_id)?;
+ self.phase1_fail_draft_media_admitted(
+ &admission,
+ draft_id,
+ expected_revision,
+ url,
+ error,
+ updated_at_unix_ms,
+ )
+ .await
+ }
+
+ async fn phase1_fail_draft_media_admitted(
+ &self,
+ _admission: &crate::runtime::mutation_admission::MutationPermit<'_>,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ url: &str,
+ error: &radroots_sdk::transport::BlossomError,
+ updated_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
if updated_at_unix_ms == 0 {
return Err(Phase1DraftError::InvalidMedia);
}
@@ -2252,6 +2374,25 @@ impl TeraRuntime {
policy: Phase1QueuePolicy,
queued_at_unix_ms: u64,
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let admission = self.mutations.draft(draft_id)?;
+ self.phase1_queue_draft_admitted(
+ &admission,
+ draft_id,
+ expected_revision,
+ policy,
+ queued_at_unix_ms,
+ )
+ .await
+ }
+
+ async fn phase1_queue_draft_admitted(
+ &self,
+ _admission: &crate::runtime::mutation_admission::MutationPermit<'_>,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ policy: Phase1QueuePolicy,
+ queued_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
let draft_id =
AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
let expected = AuthoredDraftRevision::new(expected_revision)
@@ -2321,6 +2462,7 @@ impl TeraRuntime {
draft_id: [u8; 16],
recovered_at_unix_ms: u64,
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let _admission = self.mutations.draft(draft_id)?;
let draft_id =
AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
let head = self
@@ -2350,6 +2492,7 @@ impl TeraRuntime {
draft_id: [u8; 16],
expected_revision: u64,
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let _admission = self.mutations.draft(draft_id)?;
let draft_id =
AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
let expected = AuthoredDraftRevision::new(expected_revision)
@@ -2377,6 +2520,17 @@ impl TeraRuntime {
draft_id: [u8; 16],
expected_revision: u64,
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let admission = self.mutations.draft(draft_id)?;
+ self.phase1_advance_draft_admitted(&admission, draft_id, expected_revision)
+ .await
+ }
+
+ async fn phase1_advance_draft_admitted(
+ &self,
+ _admission: &crate::runtime::mutation_admission::MutationPermit<'_>,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
let draft_id =
AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
let expected = AuthoredDraftRevision::new(expected_revision)
@@ -2462,6 +2616,7 @@ impl TeraRuntime {
.map_err(|_| Phase1DraftError::InvalidMedia)?;
let operation_id =
SigningOperationId::new(operation_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let _admission = self.mutations.authorization(operation_id)?;
let artifact_id =
AuthoredArtifactId::new(artifact_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
let policy = SignPolicy::new(deadline_unix_ms, cancellation.signing())
@@ -2503,6 +2658,7 @@ impl TeraRuntime {
transfer_cancellation: radroots_sdk::transport::BlossomCancellation,
updated_at_unix_ms: u64,
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let admission = self.mutations.draft(draft_id)?;
let blossom = self
.client
.blossom()
@@ -2513,7 +2669,8 @@ impl TeraRuntime {
.map_err(|_| Phase1DraftError::Operation)?;
let url = transaction.expected_url().as_str().to_owned();
let uploading = self
- .phase1_update_draft_media(
+ .phase1_update_draft_media_admitted(
+ &admission,
draft_id,
expected_revision,
url.as_str(),
@@ -2531,7 +2688,8 @@ impl TeraRuntime {
) {
Ok(claim) => claim,
Err(_) => {
- self.phase1_update_draft_media(
+ self.phase1_update_draft_media_admitted(
+ &admission,
draft_id,
revision,
url.as_str(),
@@ -2555,7 +2713,8 @@ impl TeraRuntime {
{
Ok(authorization) => authorization,
Err(error) => {
- self.phase1_update_draft_media(
+ self.phase1_update_draft_media_admitted(
+ &admission,
draft_id,
revision,
url.as_str(),
@@ -2572,7 +2731,8 @@ impl TeraRuntime {
.await
{
Ok(receipt) => {
- self.phase1_complete_draft_media(
+ self.phase1_complete_draft_media_admitted(
+ &admission,
draft_id,
revision,
url.as_str(),
@@ -2582,7 +2742,8 @@ impl TeraRuntime {
.await
}
Err(error) => {
- self.phase1_fail_draft_media(
+ self.phase1_fail_draft_media_admitted(
+ &admission,
draft_id,
revision,
url.as_str(),
@@ -2636,6 +2797,23 @@ impl TeraRuntime {
expected_revision: u64,
cancelled_at_unix_ms: u64,
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let admission = self.mutations.draft(draft_id)?;
+ self.phase1_cancel_draft_admitted(
+ &admission,
+ draft_id,
+ expected_revision,
+ cancelled_at_unix_ms,
+ )
+ .await
+ }
+
+ async fn phase1_cancel_draft_admitted(
+ &self,
+ _admission: &crate::runtime::mutation_admission::MutationPermit<'_>,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ cancelled_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
let draft_id =
AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
let expected = AuthoredDraftRevision::new(expected_revision)
@@ -2687,6 +2865,7 @@ impl TeraRuntime {
context: &LocalNetwork,
draft_id: [u8; 16],
) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let _admission = self.mutations.draft(draft_id)?;
let status = self.phase1_draft_status(draft_id).await?;
let operation_id = status
.draft
diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs b/core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs
@@ -0,0 +1,328 @@
+use std::sync::{
+ Mutex, Weak,
+ atomic::{AtomicBool, AtomicUsize, Ordering},
+};
+use std::time::Duration;
+
+use radroots_nostr::signing::LocalSigner;
+use radroots_signing::{Signer, signer::BoxFuture};
+use tokio::sync::Notify;
+
+use super::*;
+use crate::runtime::product_surface::CreateUpdate;
+
+struct PausedSigner {
+ inner: LocalSigner,
+ pause_status: AtomicBool,
+ pause_sign: AtomicBool,
+ entered: Notify,
+ resume: Notify,
+ statuses: AtomicUsize,
+ signatures: AtomicUsize,
+ reentrant: Mutex<Option<ReentrantCancellation>>,
+}
+
+struct ReentrantCancellation {
+ runtime: Weak<TeraRuntime>,
+ id: [u8; 16],
+ revision: u64,
+}
+
+impl PausedSigner {
+ fn new(pause_status: bool, pause_sign: bool) -> Arc<Self> {
+ Arc::new(Self {
+ inner: LocalSigner::new(
+ radroots_nostr::key::SecretKey::parse(
+ "0000000000000000000000000000000000000000000000000000000000000001",
+ )
+ .unwrap(),
+ )
+ .unwrap(),
+ pause_status: AtomicBool::new(pause_status),
+ pause_sign: AtomicBool::new(pause_sign),
+ entered: Notify::new(),
+ resume: Notify::new(),
+ statuses: AtomicUsize::new(0),
+ signatures: AtomicUsize::new(0),
+ reentrant: Mutex::new(None),
+ })
+ }
+
+ async fn wait(&self) {
+ tokio::time::timeout(Duration::from_secs(5), self.entered.notified())
+ .await
+ .expect("signer must reach the explicit gate");
+ }
+}
+
+impl Signer for PausedSigner {
+ fn status(
+ &self,
+ ) -> BoxFuture<'_, Result<radroots_signing::SignerStatus, radroots_signing::Error>> {
+ Box::pin(async {
+ self.statuses.fetch_add(1, Ordering::SeqCst);
+ let callback = self.reentrant.lock().unwrap().take();
+ if let Some(ReentrantCancellation {
+ runtime,
+ id,
+ revision,
+ }) = callback
+ {
+ assert_eq!(
+ runtime
+ .upgrade()
+ .unwrap()
+ .phase1_cancel_draft(id, revision, 1_900_000_000_000)
+ .await
+ .unwrap_err(),
+ Phase1DraftError::OperationInProgress
+ );
+ }
+ if self.pause_status.swap(false, Ordering::SeqCst) {
+ self.entered.notify_one();
+ self.resume.notified().await;
+ }
+ self.inner.status().await
+ })
+ }
+
+ fn sign(
+ &self,
+ request: radroots_signing::SignRequest,
+ ) -> BoxFuture<'_, Result<radroots_signing::SignReceipt, radroots_signing::Error>> {
+ Box::pin(async move {
+ self.signatures.fetch_add(1, Ordering::SeqCst);
+ if self.pause_sign.swap(false, Ordering::SeqCst) {
+ self.entered.notify_one();
+ self.resume.notified().await;
+ }
+ self.inner.sign(request).await
+ })
+ }
+}
+
+fn runtime(signer: Arc<PausedSigner>) -> Arc<TeraRuntime> {
+ Arc::new(
+ TeraRuntime::from_client_builder(
+ radroots_sdk::ClientBuilder::memory_default(),
+ Some(
+ PublicKey::from_hex(
+ "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ )
+ .unwrap(),
+ ),
+ None,
+ Some(signer),
+ None,
+ None,
+ )
+ .unwrap(),
+ )
+}
+
+async fn queued(runtime: &TeraRuntime, id: [u8; 16]) -> Phase1DraftStatus {
+ let saved = runtime
+ .phase1_save_draft(
+ id,
+ Phase1AddCommand::CreateUpdate(CreateUpdate::new("An admitted update").unwrap()),
+ 1_900_000_000,
+ Vec::new(),
+ None,
+ 1_900_000_000_000,
+ )
+ .await
+ .unwrap();
+ runtime
+ .phase1_queue_draft(
+ id,
+ saved.draft().revision().get(),
+ Phase1QueuePolicy::new(
+ vec!["wss://relay.example".to_owned()],
+ Phase1RelaySatisfaction::AllAccepted,
+ 2_000_000_000_000,
+ Phase1CancellationPolicy::LocalCooperative,
+ )
+ .unwrap(),
+ 1_900_000_000_001,
+ )
+ .await
+ .unwrap()
+}
+
+fn signing(
+ runtime: &Arc<TeraRuntime>,
+ id: [u8; 16],
+ revision: u64,
+) -> tokio::task::JoinHandle<Result<Phase1DraftStatus, Phase1DraftError>> {
+ let runtime = Arc::clone(runtime);
+ tokio::spawn(async move { runtime.phase1_sign_queued_draft(id, revision).await })
+}
+
+#[tokio::test]
+async fn mutation_admission_precedes_signer_callbacks_and_preserves_independent_drafts() {
+ let signer = PausedSigner::new(true, false);
+ let runtime = runtime(Arc::clone(&signer));
+ let first = queued(&runtime, [1; 16]).await;
+ let second = queued(&runtime, [2; 16]).await;
+ let revision = first.draft().revision().get();
+ let owner = signing(&runtime, [1; 16], revision);
+ signer.wait().await;
+ assert_eq!(
+ runtime
+ .phase1_sign_queued_draft([1; 16], revision)
+ .await
+ .unwrap_err(),
+ Phase1DraftError::OperationInProgress
+ );
+ assert_eq!(
+ runtime
+ .phase1_advance_draft([1; 16], revision)
+ .await
+ .unwrap_err(),
+ Phase1DraftError::OperationInProgress
+ );
+ assert_eq!(
+ runtime
+ .phase1_cancel_draft([1; 16], revision, 1_900_000_000_002)
+ .await
+ .unwrap_err(),
+ Phase1DraftError::OperationInProgress
+ );
+ assert_eq!(signer.statuses.load(Ordering::SeqCst), 1);
+ let independent = runtime
+ .phase1_sign_queued_draft([2; 16], second.draft().revision().get())
+ .await
+ .unwrap();
+ assert!(independent.push().unwrap().artifact().signed().is_some());
+ signer.resume.notify_one();
+ let receipt = owner.await.unwrap().unwrap();
+ assert!(receipt.push().unwrap().artifact().signed().is_some());
+ let replay = runtime
+ .phase1_sign_queued_draft([1; 16], revision)
+ .await
+ .unwrap();
+ assert_eq!(
+ replay.push().unwrap().artifact(),
+ receipt.push().unwrap().artifact()
+ );
+ assert_eq!(signer.signatures.load(Ordering::SeqCst), 2);
+}
+
+#[tokio::test]
+async fn mutation_admission_releases_cancelled_preclaim_work_without_leaking_the_scope() {
+ let signer = PausedSigner::new(true, false);
+ let runtime = runtime(Arc::clone(&signer));
+ let status = queued(&runtime, [3; 16]).await;
+ let revision = status.draft().revision().get();
+ // An unpolled, queued future has not admitted work or called the signer.
+ drop(runtime.phase1_sign_queued_draft([3; 16], revision));
+ assert_eq!(signer.statuses.load(Ordering::SeqCst), 0);
+ let owner = signing(&runtime, [3; 16], revision);
+ signer.wait().await;
+ owner.abort();
+ assert!(owner.await.unwrap_err().is_cancelled());
+ assert_eq!(signer.signatures.load(Ordering::SeqCst), 0);
+ let receipt = runtime
+ .phase1_sign_queued_draft([3; 16], revision)
+ .await
+ .unwrap();
+ assert!(receipt.push().unwrap().artifact().signed().is_some());
+ assert_eq!(signer.signatures.load(Ordering::SeqCst), 1);
+}
+
+#[tokio::test]
+async fn mutation_admission_cancellation_preserves_the_durable_signing_claim() {
+ let signer = PausedSigner::new(false, true);
+ let runtime = runtime(Arc::clone(&signer));
+ let status = queued(&runtime, [4; 16]).await;
+ let revision = status.draft().revision().get();
+ let owner = signing(&runtime, [4; 16], revision);
+ signer.wait().await;
+ let before = runtime.phase1_draft_status([4; 16]).await.unwrap();
+ assert!(before.push().unwrap().artifact().signing_claim().is_some());
+ owner.abort();
+ assert!(owner.await.unwrap_err().is_cancelled());
+ let after = runtime.phase1_draft_status([4; 16]).await.unwrap();
+ assert_eq!(
+ after.push().unwrap().artifact(),
+ before.push().unwrap().artifact()
+ );
+ assert_eq!(
+ runtime
+ .phase1_sign_queued_draft([4; 16], revision)
+ .await
+ .unwrap_err(),
+ Phase1DraftError::Operation
+ );
+ assert_eq!(signer.signatures.load(Ordering::SeqCst), 1);
+}
+
+#[tokio::test]
+async fn mutation_admission_reentrant_signer_cannot_cancel_its_own_transition() {
+ let signer = PausedSigner::new(false, false);
+ let runtime = runtime(Arc::clone(&signer));
+ let status = queued(&runtime, [5; 16]).await;
+ let revision = status.draft().revision().get();
+ *signer.reentrant.lock().unwrap() = Some(ReentrantCancellation {
+ runtime: Arc::downgrade(&runtime),
+ id: [5; 16],
+ revision,
+ });
+ let receipt = runtime
+ .phase1_sign_queued_draft([5; 16], revision)
+ .await
+ .unwrap();
+ assert_ne!(receipt.draft().stage(), AuthoredDraftStage::Cancelled);
+ assert!(receipt.push().unwrap().artifact().signed().is_some());
+ assert_eq!(signer.signatures.load(Ordering::SeqCst), 1);
+}
+
+#[tokio::test]
+async fn mutation_admission_upload_authorization_uses_the_existing_operation_identity() {
+ use radroots_blossom::authorization::{AuthorizationContent, ServerDomain};
+ let signer = PausedSigner::new(false, true);
+ let runtime = runtime(Arc::clone(&signer));
+ let claim = AuthoredUploadClaim::new(
+ AuthorizationContent::parse("Upload exact Tera image").unwrap(),
+ ServerDomain::parse("media.example").unwrap(),
+ radroots_blossom::Sha256::digest(b"exact upload bytes"),
+ 1_900_000_000,
+ 60,
+ )
+ .unwrap();
+ let owner = {
+ let runtime = Arc::clone(&runtime);
+ let claim = claim.clone();
+ tokio::spawn(async move {
+ runtime
+ .phase1_authorize_blossom_upload(
+ [6; 16],
+ [7; 16],
+ claim,
+ u64::MAX,
+ Phase1CancellationPolicy::LocalCooperative,
+ )
+ .await
+ })
+ };
+ signer.wait().await;
+ // Both exact replay and a conflicting artifact share one operation scope.
+ for artifact in [[7; 16], [8; 16]] {
+ assert_eq!(
+ runtime
+ .phase1_authorize_blossom_upload(
+ [6; 16],
+ artifact,
+ claim.clone(),
+ u64::MAX,
+ Phase1CancellationPolicy::LocalCooperative
+ )
+ .await
+ .unwrap_err(),
+ Phase1DraftError::OperationInProgress
+ );
+ }
+ signer.resume.notify_one();
+ assert!(!owner.await.unwrap().unwrap().as_str().is_empty());
+ assert_eq!(signer.signatures.load(Ordering::SeqCst), 1);
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/today.rs b/core/crates/tera_core/src/runtime/product_surface/today.rs
@@ -2407,6 +2407,7 @@ mod tests {
shutting_down: AtomicBool::new(false),
platform_app: RwLock::new(None),
store_public_key: None,
+ mutations: Default::default(),
settings_lock: tokio::sync::Mutex::new(()),
identity_session: tokio::sync::RwLock::new(None),
inbound_media_directory: None,
diff --git a/core/crates/tera_ffi/src/error.rs b/core/crates/tera_ffi/src/error.rs
@@ -207,6 +207,15 @@ impl From<Phase1DraftError> for TeraAppError {
Phase1DraftError::OperationUnavailable => {
("authoring_unavailable", true, &["retry"][..])
}
+ Phase1DraftError::OperationInProgress => {
+ return Self::failure(
+ "operation_in_progress",
+ "operation",
+ true,
+ &["retry_operation_with_same_idempotency_key"],
+ "An authored transition is already in progress for this operation.",
+ );
+ }
Phase1DraftError::Storage => (
"authoring_storage_failed",
true,
@@ -280,6 +289,25 @@ mod tests {
use super::*;
#[test]
+ fn mutation_admission_reports_the_existing_typed_recovery_contract() {
+ let error = TeraAppError::from(Phase1DraftError::OperationInProgress);
+ let report = error.report();
+ assert_eq!(report.schema_version, MOBILE_FFI_SCHEMA_VERSION);
+ assert_eq!(report.code, "operation_in_progress");
+ assert_eq!(report.category, "operation");
+ assert!(report.retryable);
+ assert_eq!(
+ report.recovery_actions,
+ ["retry_operation_with_same_idempotency_key"]
+ );
+ let recovery = tera_core::error::recovery::classify(report.schema_version, &report.code);
+ assert_eq!(
+ recovery.disposition,
+ tera_core::error::recovery::RecoveryDisposition::OutcomeUnknown
+ );
+ }
+
+ #[test]
fn internal_core_messages_are_not_copied_to_the_ffi_record() {
let error = TeraAppError::from(tera_core::TeraAppError::internal(
"private/path/secret-value",
diff --git a/project.yml b/project.yml
@@ -60,6 +60,8 @@ targets:
- path: TeraTests/TeraTodayStoreTests.swift
- path: TeraTests/TeraMediaStoreTests.swift
- path: TeraTests/TeraAddStoreTests.swift
+ - path: TeraTests/TeraBackgroundUploadFixtures.swift
+ - path: TeraTests/TeraMutationAdmissionTests.swift
- path: TeraTests/TeraSupportingStoreTests.swift
- path: TeraTests/TeraLifecycleTests.swift
- path: TeraTests/TeraClockTests.swift
diff --git a/release/provenance.json b/release/provenance.json
@@ -2,11 +2,11 @@
"artifacts": {
"app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b",
"ffi_api_sha256": "37ba223303e6268d12d8581ba9a1a77a327b132122e29284bec715cca3c19369",
- "ffi_provenance_sha256": "d8a97dce55ee2f58e56cc6bebc5870956df106ed7715e144f13c617be94c9d2a",
+ "ffi_provenance_sha256": "6c26d7069a9864fb5c70f1bd8633b6102d2aa9cb5774c06bf6c01e542cb3315d",
"info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef",
"privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12",
"sbom_sha256": "6b5ce897d5273290febc8b831663e12dea759cb0084f182ba7949e1c593fc410",
- "xcode_project_sha256": "33057e3e83dacfa3c9a41c270347485339da5101c217a005770022f881c1596c"
+ "xcode_project_sha256": "34c15094c5665c920f02860bc0083fb699052a7d481c87523192e863f53e7ecf"
},
"disposition": "unsigned",
"platforms": [
@@ -22,7 +22,7 @@
"lib_revision": "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb",
"source_date_epoch": 1787871027,
"swift_package_lock_sha256": "94ae067a374726cdaf6b4ca0a5e44663c57fcdc5334060c5ffef5e79cfbf04c0",
- "tera_ffi_source_tree": "ee3a2c5daa71f0fb972496b44ca3be606a887c16",
+ "tera_ffi_source_tree": "f6bfeb00965c4cf264b5f74e488427092965762f",
"xcode_package_lock_sha256": "c7f41934ea25f7a287bdc4f3a6ecabbf09a3a0bdd0f5a3e58183f355ca814096"
},
"version": "0.1.0-alpha"
diff --git a/test-fixtures/legacy-identifiers.v1.json b/test-fixtures/legacy-identifiers.v1.json
@@ -245,7 +245,7 @@
"count": 2
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 2
},
{
@@ -351,7 +351,11 @@
"count": 3
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "Tera/Runtime/TeraBackgroundUploadRequest.swift",
+ "count": 1
+ },
+ {
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
}
]
@@ -401,7 +405,7 @@
"category": "shared_apple_api",
"occurrences": [
{
- "path": "Tera/Runtime/TeraAddMediaCoordinator.swift",
+ "path": "Tera/Runtime/TeraBackgroundUploadRequest.swift",
"count": 1
}
]
@@ -483,7 +487,7 @@
"count": 3
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 2
}
]
@@ -523,7 +527,7 @@
"category": "shared_apple_api",
"occurrences": [
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
},
{
@@ -547,7 +551,7 @@
"category": "shared_apple_api",
"occurrences": [
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 4
}
]
@@ -558,10 +562,14 @@
"occurrences": [
{
"path": "Tera/Runtime/TeraAddMediaCoordinator.swift",
- "count": 7
+ "count": 3
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "Tera/Runtime/TeraBackgroundUploadRequest.swift",
+ "count": 4
+ },
+ {
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 6
}
]
@@ -572,10 +580,14 @@
"occurrences": [
{
"path": "Tera/Runtime/TeraAddMediaCoordinator.swift",
- "count": 8
+ "count": 2
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "Tera/Runtime/TeraBackgroundUploadRequest.swift",
+ "count": 7
+ },
+ {
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 9
}
]
@@ -585,7 +597,7 @@
"category": "shared_apple_api",
"occurrences": [
{
- "path": "Tera/Runtime/TeraAddMediaCoordinator.swift",
+ "path": "Tera/Runtime/TeraBackgroundUploadRequest.swift",
"count": 1
}
]
@@ -595,7 +607,7 @@
"category": "shared_apple_api",
"occurrences": [
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
}
]
@@ -605,7 +617,7 @@
"category": "shared_apple_api",
"occurrences": [
{
- "path": "Tera/Runtime/TeraAddMediaCoordinator.swift",
+ "path": "Tera/Runtime/TeraBackgroundUploadRequest.swift",
"count": 1
}
]
@@ -619,7 +631,7 @@
"count": 1
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 6
}
]
@@ -629,7 +641,7 @@
"category": "shared_apple_api",
"occurrences": [
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 6
}
]
@@ -649,7 +661,7 @@
"category": "shared_apple_api",
"occurrences": [
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
}
]
@@ -717,7 +729,7 @@
"count": 2
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 2
},
{
@@ -1015,6 +1027,10 @@
"count": 1
},
{
+ "path": "Tera/Runtime/TeraBackgroundUploadRequest.swift",
+ "count": 1
+ },
+ {
"path": "Tera/Runtime/TeraLifecycleCoordinator.swift",
"count": 1
},
@@ -1051,10 +1067,18 @@
"count": 1
},
{
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
+ "count": 1
+ },
+ {
"path": "TeraTests/TeraLifecycleTests.swift",
"count": 1
},
{
+ "path": "TeraTests/TeraMutationAdmissionTests.swift",
+ "count": 1
+ },
+ {
"path": "TeraTests/TeraRemoteQualificationTests.swift",
"count": 1
},
@@ -1141,7 +1165,7 @@
"count": 2
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
}
]
@@ -1155,7 +1179,7 @@
"count": 1
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
}
]
@@ -1169,7 +1193,7 @@
"count": 2
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
}
]
@@ -1183,7 +1207,7 @@
"count": 2
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
}
]
@@ -1197,7 +1221,7 @@
"count": 4
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
}
]
@@ -1211,7 +1235,7 @@
"count": 2
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 2
}
]
@@ -1358,10 +1382,14 @@
"occurrences": [
{
"path": "Tera/Runtime/TeraAddMediaCoordinator.swift",
- "count": 3
+ "count": 1
},
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "Tera/Runtime/TeraBackgroundUploadRequest.swift",
+ "count": 2
+ },
+ {
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
}
]
@@ -1715,7 +1743,7 @@
"count": 1
},
{
- "path": "Tera/Runtime/TeraAddMediaCoordinator.swift",
+ "path": "Tera/Runtime/TeraBackgroundUploadRequest.swift",
"count": 1
},
{
@@ -1787,7 +1815,7 @@
"category": "verification_compatibility",
"occurrences": [
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
}
]
@@ -1797,7 +1825,7 @@
"category": "verification_compatibility",
"occurrences": [
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
}
]
@@ -1952,11 +1980,19 @@
"occurrences": [
{
"path": "Tera/Runtime/TeraAddMediaCoordinator.swift",
- "count": 2
+ "count": 1
+ },
+ {
+ "path": "Tera/Runtime/TeraBackgroundUploadRequest.swift",
+ "count": 1
},
{
"path": "TeraTests/TeraAddStoreTests.swift",
- "count": 2
+ "count": 1
+ },
+ {
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
+ "count": 1
}
]
},
@@ -2431,7 +2467,7 @@
"category": "verification_compatibility",
"occurrences": [
{
- "path": "TeraTests/TeraAddStoreTests.swift",
+ "path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 1
}
]
@@ -3877,6 +3913,10 @@
"count": 6
},
{
+ "path": "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs",
+ "count": 2
+ },
+ {
"path": "core/crates/tera_core/src/runtime/product_surface/today.rs",
"count": 2
},
@@ -4169,6 +4209,10 @@
{
"path": "core/crates/tera_core/src/runtime/product_surface/outbox.rs",
"count": 5
+ },
+ {
+ "path": "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs",
+ "count": 2
}
]
},
@@ -4224,7 +4268,11 @@
},
{
"path": "core/crates/tera_core/src/runtime/product_surface/outbox.rs",
- "count": 41
+ "count": 43
+ },
+ {
+ "path": "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs",
+ "count": 1
},
{
"path": "core/crates/tera_core/src/runtime/product_surface/settings.rs",
@@ -4293,10 +4341,18 @@
"count": 1
},
{
+ "path": "core/crates/tera_core/src/runtime/mutation_admission.rs",
+ "count": 1
+ },
+ {
"path": "core/crates/tera_core/src/runtime/product_surface/outbox.rs",
"count": 1
},
{
+ "path": "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs",
+ "count": 6
+ },
+ {
"path": "core/crates/tera_ffi/Cargo.toml",
"count": 1
},
@@ -4319,6 +4375,10 @@
"count": 1
},
{
+ "path": "core/crates/tera_core/src/runtime/mutation_admission.rs",
+ "count": 1
+ },
+ {
"path": "core/crates/tera_core/src/runtime/product_surface/outbox.rs",
"count": 5
},
diff --git a/test-fixtures/swiftlint-maintainability-baseline.v1.json b/test-fixtures/swiftlint-maintainability-baseline.v1.json
@@ -1 +1 @@
-[{"text":"final class TeraAppModel: ObservableObject {","violation":{"severity":"error","ruleDescription":"Type bodies should not span too many lines","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 255 lines","ruleName":"Type Body Length","location":{"file":"Tera\/App\/TeraAppModel.swift","character":7,"line":55},"ruleIdentifier":"type_body_length"}},{"text":" init(","violation":{"ruleDescription":"Function bodies should not span too many lines","severity":"error","ruleName":"Function Body Length","reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","ruleIdentifier":"function_body_length","location":{"character":3,"file":"Tera\/App\/TeraAppModel.swift","line":71}}},{"text":"actor TeraAddMediaCoordinator: TeraAddMediaHandling {","violation":{"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 427 lines","ruleName":"Type Body Length","severity":"error","ruleDescription":"Type bodies should not span too many lines","location":{"line":73,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1},"ruleIdentifier":"type_body_length"}},{"text":" func uploadInBackground(","violation":{"location":{"line":192,"character":3,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift"},"severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 55 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length"}},{"text":"#endif","violation":{"location":{"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","line":580,"character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 400 lines or less: currently contains 580","severity":"error","ruleIdentifier":"file_length","ruleName":"File Length"}},{"text":"private final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {","violation":{"ruleDescription":"Type bodies should not span too many lines","location":{"character":15,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","line":134},"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 545 lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error"}},{"text":"}","violation":{"ruleName":"File Length","severity":"error","ruleDescription":"Files should not span too many lines.","location":{"character":1,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","line":1696},"ruleIdentifier":"file_length","reason":"File should contain 1000 lines or less: currently contains 1696"}},{"text":"}","violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"error","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 434","location":{"character":1,"line":434,"file":"Tera\/Runtime\/TeraLifecycleCoordinator.swift"}}},{"violation":{"location":{"character":1,"line":291,"file":"Tera\/Runtime\/TeraRuntimeClient.swift"},"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 821 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","ruleName":"Type Body Length"},"text":"actor TeraRuntimeClient {"},{"violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 71 lines","ruleName":"Function Body Length","location":{"character":3,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":708},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error"},"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {"},{"violation":{"severity":"error","ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","ruleIdentifier":"function_body_length","location":{"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":11,"line":887},"ruleName":"Function Body Length"},"text":" private func beginShutdown() -> ShutdownOperation {"},{"violation":{"reason":"File should contain 1000 lines or less: currently contains 1197","ruleName":"File Length","severity":"error","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","location":{"line":1197,"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift"}},"text":"}"},{"violation":{"severity":"error","ruleName":"File Length","location":{"character":1,"line":1234,"file":"Tera\/Runtime\/TeraRuntimeModels.swift"},"reason":"File should contain 1000 lines or less: currently contains 1234","ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length"},"text":"}"},{"violation":{"severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 741 lines","location":{"file":"Tera\/State\/TeraAddStore.swift","line":11,"character":7},"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"},"text":"final class TeraAddStore: ObservableObject {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","location":{"file":"Tera\/State\/TeraAddStore.swift","line":280,"character":3},"severity":"error","ruleIdentifier":"function_body_length"},"text":" func submit() async {"},{"violation":{"ruleName":"Function Body Length","location":{"character":3,"line":438,"file":"Tera\/State\/TeraAddStore.swift"},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 51 lines"},"text":" func retract(_ card: TeraTodayCard) async {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","location":{"line":524,"character":11,"file":"Tera\/State\/TeraAddStore.swift"},"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 60 lines"},"text":" private func uploadPendingMedia(_ initial: TeraDraftStatus) async throws"},{"violation":{"ruleDescription":"Files should not span too many lines.","severity":"error","ruleIdentifier":"file_length","location":{"line":810,"file":"Tera\/State\/TeraAddStore.swift","character":1},"reason":"File should contain 400 lines or less: currently contains 810","ruleName":"File Length"},"text":"}"},{"violation":{"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":78,"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift"},"ruleIdentifier":"type_body_length"},"text":"actor TeraConfigurationStore {"},{"violation":{"reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":148,"character":5,"file":"Tera\/State\/TeraConfigurationStore.swift"},"ruleIdentifier":"function_body_length"},"text":" func load() throws -> TeraAppConfiguration {"},{"text":"}","violation":{"location":{"file":"Tera\/State\/TeraConfigurationStore.swift","line":759,"character":1},"reason":"File should contain 400 lines or less: currently contains 759","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"error","ruleIdentifier":"file_length"}},{"text":"actor TeraSessionStore {","violation":{"severity":"error","ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/State\/TeraSessionStore.swift","line":40},"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 430 lines"}},{"text":" static func production(","violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","location":{"line":70,"character":10,"file":"Tera\/State\/TeraSessionStore.swift"},"severity":"error"}},{"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {","violation":{"ruleName":"Cyclomatic Complexity","ruleIdentifier":"cyclomatic_complexity","reason":"Function should have complexity 10 or less; currently complexity is 14","ruleDescription":"Complexity of function bodies should be limited.","location":{"line":182,"character":11,"file":"Tera\/State\/TeraSessionStore.swift"},"severity":"error"}},{"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {","violation":{"severity":"error","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","location":{"file":"Tera\/State\/TeraSessionStore.swift","line":182,"character":11},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 64 lines","ruleDescription":"Function bodies should not span too many lines"}},{"text":" private func reconcileIdentity(_ identity: TeraAppIdentity) async throws {","violation":{"ruleName":"Function Body Length","ruleIdentifier":"function_body_length","location":{"character":11,"file":"Tera\/State\/TeraSessionStore.swift","line":388},"ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines"}},{"text":"}","violation":{"reason":"File should contain 400 lines or less: currently contains 492","severity":"error","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","location":{"line":492,"character":1,"file":"Tera\/State\/TeraSessionStore.swift"}}},{"text":"}","violation":{"ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 451","ruleIdentifier":"file_length","ruleName":"File Length","location":{"line":451,"character":1,"file":"Tera\/State\/TeraSupportingStores.swift"}}},{"text":"struct TeraAddView: View {","violation":{"ruleDescription":"Type bodies should not span too many lines","location":{"file":"Tera\/Views\/TeraAddView.swift","character":1,"line":4},"reason":"Struct body should span 350 lines or less excluding comments and whitespace: currently spans 454 lines","ruleName":"Type Body Length","severity":"error","ruleIdentifier":"type_body_length"}},{"text":"}","violation":{"ruleIdentifier":"file_length","severity":"error","ruleName":"File Length","ruleDescription":"Files should not span too many lines.","location":{"file":"Tera\/Views\/TeraAddView.swift","character":1,"line":560},"reason":"File should contain 400 lines or less: currently contains 560"}},{"text":"struct TeraSettingsView: View {","violation":{"ruleName":"Type Body Length","location":{"file":"Tera\/Views\/TeraSupportingViews.swift","character":1,"line":365},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines","severity":"error"}},{"text":"}","violation":{"ruleName":"File Length","location":{"file":"Tera\/Views\/TeraSupportingViews.swift","character":1,"line":629},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","reason":"File should contain 400 lines or less: currently contains 629","severity":"error"}},{"text":"}","violation":{"ruleIdentifier":"file_length","severity":"error","ruleName":"File Length","location":{"file":"Tera\/Views\/TeraTodayView.swift","line":493,"character":1},"reason":"File should contain 400 lines or less: currently contains 493","ruleDescription":"Files should not span too many lines."}},{"text":"final class TeraAddStoreTests: XCTestCase {","violation":{"ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 665 lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","location":{"file":"TeraTests\/TeraAddStoreTests.swift","line":6,"character":7}}},{"text":"private actor AddBackend: TeraRuntimeBackend {","violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","location":{"file":"TeraTests\/TeraAddStoreTests.swift","character":9,"line":847},"ruleIdentifier":"type_body_length","severity":"error","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 579 lines"}},{"text":" nonisolated static func schemas() -> [TeraAddSchema] {","violation":{"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 89 lines","ruleName":"Function Body Length","severity":"error","ruleIdentifier":"function_body_length","location":{"file":"TeraTests\/TeraAddStoreTests.swift","character":22,"line":934}}},{"text":" private func makeStatus(","violation":{"ruleDescription":"Number of function parameters should be low.","ruleIdentifier":"function_parameter_count","ruleName":"Function Parameter Count","reason":"Function should have 5 parameters or less: it currently has 8","location":{"file":"TeraTests\/TeraAddStoreTests.swift","line":1371,"character":11},"severity":"error"}},{"text":" var counts: (enqueue: Int, retry: Int, cancel: Int, acceptedSettlement: Int) {","violation":{"ruleDescription":"Tuples shouldn't have too many members. Create a custom type instead.","location":{"line":1635,"character":15,"file":"TeraTests\/TeraAddStoreTests.swift"},"ruleIdentifier":"large_tuple","reason":"Tuples should have at most 2 members","ruleName":"Large Tuple","severity":"error"}},{"text":"}","violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1784","severity":"error","ruleIdentifier":"file_length","location":{"file":"TeraTests\/TeraAddStoreTests.swift","line":1784,"character":1}}},{"text":"final class TeraRemoteQualificationTests: XCTestCase {","violation":{"severity":"error","ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","location":{"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":7,"character":7},"reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines"}},{"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {","violation":{"location":{"line":45,"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift"},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","ruleIdentifier":"function_body_length","severity":"error","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length"}},{"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {","violation":{"ruleName":"Function Body Length","location":{"character":3,"line":212,"file":"TeraTests\/TeraRemoteQualificationTests.swift"},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","severity":"error"}},{"text":"final class TeraRuntimeClientTests: XCTestCase {","violation":{"ruleName":"Type Body Length","location":{"character":7,"file":"TeraTests\/TeraRuntimeClientTests.swift","line":4},"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 334 lines","ruleDescription":"Type bodies should not span too many lines"}},{"text":"final class TeraStateMigrationTests: XCTestCase {","violation":{"ruleName":"Type Body Length","location":{"character":7,"file":"TeraTests\/TeraStateMigrationTests.swift","line":6},"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","ruleDescription":"Type bodies should not span too many lines"}},{"text":" func testRelayValidationMatchesRuntimeProfiles() throws {","violation":{"location":{"file":"TeraTests\/TeraStateMigrationTests.swift","character":5,"line":7},"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","ruleIdentifier":"function_body_length","severity":"error","ruleName":"Function Body Length"}},{"text":"}","violation":{"reason":"File should contain 400 lines or less: currently contains 622","ruleName":"File Length","severity":"error","ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","location":{"file":"TeraTests\/TeraStateMigrationTests.swift","character":1,"line":622}}},{"text":"private actor SupportingBackend: TeraRuntimeBackend {","violation":{"ruleIdentifier":"type_body_length","location":{"file":"TeraTests\/TeraSupportingStoreTests.swift","character":9,"line":207},"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"error","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 253 lines"}},{"text":"}","violation":{"severity":"error","location":{"file":"TeraTests\/TeraSupportingStoreTests.swift","line":487,"character":1},"reason":"File should contain 400 lines or less: currently contains 487","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length"}},{"text":"final class TeraRemoteQualificationUITests: XCTestCase {","violation":{"ruleDescription":"Type bodies should not span too many lines","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1493 lines","ruleIdentifier":"type_body_length","location":{"line":4,"character":7,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift"},"ruleName":"Type Body Length","severity":"error"}},{"text":" func testLocalSocialFiveFlowScenario() throws {","violation":{"ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 73 lines","ruleName":"Function Body Length","location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":62,"character":3},"ruleDescription":"Function bodies should not span too many lines","severity":"error"}},{"text":" func testLocalSocialDeterministicPersonas() throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"error","location":{"line":174,"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift"},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines"}},{"text":" func testRemoteBlossomUploadAndRecovery() throws {","violation":{"location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":247,"character":3},"severity":"error","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines"}},{"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {","violation":{"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":573,"character":11},"severity":"error","reason":"Function should have complexity 10 or less; currently complexity is 18"}},{"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {","violation":{"severity":"error","reason":"Function should have complexity 10 or less; currently complexity is 11","ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","location":{"line":767,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11}}},{"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {","violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","severity":"error","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","location":{"line":767,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"ruleName":"Function Body Length"}},{"text":" static func environment(","violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","severity":"error","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","location":{"line":1736,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":10},"ruleName":"Function Body Length"}},{"violation":{"reason":"File should contain 1000 lines or less: currently contains 2212","severity":"error","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","location":{"line":2212,"character":1,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift"}},"text":"}"}]
-\ No newline at end of file
+[{"text":"final class TeraAppModel: ObservableObject {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 255 lines","ruleName":"Type Body Length","location":{"character":7,"file":"Tera\/App\/TeraAppModel.swift","line":55}}},{"text":" init(","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","ruleName":"Function Body Length","location":{"character":3,"file":"Tera\/App\/TeraAppModel.swift","line":71}}},{"text":"actor TeraAddMediaCoordinator: TeraAddMediaHandling {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 330 lines","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","line":73}}},{"text":"#endif","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 480","ruleName":"File Length","location":{"character":1,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","line":480}}},{"text":"private final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 545 lines","ruleName":"Type Body Length","location":{"character":15,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","line":134}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 1000 lines or less: currently contains 1696","ruleName":"File Length","location":{"character":1,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","line":1696}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 434","ruleName":"File Length","location":{"character":1,"file":"Tera\/Runtime\/TeraLifecycleCoordinator.swift","line":434}}},{"text":"actor TeraRuntimeClient {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 821 lines","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":291}}},{"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 71 lines","ruleName":"Function Body Length","location":{"character":3,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":708}}},{"text":" private func beginShutdown() -> ShutdownOperation {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","ruleName":"Function Body Length","location":{"character":11,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":887}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 1000 lines or less: currently contains 1197","ruleName":"File Length","location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":1197}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 1000 lines or less: currently contains 1234","ruleName":"File Length","location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeModels.swift","line":1234}}},{"text":"final class TeraAddStore: ObservableObject {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 741 lines","ruleName":"Type Body Length","location":{"character":7,"file":"Tera\/State\/TeraAddStore.swift","line":11}}},{"text":" func submit() async {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","ruleName":"Function Body Length","location":{"character":3,"file":"Tera\/State\/TeraAddStore.swift","line":280}}},{"text":" func retract(_ card: TeraTodayCard) async {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 51 lines","ruleName":"Function Body Length","location":{"character":3,"file":"Tera\/State\/TeraAddStore.swift","line":438}}},{"text":" private func uploadPendingMedia(_ initial: TeraDraftStatus) async throws","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 60 lines","ruleName":"Function Body Length","location":{"character":11,"file":"Tera\/State\/TeraAddStore.swift","line":524}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 810","ruleName":"File Length","location":{"character":1,"file":"Tera\/State\/TeraAddStore.swift","line":810}}},{"text":"actor TeraConfigurationStore {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift","line":78}}},{"text":" func load() throws -> TeraAppConfiguration {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","ruleName":"Function Body Length","location":{"character":5,"file":"Tera\/State\/TeraConfigurationStore.swift","line":148}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 759","ruleName":"File Length","location":{"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift","line":759}}},{"text":"actor TeraSessionStore {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 430 lines","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/State\/TeraSessionStore.swift","line":40}}},{"text":" static func production(","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","ruleName":"Function Body Length","location":{"character":10,"file":"Tera\/State\/TeraSessionStore.swift","line":70}}},{"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {","violation":{"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","severity":"error","reason":"Function should have complexity 10 or less; currently complexity is 14","ruleName":"Cyclomatic Complexity","location":{"character":11,"file":"Tera\/State\/TeraSessionStore.swift","line":182}}},{"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 64 lines","ruleName":"Function Body Length","location":{"character":11,"file":"Tera\/State\/TeraSessionStore.swift","line":182}}},{"text":" private func reconcileIdentity(_ identity: TeraAppIdentity) async throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","ruleName":"Function Body Length","location":{"character":11,"file":"Tera\/State\/TeraSessionStore.swift","line":388}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 492","ruleName":"File Length","location":{"character":1,"file":"Tera\/State\/TeraSessionStore.swift","line":492}}},{"text":"struct TeraAddView: View {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Struct body should span 350 lines or less excluding comments and whitespace: currently spans 454 lines","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/Views\/TeraAddView.swift","line":4}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 560","ruleName":"File Length","location":{"character":1,"file":"Tera\/Views\/TeraAddView.swift","line":560}}},{"text":"struct TeraSettingsView: View {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines","ruleName":"Type Body Length","location":{"character":1,"file":"Tera\/Views\/TeraSupportingViews.swift","line":365}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 629","ruleName":"File Length","location":{"character":1,"file":"Tera\/Views\/TeraSupportingViews.swift","line":629}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 493","ruleName":"File Length","location":{"character":1,"file":"Tera\/Views\/TeraTodayView.swift","line":493}}},{"text":"final class TeraAddStoreTests: XCTestCase {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 665 lines","ruleName":"Type Body Length","location":{"character":7,"file":"TeraTests\/TeraAddStoreTests.swift","line":6}}},{"text":"private actor AddBackend: TeraRuntimeBackend {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 492 lines","ruleName":"Type Body Length","location":{"character":9,"file":"TeraTests\/TeraAddStoreTests.swift","line":847}}},{"text":" private func makeStatus(","violation":{"ruleIdentifier":"function_parameter_count","ruleDescription":"Number of function parameters should be low.","severity":"error","reason":"Function should have 5 parameters or less: it currently has 8","ruleName":"Function Parameter Count","location":{"character":11,"file":"TeraTests\/TeraAddStoreTests.swift","line":1283}}},{"text":" nonisolated static func schemas() -> [TeraAddSchema] {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 89 lines","ruleName":"Function Body Length","location":{"character":22,"file":"TeraTests\/TeraAddStoreTests.swift","line":1380}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 1000 lines or less: currently contains 1528","ruleName":"File Length","location":{"character":1,"file":"TeraTests\/TeraAddStoreTests.swift","line":1528}}},{"text":"final class TeraRemoteQualificationTests: XCTestCase {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines","ruleName":"Type Body Length","location":{"character":7,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":7}}},{"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","ruleName":"Function Body Length","location":{"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":45}}},{"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","ruleName":"Function Body Length","location":{"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":212}}},{"text":"final class TeraRuntimeClientTests: XCTestCase {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 334 lines","ruleName":"Type Body Length","location":{"character":7,"file":"TeraTests\/TeraRuntimeClientTests.swift","line":4}}},{"text":"final class TeraStateMigrationTests: XCTestCase {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","ruleName":"Type Body Length","location":{"character":7,"file":"TeraTests\/TeraStateMigrationTests.swift","line":6}}},{"text":" func testRelayValidationMatchesRuntimeProfiles() throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","ruleName":"Function Body Length","location":{"character":5,"file":"TeraTests\/TeraStateMigrationTests.swift","line":7}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 622","ruleName":"File Length","location":{"character":1,"file":"TeraTests\/TeraStateMigrationTests.swift","line":622}}},{"text":"private actor SupportingBackend: TeraRuntimeBackend {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 253 lines","ruleName":"Type Body Length","location":{"character":9,"file":"TeraTests\/TeraSupportingStoreTests.swift","line":207}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 400 lines or less: currently contains 487","ruleName":"File Length","location":{"character":1,"file":"TeraTests\/TeraSupportingStoreTests.swift","line":487}}},{"text":"final class TeraRemoteQualificationUITests: XCTestCase {","violation":{"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1493 lines","ruleName":"Type Body Length","location":{"character":7,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":4}}},{"text":" func testLocalSocialFiveFlowScenario() throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 73 lines","ruleName":"Function Body Length","location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":62}}},{"text":" func testLocalSocialDeterministicPersonas() throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","ruleName":"Function Body Length","location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":174}}},{"text":" func testRemoteBlossomUploadAndRecovery() throws {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines","ruleName":"Function Body Length","location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":247}}},{"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {","violation":{"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","severity":"error","reason":"Function should have complexity 10 or less; currently complexity is 18","ruleName":"Cyclomatic Complexity","location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":573}}},{"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {","violation":{"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","severity":"error","reason":"Function should have complexity 10 or less; currently complexity is 11","ruleName":"Cyclomatic Complexity","location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":767}}},{"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","ruleName":"Function Body Length","location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":767}}},{"text":" static func environment(","violation":{"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","ruleName":"Function Body Length","location":{"character":10,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":1736}}},{"text":"}","violation":{"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","reason":"File should contain 1000 lines or less: currently contains 2212","ruleName":"File Length","location":{"character":1,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":2212}}}]
+\ No newline at end of file