Skip to content

feat(swift): add offline write outbox so local writes reach the server - #2673

Merged
mikib0 merged 2 commits into
developmentfrom
worktree-issue-2672-offline-outbox
Aug 9, 2026
Merged

feat(swift): add offline write outbox so local writes reach the server#2673
mikib0 merged 2 commits into
developmentfrom
worktree-issue-2672-offline-outbox

Conversation

@mikib0

@mikib0 mikib0 commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #2672.

Problem

The Swift app is offline-first only on the read path. Writes made while offline were stranded on-device permanently — no outbox, no retry, and no path by which a locally-created pack or trip ever reached the server. Every reference to the local- id prefix was a guard that skipped the server rather than a push.

Delete was also inconsistent with create/update in the same file: deletePack restored the removed pack and rethrew ("Couldn't Delete Pack"), while createPack under identical conditions silently succeeded locally.

Approach

The API already accepts client-supplied ids on create (packages/api/src/routes/packs/index.ts inserts data.id), so the only reason offline records were unsyncable is that the offline path minted a deliberately-poisoned local- id instead of reusing the same client UUID scheme. Dropping the prefix removes the id-reconciliation problem entirely.

Changes

  • PendingMutation (new SwiftData model) — entity type, entity id, operation, optional parent id, JSON payload, attemptCount, lastError, failed. Registered in the container, so queued writes survive kill/relaunch.
  • OutboxService (new) — drains the queue serially in createdAt order, so create-then-update on the same entity replays in the order the user made it.
  • Retired the local- prefix in Packs/Trips for a plain client UUID, and threaded that id through createPack / addItem / createTrip so a replayed create can't produce a duplicate record.
  • Mutation collapsing on enqueue — create+delete of a never-synced entity cancels out and never contacts the server; updates fold into a pending create; consecutive updates collapse to the latest.
  • Terminal-failure policy — 4xx is terminal (the payload is wrong, retrying never helps); 5xx and transport errors retry up to 5 attempts, then mark the mutation failed and keep it for the UI rather than dropping it silently. 404-on-delete and 409-on-create count as success — the server already agrees with local state.
  • Delete now matches create/update — a failed or offline delete stays deleted locally and flushes later, for both deletePack and deleteItem.
  • .flushesPendingWrites() on the root view flushes at launch, on NetworkMonitor.isConnected change, and on foreground.

Acceptance criteria

  • A pack created, edited, and deleted entirely offline reaches the server correctly on reconnect
  • Killing and relaunching before reconnect does not lose queued writes (SwiftData-backed)
  • A 4xx surfaces instead of retrying indefinitely
  • deletePack and createPack behave consistently when the network is unavailable

Decisions on the issue's open questions

  • Conflict resolution: last-write-wins, no conflict UI. Appropriate for single-user pack data, but it can silently lose edits across two devices — worth revisiting if multi-device lands.
  • Scope: built the outbox as a general shape (any entity type), rolled out to Packs + Trips only, matching current cache coverage.
  • Delete of a never-synced entity: drops the queued create and never contacts the server.

Out of scope

The local- guards in PackTemplatesViewModel and TrailConditionsViewModel are left alone — they mint and guard consistently within their own files and are outside this issue's Packs/Trips scope. Extending caching to the remaining 18 features is likewise separate.

Test plan

Launch with --force-offline, create/edit/delete a pack and a trip, force-quit and relaunch, then remove the flag and confirm all writes land server-side on reconnect.

Summary by CodeRabbit

  • New Features
    • Added reliable offline support for creating, updating, and deleting packs, items, and trips.
    • Pending changes now sync automatically when connectivity returns or the app becomes active.
    • Added retry handling for temporary sync failures.
  • Bug Fixes
    • Deleted content remains removed locally while synchronization is pending.
    • Improved consistency when adding items, changing categories, and deleting trips.
  • Improvements
    • Offline-created content now preserves its identity during synchronization.
    • Pending and failed changes are tracked for more reliable recovery.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The Swift app now persists offline pack, item, and trip mutations in SwiftData. OutboxService replays queued operations after connectivity or authentication becomes available. Local UUIDs remain stable across retries, and deletes remain optimistic.

