Auto-Flush hat 4xx-Errors (duplicate, validation_failed, unauthorized) bisher stillschweigend gedroppt — User offline einreichen, im Web denselben Text posten, Online gehen → die Native-Submission war weg ohne Hinweis. SubmissionQueue: - struct DropRecord (textPreview, authorName, code, message, droppedAt) - private(set) var dropNotifications: [DropRecord] - tryFlush sammelt jetzt einen Pre-Delete-Snapshot in dropNotifications - consumeDropNotifications() leert die Liste — UI ruft beim Banner-Quittieren auf SubmitQuoteView: - droppedBanner zeigt alle gedroppten Drafts mit Text-Preview + lokalisierter Error-Message - "Quittieren"-Button leert nur die UI-State (Server-Drop ist final) - harvestDropNotifications() läuft nach jedem flushPending iOS + macOS BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
147 lines
5.4 KiB
Swift
147 lines
5.4 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
|
|
|
|
/// Sammelt Drop-Records vom letzten tryFlush — eine UI-Schicht kann
|
|
/// sie als "diese N Submissions wurden permanent verworfen"-Banner
|
|
/// anzeigen, statt dass der User stumm verliert.
|
|
struct DropRecord: Identifiable, Sendable {
|
|
let id = UUID()
|
|
let textPreview: String
|
|
let authorName: String?
|
|
let code: String?
|
|
let message: String
|
|
let droppedAt: Date
|
|
}
|
|
|
|
/// Drop-Notifications der letzten Session — UI liest und konsumiert
|
|
/// via `consumeDropNotifications()`.
|
|
private(set) var dropNotifications: [DropRecord] = []
|
|
|
|
init(container: ModelContainer) {
|
|
self.container = container
|
|
}
|
|
|
|
/// Leert die Liste — Caller hat sie gerade angezeigt.
|
|
func consumeDropNotifications() -> [DropRecord] {
|
|
let snapshot = dropNotifications
|
|
dropNotifications.removeAll()
|
|
return snapshot
|
|
}
|
|
|
|
/// 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
|
|
// Notify UI: dieser Draft wurde verworfen. Pre-Snapshot
|
|
// der Daten vor dem Delete machen.
|
|
dropNotifications.append(DropRecord(
|
|
textPreview: String(entry.text.prefix(120)),
|
|
authorName: entry.authorName,
|
|
code: error.code,
|
|
message: error.errorDescription ?? "Fehler",
|
|
droppedAt: Date()
|
|
))
|
|
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
|
|
}
|
|
}
|