ζ-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>
This commit is contained in:
Till JS 2026-05-19 16:22:52 +02:00
parent ef8364f05d
commit 61927d27a3
4 changed files with 308 additions and 3 deletions

View file

@ -8,6 +8,7 @@ import WidgetKit
struct ZitareNativeApp: App {
@State private var auth: AuthClient
@State private var authGate: ManaAuthGate
@State private var submissionQueue: SubmissionQueue
private let snapshotContainer: ModelContainer?
init() {
@ -23,6 +24,18 @@ struct ZitareNativeApp: App {
)
snapshotContainer = nil
}
let pending: ModelContainer
do {
pending = try PendingSubmissionContainer.make()
} catch {
Log.app.error(
"PendingSubmissionContainer-Disk init fehlgeschlagen, falle auf in-memory zurück: \(String(describing: error), privacy: .public)"
)
// In-memory-Fallback statt nil. Submissions sind dann nur
// für die aktuelle Session persistiert besser als Crash.
pending = try! PendingSubmissionContainer.make(inMemory: true)
}
_submissionQueue = State(initialValue: SubmissionQueue(container: pending))
Log.app.info(
"Zitare starting — auth status: \(String(describing: auth.status), privacy: .public)"
)
@ -33,13 +46,23 @@ struct ZitareNativeApp: App {
RootView()
.environment(auth)
.environment(authGate)
.environment(submissionQueue)
.tint(ZitareTheme.primary)
.task {
await refreshSnapshot()
await flushPending()
}
}
}
private func flushPending() async {
let api = ZitareAPI(auth: auth)
let sent = await submissionQueue.tryFlush(api: api)
if sent > 0 {
Log.app.info("Auto-flushed \(sent) pending submission(s) at launch")
}
}
private func refreshSnapshot() async {
guard let container = snapshotContainer else { return }
let sync = SnapshotSync(container: container)

View file

@ -0,0 +1,84 @@
import Foundation
import SwiftData
/// Pending Quote-Submission, die wegen Network-Fehler nicht durchging.
/// Wird beim nächsten Foreground/Reconnect via `SubmissionQueue.tryFlush`
/// nachgereicht.
///
/// Eigener Store (`submissions.store`) im App-Group-Container, damit
/// Snapshot-Sync und Pending-Submissions sich nicht in den Quere kommen.
@Model
final class PendingSubmission {
@Attribute(.unique) var id: UUID
var createdAt: Date
var lastTriedAt: Date?
var retryCount: Int
var lastError: String?
// QuoteDraft-Felder, flach gespeichert für SwiftData-Kompatibilität.
var text: String
var language: String
var authorName: String?
var authorSlug: String?
var sourceTitle: String?
var sourceKindRaw: String?
var sourceYear: Int?
var editReason: String?
var acceptedTos: Bool
init(draft: QuoteDraft, id: UUID = UUID(), createdAt: Date = Date()) {
self.id = id
self.createdAt = createdAt
self.retryCount = 0
self.text = draft.text
self.language = draft.language
self.authorName = draft.authorName
self.authorSlug = draft.authorSlug
self.sourceTitle = draft.sourceTitle
self.sourceKindRaw = draft.sourceKind?.rawValue
self.sourceYear = draft.sourceYear
self.editReason = draft.editReason
self.acceptedTos = draft.acceptedTos
}
func toDraft() -> QuoteDraft {
QuoteDraft(
text: text,
language: language,
authorName: authorName,
authorSlug: authorSlug,
sourceTitle: sourceTitle,
sourceKind: sourceKindRaw.flatMap(QuoteDraft.SourceKind.init(rawValue:)),
sourceYear: sourceYear,
editReason: editReason,
acceptedTos: acceptedTos
)
}
}
/// Helper für den ModelContainer. Eigener Store, damit das
/// Snapshot-Schema nicht mit-migriert wird, wenn wir Submission-
/// Felder ändern.
enum PendingSubmissionContainer {
static let appGroup = "group.ev.mana.zitare"
static func defaultStoreURL() -> URL {
let fm = FileManager.default
if let groupURL = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroup) {
return groupURL.appendingPathComponent("submissions.store")
}
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory())
return docs.appendingPathComponent("submissions.store")
}
static func make(inMemory: Bool = false) throws -> ModelContainer {
let schema = Schema([PendingSubmission.self])
let config: ModelConfiguration = if inMemory {
ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
} else {
ModelConfiguration("submissions", schema: schema, url: defaultStoreURL())
}
return try ModelContainer(for: schema, configurations: [config])
}
}