Changes

Offline mutation persistence

Layer / File(s) Summary
Pending mutation persistence
apps/swift/Sources/PackRat/Persistence/PendingMutation.swift, apps/swift/Sources/PackRat/Persistence/PersistenceController.swift
SwiftData stores mutation metadata, retry state, failure state, and Codable payloads for packs, items, and trips.
Pack and item mutation producers
apps/swift/Sources/PackRat/Services/PackService.swift, apps/swift/Sources/PackRat/Features/Packs/PacksViewModel.swift, apps/swift/Sources/PackRat/Features/Catalog/CatalogView.swift, apps/swift/Sources/PackRat/Features/Packs/PackDetailView.swift
Pack and item mutations preserve local UUIDs, queue failed or offline writes, and keep deletes removed locally.
Trip mutation producers
apps/swift/Sources/PackRat/Services/TripService.swift, apps/swift/Sources/PackRat/Features/Trips/TripsViewModel.swift, apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift
Trip mutations preserve local UUIDs, queue failed or offline writes, and keep deletes removed locally.
Outbox replay and app integration
apps/swift/Sources/PackRat/Services/OutboxService.swift, apps/swift/Sources/PackRat/Shared/OutboxFlushModifier.swift, apps/swift/Sources/PackRat/PackRatApp.swift
OutboxService coalesces and replays queued mutations with retry and terminal-failure handling. The app flushes pending writes at launch, on foreground activation, and after connectivity restoration.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ViewModel
  participant SwiftData
  participant OutboxService
  participant PackTripService
  participant Connectivity

  ViewModel->>PackTripService: Submit mutation with local UUID
  PackTripService-->>ViewModel: Return success or failure
  ViewModel->>OutboxService: Enqueue failed or offline mutation
  OutboxService->>SwiftData: Persist PendingMutation
  Connectivity-->>OutboxService: Report restored connectivity
  OutboxService->>SwiftData: Fetch queued mutations
  OutboxService->>PackTripService: Replay mutation with preserved UUID
  PackTripService-->>OutboxService: Return replay result
  OutboxService->>SwiftData: Delete successful mutation or record failure
Loading

Possibly related issues

Possibly related PRs

Suggested labels: mobile, database, api

Suggested reviewers: andrew-bierman

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding an offline write outbox for Swift local writes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-issue-2672-offline-outbox

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for packages/units (./packages/units)

Status Category Percentage Covered / Total
🟢 Lines 100% (🎯 100%) 35 / 35
🟢 Statements 100% (🎯 100%) 35 / 35
🟢 Functions 100% (🎯 100%) 6 / 6
🟢 Branches 100% (🎯 100%) 11 / 11
File CoverageNo changed files found.
Generated in workflow #582 for commit 97b8bb1 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for packages/overpass (./packages/overpass)

Status Category Percentage Covered / Total
🟢 Lines 100% (🎯 80%) 155 / 155
🟢 Statements 100% (🎯 80%) 155 / 155
🟢 Functions 100% (🎯 80%) 13 / 13
🟢 Branches 95.65% (🎯 70%) 44 / 46
File CoverageNo changed files found.
Generated in workflow #582 for commit 97b8bb1 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for packages/utils (./packages/utils)

Status Category Percentage Covered / Total
🟢 Lines 100% (🎯 100%) 92 / 92
🟢 Statements 100% (🎯 100%) 92 / 92
🟢 Functions 100% (🎯 100%) 1 / 1
🟢 Branches 100% (🎯 100%) 1 / 1
File CoverageNo changed files found.
Generated in workflow #582 for commit 97b8bb1 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for packages/analytics (./packages/analytics)

