Bei Network-Failure landet der Quote-Draft jetzt in einer persistenten SwiftData-Queue (\`PendingSubmission\`) statt im Error-Banner. Beim nächsten App-Launch ODER beim Wechsel auf scenePhase.active wird der Flush automatisch versucht. Retry-Policy: - 5xx oder Transport-Failure (NSURLErrorDomain) → in Queue, Retry - 4xx mit code (validation_failed, duplicate, unauthorized) → permanenter Fehler, kein Retry (User-Aktion nötig) - Hard-Limit 50 Retries pro Entry, danach pausiert App-Group-Store \`submissions.store\` (parallel zu snapshot.store) im \`group.ev.mana.zitare\`-Container. Fallback auf In-Memory falls Disk-Init scheitert (App-Group noch nicht aktiviert im Apple-Dev-Portal). UI-Pieces: - Pending-Banner zeigt Queue-Tiefe wenn > 0 - Queued-Banner nach erfolgreichem Enqueue - Form-Reset nach Enqueue (User sieht: "weg, kommt nach") - onChange(scenePhase) → Auto-Flush bei Foreground - ZitareNativeApp.task: Flush am Launch Files: - Sources/Core/Submit/PendingSubmissionModel.swift (neu, @Model) - Sources/Core/Submit/SubmissionQueue.swift (neu, @Observable @MainActor) - Sources/App/ZitareNativeApp.swift: Container-Init + environment-Wiring - Sources/Features/Submit/SubmitQuoteView.swift: enqueue + flush + banners iOS + macOS BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
115 lines
4.1 KiB
Swift
115 lines
4.1 KiB
Swift
import Foundation
|
|
import ManaCore
|
|
import SwiftData
|
|
|
|
/// Verwaltet die `PendingSubmission`-Queue. Hauptverantwortung: Drafts
|
|
/// persistieren bei Network-Failure, beim Foreground/Reconnect via
|
|
/// `tryFlush(api:)` einen Retry-Run starten.
|
|
///
|
|
/// **Retry-Strategie:** alle wartenden Drafts werden sequentiell gesendet.
|
|
/// Bei `400/409/401` (Server-Code) wird der Eintrag **gelöscht** — das
|
|
/// sind permanente Fehler (validation_failed, duplicate, unauthorized),
|
|
/// die ein Retry nicht rettet. Bei Network-/5xx-Fehler bleibt der Draft
|
|
/// in der Queue mit `lastError` für Diagnose; `retryCount++`.
|
|
///
|
|
/// **Hartes Limit:** 50 Retries pro Eintrag, danach wird er als
|
|
/// "permanent failed" markiert (`retryCount > 50`) und nicht mehr
|
|
/// versucht — User muss manuell löschen oder neu eintragen.
|
|
@MainActor
|
|
@Observable
|
|
final class SubmissionQueue {
|
|
let container: ModelContainer
|
|
private var inFlight: Bool = false
|
|
|
|
init(container: ModelContainer) {
|
|
self.container = container
|
|
}
|
|
|
|
/// Hängt einen Draft an die Queue.
|
|
func deposit(_ draft: QuoteDraft, error: String? = nil) {
|
|
let ctx = ModelContext(container)
|
|
let entry = PendingSubmission(draft: draft)
|
|
entry.lastError = error
|
|
ctx.insert(entry)
|
|
try? ctx.save()
|
|
Log.app.info("Submission deposited (queue depth: \(self.queueDepth()))")
|
|
}
|
|
|
|
/// Anzahl der wartenden Submissions.
|
|
func queueDepth() -> Int {
|
|
let ctx = ModelContext(container)
|
|
let descriptor = FetchDescriptor<PendingSubmission>()
|
|
return (try? ctx.fetch(descriptor).count) ?? 0
|
|
}
|
|
|
|
/// Holt alle pending Drafts (für UI).
|
|
func loadAll() -> [PendingSubmission] {
|
|
let ctx = ModelContext(container)
|
|
let descriptor = FetchDescriptor<PendingSubmission>(
|
|
sortBy: [SortDescriptor(\.createdAt)]
|
|
)
|
|
return (try? ctx.fetch(descriptor)) ?? []
|
|
}
|
|
|
|
/// Löscht eine Submission manuell aus der Queue.
|
|
func delete(id: UUID) {
|
|
let ctx = ModelContext(container)
|
|
let descriptor = FetchDescriptor<PendingSubmission>(
|
|
predicate: #Predicate { $0.id == id }
|
|
)
|
|
if let row = try? ctx.fetch(descriptor).first {
|
|
ctx.delete(row)
|
|
try? ctx.save()
|
|
}
|
|
}
|
|
|
|
/// Versucht alle wartenden Submissions zu senden. Returns count of
|
|
/// erfolgreich gesendeter Items.
|
|
@discardableResult
|
|
func tryFlush(api: ZitareAPI) async -> Int {
|
|
guard !inFlight else { return 0 }
|
|
inFlight = true
|
|
defer { inFlight = false }
|
|
|
|
let ctx = ModelContext(container)
|
|
let descriptor = FetchDescriptor<PendingSubmission>(
|
|
predicate: #Predicate { $0.retryCount <= 50 },
|
|
sortBy: [SortDescriptor(\.createdAt)]
|
|
)
|
|
guard let entries = try? ctx.fetch(descriptor), !entries.isEmpty else {
|
|
return 0
|
|
}
|
|
|
|
Log.app.info("Flushing \(entries.count) pending submission(s)")
|
|
var sent = 0
|
|
for entry in entries {
|
|
let draft = entry.toDraft()
|
|
do {
|
|
_ = try await api.submitQuote(draft)
|
|
ctx.delete(entry)
|
|
sent += 1
|
|
} catch let error as ZitareAPIError where shouldDrop(error) {
|
|
Log.app.warning("Dropping pending submission (permanent error: \(error.code ?? "?", privacy: .public))")
|
|
entry.lastError = error.errorDescription
|
|
ctx.delete(entry)
|
|
} catch let error as LocalizedError {
|
|
entry.lastError = error.errorDescription
|
|
entry.lastTriedAt = Date()
|
|
entry.retryCount += 1
|
|
} catch {
|
|
entry.lastError = String(describing: error)
|
|
entry.lastTriedAt = Date()
|
|
entry.retryCount += 1
|
|
}
|
|
}
|
|
try? ctx.save()
|
|
Log.app.info("Flush done — sent \(sent)/\(entries.count)")
|
|
return sent
|
|
}
|
|
|
|
/// 4xx-Errors sind permanent (validation, auth, duplicate) → Drop.
|
|
/// 5xx und Transport-Failure → Retry.
|
|
private func shouldDrop(_ error: ZitareAPIError) -> Bool {
|
|
error.status >= 400 && error.status < 500
|
|
}
|
|
}
|