zitare-native/Sources/Core/Submit/SubmissionQueue.swift
Till JS 61927d27a3 ζ-3.5: Offline-Submit-Queue mit SwiftData + Auto-Retry
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>
2026-05-19 16:22:52 +02:00

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
}
}