Status Category Percentage Covered / Total
🟢 Lines 100% (🎯 80%) 745 / 745
🟢 Statements 100% (🎯 80%) 745 / 745
🟢 Functions 100% (🎯 85%) 48 / 48
🟢 Branches 87.35% (🎯 80%) 152 / 174
File CoverageNo changed files found.
Generated in workflow #582 for commit 97b8bb1 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for apps/expo (./apps/expo)

Status Category Percentage Covered / Total
🟢 Lines 97.64% (🎯 95%) 623 / 638
🟢 Statements 97.64% (🎯 95%) 623 / 638
🟢 Functions 100% (🎯 97%) 52 / 52
🟢 Branches 95.19% (🎯 92%) 218 / 229
File CoverageNo changed files found.
Generated in workflow #582 for commit 97b8bb1 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for packages/mcp (./packages/mcp)

Status Category Percentage Covered / Total
🟢 Lines 99.41% (🎯 80%) 4273 / 4298
🟢 Statements 99.41% (🎯 80%) 4273 / 4298
🟢 Functions 100% (🎯 80%) 101 / 101
🟢 Branches 98.67% (🎯 80%) 597 / 605
File CoverageNo changed files found.
Generated in workflow #582 for commit 97b8bb1 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for packages/api (./packages/api)

Status Category Percentage Covered / Total
🟢 Lines 99.01% (🎯 95%) 1906 / 1925
🟢 Statements 99.01% (🎯 95%) 1906 / 1925
🟢 Functions 100% (🎯 97%) 100 / 100
🟢 Branches 97.08% (🎯 92%) 665 / 685
File CoverageNo changed files found.
Generated in workflow #582 for commit 97b8bb1 by the Vitest Coverage Report Action

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Deploying packrat-guides with  Cloudflare Pages  Cloudflare Pages

Latest commit: 26d8009
Status: ✅  Deploy successful!
Preview URL: https://716cc8c2.packrat-guides-6gq.pages.dev
Branch Preview URL: https://worktree-issue-2672-offline.packrat-guides-6gq.pages.dev

View logs

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
packrat-admin 26d8009 Commit Preview URL

Branch Preview URL
Aug 09 2026, 02:49 PM

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file api ci/cd mobile web database labels Aug 9, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown
Contributor

Deploying packrat-landing with  Cloudflare Pages  Cloudflare Pages

Latest commit: 26d8009
Status: ✅  Deploy successful!
Preview URL: https://145f493c.packrat-landing.pages.dev
Branch Preview URL: https://worktree-issue-2672-offline.packrat-landing.pages.dev

View logs

@mikib0
mikib0 changed the base branch from main to development August 9, 2026 14:51
@mikib0
mikib0 changed the base branch from development to main August 9, 2026 14:51
@mikib0
mikib0 changed the base branch from main to development August 9, 2026 14:51
mikib0 added 2 commits August 9, 2026 18:04
Writes made while offline were stranded on-device permanently: there was no
outbox, no retry, and no path by which a locally-created pack or trip ever
reached the server. Every reference to the `local-` id prefix was a guard that
skipped the server rather than a push.

Add a durable outbox:

- `PendingMutation` SwiftData model — entity type, entity id, operation,
  optional parent id, JSON payload, attempt count and terminal-failure flag.
- `OutboxService` drains the queue serially in `createdAt` order on reconnect,
  on foreground, and at launch, so create-then-update replays in the order the
  user made it.
- Retire the `local-` prefix in Packs/Trips in favour of a plain client UUID.
  The API already accepts client-supplied ids on create, so an offline record
  is syncable as-is — no id reconciliation, no dangling child foreign keys.
- Thread that id through `createPack`/`addItem`/`createTrip` so a replayed
  create can't produce a duplicate record.
- Collapse redundant mutations on enqueue: create+delete of a never-synced
  entity cancels out, updates fold into a pending create, and consecutive
  updates collapse to the latest.
