field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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:
MPackage.swift | 2++
MTera.xcodeproj/project.pbxproj | 8++++++++
MTera/Runtime/TeraAddMediaCoordinator.swift | 138+++++++++++--------------------------------------------------------------------
ATera/Runtime/TeraBackgroundUploadRequest.swift | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTera/State/TeraAddStore.swift | 4++--
ATera/State/TeraSettingsStore.swift | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTera/State/TeraSupportingStores.swift | 220-------------------------------------------------------------------------------
MTeraFFI/provenance.json | 54+++++++++++++++++++++++++++---------------------------
MTeraFFI/source.lock | 4++--
MTeraFFI/source/aarch64-apple-darwin.json | 44++++++++++++++++++++++++++++----------------
MTeraFFI/source/aarch64-apple-ios-sim.json | 44++++++++++++++++++++++++++++----------------
MTeraFFI/source/aarch64-apple-ios.json | 44++++++++++++++++++++++++++++----------------
MTeraTests/TeraAddStoreTests.swift | 542+++++++++++++++++++++----------------------------------------------------------
ATeraTests/TeraBackgroundUploadFixtures.swift | 360+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATeraTests/TeraMutationAdmissionTests.swift | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTeraTests/TeraRuntimeResourceFixtures.swift | 31+++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/mod.rs | 6++++++
Acore/crates/tera_core/src/runtime/mutation_admission.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/outbox.rs | 205++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Acore/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/today.rs | 1+
Mcore/crates/tera_ffi/src/error.rs | 28++++++++++++++++++++++++++++
Mproject.yml | 2++
Mrelease/provenance.json | 6+++---
Mtest-fixtures/legacy-identifiers.v1.json | 124++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mtest-fixtures/swiftlint-maintainability-baseline.v1.json | 4++--
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