View file

@ -0,0 +1,115 @@
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
}
}

View file

@ -19,12 +19,16 @@ import SwiftUI
struct SubmitQuoteView: View {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@Environment(SubmissionQueue.self) private var submissionQueue
@Environment(\.scenePhase) private var scenePhase
@State private var draft = QuoteDraft.empty
@State private var includesSource = false
@State private var submitting = false
@State private var lastError: String?
@State private var lastSuccessSlug: String?
@State private var queuedForRetry: Bool = false
@State private var pendingCount: Int = 0
@FocusState private var textFocused: Bool
var body: some View {
@ -34,9 +38,15 @@ struct SubmitQuoteView: View {
authorSection
sourceSection
licenseSection
if pendingCount > 0 {
Section { pendingBanner }
}
if let lastError {
Section { errorBanner(lastError) }
}
if queuedForRetry {
Section { queuedBanner }
}
if let lastSuccessSlug {
Section { successBanner(slug: lastSuccessSlug) }
}
@ -63,6 +73,14 @@ struct SubmitQuoteView: View {
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.task {
refreshPendingCount()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active {
Task { await flushPending() }
}
}
}
}
@ -148,6 +166,29 @@ struct SubmitQuoteView: View {
.font(.callout)
}
private var pendingBanner: some View {
VStack(alignment: .leading, spacing: 4) {
Label("\(pendingCount) Submission(s) in der Warteschlange", systemImage: "tray.and.arrow.up")
.foregroundStyle(ZitareTheme.mutedForeground)
.font(.callout)
Text("Beim nächsten Verbindungsaufbau werden sie automatisch nachgereicht.")
.font(.caption)
.foregroundStyle(ZitareTheme.mutedForeground)
}
}
private var queuedBanner: some View {
VStack(alignment: .leading, spacing: 4) {
Label("Offline gespeichert — wird automatisch nachgereicht", systemImage: "tray.and.arrow.up.fill")
.foregroundStyle(ZitareTheme.primary)
.font(.callout)
.fontWeight(.medium)
Text("Sobald wieder Netzwerk verfügbar ist, fliegt der Draft an die Moderation. Du musst nichts mehr tun.")
.font(.caption)
.foregroundStyle(ZitareTheme.mutedForeground)
}
}
private func successBanner(slug: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Label("Eingereicht — wartet auf Moderation", systemImage: "checkmark.seal.fill")
@ -217,13 +258,55 @@ struct SubmitQuoteView: View {
lastSuccessSlug = result.slug
draft = .empty
includesSource = false
} catch let error as LocalizedError {
lastError = error.errorDescription ?? "Fehler"
queuedForRetry = false
} catch let apiError as ZitareAPIError {
// 4xx mit Code (validation_failed, duplicate, unauthorized)
// permanenter Fehler, kein Retry. 5xx oder kein Code Queue.
if apiError.status >= 500 || apiError.code == nil {
enqueue(payload, error: apiError.errorDescription)
} else {
lastError = apiError.errorDescription ?? "Fehler"
}
} catch {
lastError = String(describing: error)
// URLError / Transport-Failure ebenfalls in die Queue.
if isTransportError(error) {
enqueue(payload, error: String(describing: error))
} else {
lastError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
}
}
}
private func enqueue(_ payload: QuoteDraft, error: String?) {
submissionQueue.deposit(payload, error: error)
queuedForRetry = true
lastError = nil
draft = .empty
includesSource = false
refreshPendingCount()
Log.app.info("Submit fehlgeschlagen — in Queue gelegt (\(self.pendingCount) wartend)")
}
private func isTransportError(_ error: Error) -> Bool {
let ns = error as NSError
return ns.domain == NSURLErrorDomain
}
@MainActor
private func flushPending() async {
guard pendingCount > 0 else { return }
let api = ZitareAPI(auth: auth)
let sent = await submissionQueue.tryFlush(api: api)
if sent > 0 {
Log.app.info("Foreground-Flush: \(sent) Submission(s) nachgereicht")
}
refreshPendingCount()
}
private func refreshPendingCount() {
pendingCount = submissionQueue.queueDepth()
}
private func label(for kind: QuoteDraft.SourceKind) -> LocalizedStringKey {
switch kind {
case .book: "Buch"