- Terminal-failure policy: 4xx is not retried (the payload is wrong), 5xx and
  transport errors retry up to 5 attempts, then mark the mutation failed and
  keep it for the UI rather than dropping it. A 404 on delete and 409 on
  create count as success — the server already agrees.
- Make delete consistent with create/update: a failed delete now stays deleted
  locally and flushes later instead of resurrecting the record.

Refs #2672
…tions

Device testing on iPhone 17 found the outbox silently dropped every delete: a
pack deleted offline vanished from the UI but no PendingMutation row was ever
written, so the delete never reached the server on reconnect.

`OutboxService.enqueue` starts with `guard let context else { return }`, and the
view-model write methods take `context: ModelContext? = nil`. The create/update
form views passed a context, but the delete paths and two item paths relied on
the default nil — so exactly the writes that needed queuing were discarded.

Pass `modelContext` at the call sites that were missing it:

- `TripsListView` deleteTrip (context menu and swipe action)
- `PackDetailView` deleteItem and updateItem
- `CatalogView` addItem in AddCatalogItemToPackSheet

Verified end-to-end against a local API with a real signed-in account: a pack
created offline reaches the server on reconnect, and a pack deleted offline
enqueues (`pack|delete|48d5961a…`), flushes on reconnect, and leaves the queue
empty with no failed rows.

Refs #2672
@mikib0
mikib0 force-pushed the worktree-issue-2672-offline-outbox branch from 26d8009 to 97b8bb1 Compare August 9, 2026 17:08
@github-actions github-actions Bot removed documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file api ci/cd mobile web labels Aug 9, 2026
@github-actions github-actions Bot removed the database label Aug 9, 2026
@mikib0
mikib0 marked this pull request as ready for review August 9, 2026 17:14
@mikib0
mikib0 merged commit acdcabf into development Aug 9, 2026
23 of 24 checks passed
@mikib0
mikib0 deleted the worktree-issue-2672-offline-outbox branch August 9, 2026 17:18

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/swift/Sources/PackRat/Services/OutboxService.swift`:
- Around line 126-146: Update OutboxService replay at
apps/swift/Sources/PackRat/Services/OutboxService.swift#L126-L146 to track
mutation entity IDs returning .retry or .terminal and skip later queued
mutations whose parentId matches. Update the .delete branch at
apps/swift/Sources/PackRat/Services/OutboxService.swift#L60-L89 to fetch and
delete child PendingMutation rows with parentId equal to the deleted entityId,
ensuring dependent work is not replayed after cancellation.
- Around line 293-295: Update the outbox flush loop around the
PackRatError.unauthorized handling so authentication failures use a non-counting
outcome that preserves the queued mutation without incrementing attemptCount,
then stop draining because subsequent mutations will encounter the same expired
session. Keep the existing retry behavior for other transient failures and
retain the re-authentication message.
- Around line 279-301: Update classify(_:operation:) to cast the Error
existential to PackRatError before pattern matching, then switch on the typed
enum. Preserve all existing outcome logic for httpError, notFound, unauthorized,
decodingError, and the default retry path.
- Around line 296-306: Update decode<T> and its callers so missing or unreadable
stored payloads produce the appropriate terminal outcome directly at the decode
site, rather than being passed to classify’s server-response message. Preserve
retry classification for unrelated errors and ensure lastError uses local
payload wording.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 03472f3a-ff62-49ed-bb8c-f78d9b2a3557

📥 Commits

Reviewing files that changed from the base of the PR and between a1c24ba and 97b8bb1.

📒 Files selected for processing (12)
  • apps/swift/Sources/PackRat/Features/Catalog/CatalogView.swift
  • apps/swift/Sources/PackRat/Features/Packs/PackDetailView.swift
  • apps/swift/Sources/PackRat/Features/Packs/PacksViewModel.swift
  • apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift
  • apps/swift/Sources/PackRat/Features/Trips/TripsViewModel.swift
  • apps/swift/Sources/PackRat/PackRatApp.swift
  • apps/swift/Sources/PackRat/Persistence/PendingMutation.swift
  • apps/swift/Sources/PackRat/Persistence/PersistenceController.swift
  • apps/swift/Sources/PackRat/Services/OutboxService.swift
  • apps/swift/Sources/PackRat/Services/PackService.swift
  • apps/swift/Sources/PackRat/Services/TripService.swift
  • apps/swift/Sources/PackRat/Shared/OutboxFlushModifier.swift

Comment on lines +126 to +146
for mutation in queued {
// Connectivity can drop mid-drain; stop and keep the rest queued.
guard NetworkMonitor.shared.isConnected else { break }
let outcome = await apply(mutation)
switch outcome {
case .success:
context.delete(mutation)
didSync = true
case .retry(let message):
mutation.attemptCount += 1
mutation.lastError = message
if mutation.attemptCount >= Self.maxAttempts {
mutation.failed = true
}
case .terminal(let message):
mutation.attemptCount += 1
mutation.lastError = message
mutation.failed = true
}
try? context.save()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The outbox ignores the parentId dependency between mutations. PendingMutation records parentId, but neither the cancel path nor the replay path uses it. Queued rows are treated as independent, so a pack item can outlive its pack or replay before its pack exists. Both sites below produce permanent failed rows for work the user expects to sync.

  • apps/swift/Sources/PackRat/Services/OutboxService.swift#L126-L146: track entities whose mutation returned .retry or .terminal in this pass, and skip any later mutation whose parentId is in that set, so a deferred pack create does not force its item creates into a terminal 404.
  • apps/swift/Sources/PackRat/Services/OutboxService.swift#L60-L89: in the .delete branch, also fetch and delete rows whose parentId equals the deleted entityId, so removing a pack cancels its queued item mutations instead of replaying them against a pack that never reached the server.
📍 Affects 1 file
  • apps/swift/Sources/PackRat/Services/OutboxService.swift#L126-L146 (this comment)
  • apps/swift/Sources/PackRat/Services/OutboxService.swift#L60-L89
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/swift/Sources/PackRat/Services/OutboxService.swift` around lines 126 -
146, Update OutboxService replay at
apps/swift/Sources/PackRat/Services/OutboxService.swift#L126-L146 to track
mutation entity IDs returning .retry or .terminal and skip later queued
mutations whose parentId matches. Update the .delete branch at
apps/swift/Sources/PackRat/Services/OutboxService.swift#L60-L89 to fetch and
delete child PendingMutation rows with parentId equal to the deleted entityId,
ensuring dependent work is not replayed after cancellation.

Comment on lines +279 to +301
private func classify(_ error: Error, operation: OutboxOperation) -> Outcome {
switch error {
case PackRatError.httpError(let statusCode, let message):
// The server already agrees with our intent.
if operation == .delete, statusCode == 404 { return .success }
if operation == .create, statusCode == 409 { return .success }
// 4xx means the request itself is wrong — retrying can't fix it.
if (400...499).contains(statusCode) {
return .terminal(message ?? "Server rejected the change (\(statusCode))")
}
// 5xx is transient.
return .retry(message ?? "Server error (\(statusCode))")
case PackRatError.notFound:
return operation == .delete ? .success : .terminal("The item no longer exists on the server")
case PackRatError.unauthorized:
// Keep queued: the write should survive a re-auth.
return .retry("Sign-in required to sync")
case PackRatError.decodingError:
return .terminal("Could not read the server response")
default:
return .retry(error.localizedDescription)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Show the PackRatError declaration and look for precedent of switching over an existential Error.
fd -i 'packraterror|apierror|error' apps/swift/Sources --extension swift --exec cat -n {} \; | head -200
rg -n --type=swift -B2 -A6 'switch error' apps/swift/Sources

Repository: PackRat-AI/PackRat

Length of output: 9764


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Swift files mentioning PackRatError =="
rg -n --type=swift 'enum PackRatError|typealias PackRatError|struct PackRatError|class PackRatError|import.*Error' apps/swift/Sources || true

echo
echo "== Switch/catch patterns with PackRatError =="
rg -n --type=swift -B3 -A8 'switch .*error|catch (PackRatError|[^,{}]+Error)|case PackRatError\.' apps/swift/Sources || true

echo
echo "== OutboxService classify snippet with surrounding context =="
sed -n '240,320p' apps/swift/Sources/PackRat/Services/OutboxService.swift

echo
echo "== PackRatError references == "
rg -n --type=swift 'PackRatError' apps/swift/Sources

Repository: PackRat-AI/PackRat

Length of output: 15169


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== APIError.swift =="
cat -n apps/swift/Sources/PackRat/Models/APIError.swift

echo
echo "== Swift availability/tools =="
command -v swift || true
swift --version 2>/dev/null || true

echo
echo "== Swift switch enum-case pattern probe if swift is available =="
if command -v swift >/dev/null 2>&1; then
  tmp="$(mktemp -d)"
  cat > "$tmp/swiftswitch.swift" <<'EOF'
enum PackRatError: Error {
    case httpError(statusCode: Int, message: String?)
    case notFound
}

func classify(_ error: Error) -> String {
    switch error {
    case PackRatError.httpError(let statusCode, let message):
        return "http \(statusCode) \(message ?? "")"
    case PackRatError.notFound:
        return "not found"
    default:
        return error.localizedDescription
    }
}
EOF
  swift "$tmp/swiftswitch.swift" 2>&1 || true
else
  echo "swift compiler not available"
fi

Repository: PackRat-AI/PackRat

Length of output: 1273


🌐 Web query:

Swift switch case enum pattern matching switch Error existential pattern matched directly Swift enum Error switch pattern case

💡 Result:

In Swift, you cannot directly use an enumeration case pattern to match against an existential type (like any Error) [1][2]. Because an existential type erases the concrete type information at compile time, the compiler cannot verify if the underlying value matches a specific enumeration case without a dynamic cast [1][3]. When you attempt to switch over an any Error value using an enum case pattern, the compiler will emit an error because enum case patterns require the value to be of the specific enum type [4][1]. To work around this, you must use a type-casting pattern [4]. There are two common approaches: 1. Type Casting in a Switch Case: You can cast the existential to a specific enum type within a case statement [5][6]. switch error { case let myError as MySpecificErrorEnum: // Handle specific enum cases here switch myError { case.someCase:... } case let otherError: // Handle other errors } 2. Casting in a Catch Block: When catching errors, you can use the same pattern-matching syntax to cast the error to your expected enum type [7][8]. do { try performAction } catch let error as MySpecificErrorEnum { // 'error' is now typed as MySpecificErrorEnum switch error { case.someCase:... } } catch { // Handle other errors } If you find yourself frequently switching over error existentials, you may want to consider using Swift's typed throws (introduced in later versions) if your API design allows it [9][10]. Typed throws (e.g., throws(MyError)) eliminate the need for existential boxing, allowing the compiler to know the exact error type and enabling exhaustive switch statements directly on the thrown error [9][10].

Citations:


Cast error before case-matching PackRatError.

PackRatError is an Error existential here, so switch error { case PackRatError.httpError(...) } is not a valid switch pattern. Use a cast in the first case and then switch on the typed enum.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/swift/Sources/PackRat/Services/OutboxService.swift` around lines 279 -
301, Update classify(_:operation:) to cast the Error existential to PackRatError
before pattern matching, then switch on the typed enum. Preserve all existing
outcome logic for httpError, notFound, unauthorized, decodingError, and the
default retry path.

Comment on lines +293 to +295
case PackRatError.unauthorized:
// Keep queued: the write should survive a re-auth.
return .retry("Sign-in required to sync")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

An expired session burns the retry budget and discards offline work.

.unauthorized returns .retry, and every .retry increments attemptCount. Foreground activation, reconnect, and launch each trigger a flush. Five of those with an expired token mark the mutation failed, even though nothing is wrong with the payload. The comment states the write should survive a re-auth, but it does not.

Do not charge auth failures against the attempt budget.

🐛 Proposed fix: add a non-counting outcome
     private enum Outcome {
         case success
         /// Transport-level failure — worth another attempt later.
         case retry(String)
+        /// Blocked by something outside the payload (for example a missing
+        /// session). Requeue without consuming an attempt.
+        case deferred(String)
         /// Server rejected the payload; retrying can't fix it.
         case terminal(String)
     }
         case PackRatError.unauthorized:
             // Keep queued: the write should survive a re-auth.
-            return .retry("Sign-in required to sync")
+            return .deferred("Sign-in required to sync")

Handle it in the flush loop, and stop the drain because every later mutation will hit the same wall:

             case .retry(let message):
                 mutation.attemptCount += 1
                 mutation.lastError = message
                 if mutation.attemptCount >= Self.maxAttempts {
                     mutation.failed = true
                 }
+            case .deferred(let message):
+                mutation.lastError = message
+                try? context.save()
+                return didSync
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/swift/Sources/PackRat/Services/OutboxService.swift` around lines 293 -
295, Update the outbox flush loop around the PackRatError.unauthorized handling
so authentication failures use a non-counting outcome that preserves the queued
mutation without incrementing attemptCount, then stop draining because
subsequent mutations will encounter the same expired session. Keep the existing
retry behavior for other transient failures and retain the re-authentication
message.

Comment on lines +296 to +306
case PackRatError.decodingError:
return .terminal("Could not read the server response")
default:
return .retry(error.localizedDescription)
}
}

private func decode<T: Decodable>(_ data: Data?) throws -> T {
guard let data else { throw PackRatError.decodingError(OutboxError.missingPayload) }
return try JSONDecoder().decode(T.self, from: data)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The error text blames the server for a local payload problem.

decode throws PackRatError.decodingError when the stored payload is missing or unreadable. classify then reports "Could not read the server response". That text is shown to the user through lastError, and it points at the wrong cause.

Return the terminal outcome at the decode site instead of routing a local failure through the server-response branch.

🐛 Proposed fix
         case PackRatError.decodingError:
             return .terminal("Could not read the server response")
+        case OutboxError.missingPayload:
+            return .terminal("The queued change is missing its data")
     private func decode<T: Decodable>(_ data: Data?) throws -> T {
-        guard let data else { throw PackRatError.decodingError(OutboxError.missingPayload) }
+        guard let data else { throw OutboxError.missingPayload }
         return try JSONDecoder().decode(T.self, from: data)
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case PackRatError.decodingError:
return .terminal("Could not read the server response")
default:
return .retry(error.localizedDescription)
}
}
private func decode<T: Decodable>(_ data: Data?) throws -> T {
guard let data else { throw PackRatError.decodingError(OutboxError.missingPayload) }
return try JSONDecoder().decode(T.self, from: data)
}
case PackRatError.decodingError:
return .terminal("Could not read the server response")
case OutboxError.missingPayload:
return .terminal("The queued change is missing its data")
default:
return .retry(error.localizedDescription)
}
}
private func decode<T: Decodable>(_ data: Data?) throws -> T {
guard let data else { throw OutboxError.missingPayload }
return try JSONDecoder().decode(T.self, from: data)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/swift/Sources/PackRat/Services/OutboxService.swift` around lines 296 -
306, Update decode<T> and its callers so missing or unreadable stored payloads
produce the appropriate terminal outcome directly at the decode site, rather
than being passed to classify’s server-response message. Preserve retry
classification for unrelated errors and ensure lastError uses local payload
wording.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Swift app: offline writes are stranded — no outbox, local- IDs never sync

1 participant