reis-mobile 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +54 -27
- package/agents/android-architect.md +3 -3
- package/agents/android-performance-engineer.md +1 -1
- package/agents/flutter-architect.md +3 -3
- package/agents/flutter-performance-engineer.md +1 -1
- package/agents/ios-architect.md +3 -3
- package/agents/ios-performance-engineer.md +1 -1
- package/agents/kmp-architect.md +3 -3
- package/agents/kmp-performance-engineer.md +1 -1
- package/agents/lead-mobile.md +3 -0
- package/agents/mobile-release-engineer.md +4 -4
- package/agents/mobile-staff-engineer.md +1 -1
- package/agents/rn-architect.md +3 -3
- package/agents/rn-performance-engineer.md +1 -1
- package/bin/reis-mobile.mjs +24 -1
- package/commands/reis-mobile.md +7 -2
- package/commands/release.md +46 -0
- package/core/release/context.mjs +11 -0
- package/core/router/intents.mjs +15 -0
- package/package.json +1 -1
- package/skills/mobile-accessibility/SKILL.md +3 -0
- package/skills/mobile-accessibility/references/localization.md +77 -0
- package/skills/mobile-observability/SKILL.md +137 -0
- package/skills/mobile-observability/references/android.md +105 -0
- package/skills/mobile-observability/references/flutter.md +86 -0
- package/skills/mobile-observability/references/ios.md +88 -0
- package/skills/mobile-observability/references/kotlin-multiplatform.md +76 -0
- package/skills/mobile-observability/references/react-native.md +62 -0
- package/skills/mobile-offline-sync/SKILL.md +132 -0
- package/skills/mobile-offline-sync/references/android.md +123 -0
- package/skills/mobile-offline-sync/references/flutter.md +103 -0
- package/skills/mobile-offline-sync/references/ios.md +103 -0
- package/skills/mobile-offline-sync/references/kotlin-multiplatform.md +114 -0
- package/skills/mobile-offline-sync/references/react-native.md +108 -0
- package/skills/mobile-performance/SKILL.md +88 -0
- package/skills/mobile-performance/references/android.md +96 -0
- package/skills/mobile-performance/references/background.md +87 -0
- package/skills/mobile-performance/references/flutter.md +101 -0
- package/skills/mobile-performance/references/ios.md +82 -0
- package/skills/mobile-performance/references/kotlin-multiplatform.md +62 -0
- package/skills/mobile-performance/references/react-native.md +85 -0
- package/skills/mobile-release/SKILL.md +11 -22
- package/skills/mobile-release/references/billing.md +92 -0
- package/skills/mobile-release/references/readiness.md +88 -0
- package/skills/mobile-release/scripts/verdict.mjs +59 -0
- package/skills/mobile-security/SKILL.md +3 -0
- package/skills/mobile-security/references/auth.md +92 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Observability in Kotlin Multiplatform
|
|
2
|
+
|
|
3
|
+
Two things are shared and one is not. The **instrumentation** — what you record, the event names, the attributes, the sampling decision, the redaction rules — belongs in `commonMain`, so both apps report the same names and a dashboard can compare them. The **reporting SDK** is per host: Crashlytics/Sentry on Android through the Android SDK, the same vendor on iOS through its Apple SDK. There is no cross-platform crash reporter that covers Kotlin/Native crashes as well as each platform's native reporter does.
|
|
4
|
+
|
|
5
|
+
## The boundary
|
|
6
|
+
|
|
7
|
+
```kotlin
|
|
8
|
+
// commonMain — the only API the shared code knows about
|
|
9
|
+
interface Telemetry {
|
|
10
|
+
fun breadcrumb(name: String, attributes: Map<String, String> = emptyMap())
|
|
11
|
+
fun setKey(key: String, value: String)
|
|
12
|
+
fun recordError(throwable: Throwable, fatal: Boolean = false)
|
|
13
|
+
fun startSpan(name: String): Span
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`androidMain` implements it over the Android SDK; `iosMain` implements it over the Apple SDK, or the iOS app injects a Swift implementation through the interface. Prefer injection from the host over `expect`/`actual` here: it keeps the vendor out of the shared dependency graph and makes `commonTest` a matter of passing a recording fake.
|
|
18
|
+
|
|
19
|
+
Everything else — which events exist, their attribute keys, the sampling rate, the redaction — lives in `commonMain` and is tested once.
|
|
20
|
+
|
|
21
|
+
## Kotlin/Native crashes on iOS
|
|
22
|
+
|
|
23
|
+
This is the part that surprises teams:
|
|
24
|
+
|
|
25
|
+
- An **unhandled Kotlin exception on iOS terminates the process**. By default it is reported as a native crash, and the report's usefulness depends entirely on the framework's dSYM being uploaded alongside the app's.
|
|
26
|
+
- `setUnhandledExceptionHook` lets you record the Kotlin exception through the host reporter *before* the process dies — use it to attach the shared-layer context (the screen, the operation, the outbox depth) that a raw native crash report will not carry. Do not use it to swallow the exception.
|
|
27
|
+
- Kotlin frames appear as addresses unless the framework is built with debug symbols and those symbols are uploaded. Check `binaryOptions` and the framework build configuration, and verify once with a deliberate release crash from shared code.
|
|
28
|
+
- `kotlin.native.binary.` options that strip symbols will make every shared-code crash unreadable. Confirm what the release framework actually ships.
|
|
29
|
+
|
|
30
|
+
On Android the shared code is ordinary JVM code: Kotlin exceptions reach the default uncaught handler and the reporter sees them normally, symbolicated through the R8 mapping file.
|
|
31
|
+
|
|
32
|
+
## Instrumenting the shared layer
|
|
33
|
+
|
|
34
|
+
- **Ktor:** a client plugin is the single place to generate the request id, add the header, record the span and drop a network breadcrumb — for both platforms at once.
|
|
35
|
+
|
|
36
|
+
```kotlin
|
|
37
|
+
val client = HttpClient {
|
|
38
|
+
install(createClientPlugin("Observability") {
|
|
39
|
+
onRequest { request, _ ->
|
|
40
|
+
request.headers.append("X-Request-Id", uuid4().toString())
|
|
41
|
+
}
|
|
42
|
+
onResponse { response ->
|
|
43
|
+
telemetry.breadcrumb("network", mapOf(
|
|
44
|
+
"host" to response.request.url.host, // host only, never the full URL
|
|
45
|
+
"status" to response.status.value.toString(),
|
|
46
|
+
))
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- **Repository and sync errors:** the shared sync engine ([mobile-offline-sync](../../mobile-offline-sync/SKILL.md)) is where the most valuable context lives — outbox depth, attempt counts, conflict outcomes. Record it as keys and breadcrumbs from `commonMain` so both apps report it identically.
|
|
53
|
+
- **Coroutine failures:** an exception in a shared `CoroutineScope` with no handler is lost. Install a `CoroutineExceptionHandler` that routes to `Telemetry.recordError`, and remember that a failing child in a plain `Job` cancels the scope — the report must say which.
|
|
54
|
+
- **Spans:** `OSSignposter` on iOS and trace sections on Android are host concerns; expose `startSpan` and let each `actual` map it, so the same span name shows up in Instruments and in a system trace.
|
|
55
|
+
|
|
56
|
+
## Segmentation
|
|
57
|
+
|
|
58
|
+
The dimensions in the skill's §4 apply, plus one KMP-specific pair worth setting as keys: the **shared module version** (if it is versioned independently of the apps) and the **target**. "Only on iOS" is the single most useful filter in a KMP dashboard, and it only exists if the target is an attribute on every signal.
|
|
59
|
+
|
|
60
|
+
## Platform field data
|
|
61
|
+
|
|
62
|
+
Android vitals, Play Console ANRs, Xcode Organizer and MetricKit all apply unchanged — the shared code runs inside two ordinary apps. See [android.md](android.md) and [ios.md](ios.md). The lab counterpart, including how to tell shared cost from host cost, is [mobile-performance/references/kotlin-multiplatform.md](../../mobile-performance/references/kotlin-multiplatform.md).
|
|
63
|
+
|
|
64
|
+
## Testing
|
|
65
|
+
|
|
66
|
+
- A recording fake `Telemetry` in `commonTest` asserts that a failing sync drain records the error once, with the expected keys, and does not log the payload.
|
|
67
|
+
- The redaction rules are shared code: test them against a payload containing a token, on both JVM and Native test targets.
|
|
68
|
+
- Sampling decisions should be deterministic given an injected seed or clock, so the test can assert "a failed session is always kept".
|
|
69
|
+
|
|
70
|
+
## Official documentation
|
|
71
|
+
|
|
72
|
+
- Kotlin Multiplatform: https://kotlinlang.org/docs/multiplatform.html
|
|
73
|
+
- Kotlin/Native and Swift/Objective-C interop: https://kotlinlang.org/docs/native-objc-interop.html
|
|
74
|
+
- Ktor client plugins: https://ktor.io/docs/client-custom-plugins.html
|
|
75
|
+
- Crashlytics: https://firebase.google.com/docs/crashlytics
|
|
76
|
+
- MetricKit: https://developer.apple.com/documentation/metrickit
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Observability in React Native and Expo
|
|
2
|
+
|
|
3
|
+
A React Native app fails in two runtimes. A **JavaScript error** produces a red screen in development and, in production, either a caught error boundary or a fatal that the native crash reporter never sees on its own. A **native crash** produces a normal Android/iOS crash with no JavaScript stack. A setup that covers only one of them explains half the incidents — and the half it misses is usually the harder one.
|
|
4
|
+
|
|
5
|
+
## Cover both runtimes
|
|
6
|
+
|
|
7
|
+
Use a reporter with a React Native SDK (`@sentry/react-native`, Bugsnag, Crashlytics via `@react-native-firebase/crashlytics`, Embrace). What to check in the configuration:
|
|
8
|
+
|
|
9
|
+
- **JavaScript fatals are reported as fatals**, not swallowed by a global handler that logs and continues. `ErrorUtils.setGlobalHandler` is the underlying mechanism; the SDK wraps it, and an app that installs its own handler afterwards silently disables reporting.
|
|
10
|
+
- **Unhandled promise rejections** are captured. In Hermes they do not reach the global error handler by default, and an SDK option or a rejection tracker is needed.
|
|
11
|
+
- **Native crashes** are captured by the SDK's Android and iOS layers — which means the native install steps (autolinking plus any Gradle/Podfile lines the SDK documents) must actually be present. Verify on a release build, not in Metro.
|
|
12
|
+
- **React error boundaries** report and then render a fallback. A boundary that catches and shows a blank screen without reporting is the most common invisible failure in RN apps.
|
|
13
|
+
|
|
14
|
+
## Source maps are the whole game
|
|
15
|
+
|
|
16
|
+
A production JavaScript stack trace is minified Hermes bytecode offsets. Without source maps uploaded per build, every report is unreadable:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# The release build emits the bundle and its map; upload both, per platform, per version.
|
|
20
|
+
npx react-native bundle --platform android --dev false \
|
|
21
|
+
--entry-file index.js \
|
|
22
|
+
--bundle-output android/app/build/generated/.../index.android.bundle \
|
|
23
|
+
--sourcemap-output index.android.bundle.map
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- The SDK's Gradle plugin / Xcode build phase usually automates the upload; confirm it ran for the shipped variant, and that the **release identifier** (version + build number + bundle hash) matches what the app reports at runtime. A mismatched release is the usual reason a correctly uploaded map still does not symbolicate.
|
|
27
|
+
- **Expo:** `eas build` uploads source maps when the SDK's config plugin is installed; **`eas update` publishes a new bundle without a new build**, so each OTA update needs its own source map upload and its own release identifier. An OTA-heavy app with build-time-only uploads has unreadable traces for every update after the first.
|
|
28
|
+
- Android also needs the R8 mapping file and iOS the dSYMs for native frames. Three artifacts, as in every cross-platform stack.
|
|
29
|
+
|
|
30
|
+
## OTA updates change what "app version" means
|
|
31
|
+
|
|
32
|
+
With `expo-updates` or CodePush, the native build and the JavaScript bundle version independently. Every signal must carry **both** — the native version/build and the update/bundle id — or you cannot tell which JavaScript is actually running on a device reporting a crash. Set the update id as a custom key at startup and include it in the release identifier used for source maps.
|
|
33
|
+
|
|
34
|
+
This is the single most important RN-specific item in the skill's §4 segmentation list.
|
|
35
|
+
|
|
36
|
+
## Context and breadcrumbs
|
|
37
|
+
|
|
38
|
+
- **Navigation:** React Navigation's `onStateChange`, or Expo Router's equivalent, produces a screen breadcrumb per transition. Most SDKs ship an integration that does it for you.
|
|
39
|
+
- **Network:** wrap `fetch`/`axios` once to generate the request id, add the `X-Request-Id` header, and record method, host, status and duration. SDK network breadcrumbs are on by default in some vendors and capture full URLs — review and restrict that before shipping, since query strings carry tokens.
|
|
40
|
+
- **Custom keys:** screen, auth state, flag values, update id, connectivity class from NetInfo, outbox depth.
|
|
41
|
+
- **Redux/Zustand/TanStack Query:** an action or query-error middleware that forwards failures with the state slice that caused them. Beware the vendor Redux integrations that attach the **entire state tree** to every event — that is both a cost and a privacy problem.
|
|
42
|
+
|
|
43
|
+
## Performance and native field data
|
|
44
|
+
|
|
45
|
+
- The RN SDKs report app-start, slow/frozen frames and, in newer versions, some JS-thread metrics. Treat them as indicators and read the platform's own data as the truth.
|
|
46
|
+
- Android vitals and Play Console ANR data, Xcode Organizer and MetricKit all still apply and still see the real process — see [android.md](android.md) and [ios.md](ios.md). An ANR in a React Native app is an Android ANR, reported by Play, and invisible from JavaScript.
|
|
47
|
+
- The lab counterparts are in [mobile-performance/references/react-native.md](../../mobile-performance/references/react-native.md).
|
|
48
|
+
|
|
49
|
+
## Privacy and consent
|
|
50
|
+
|
|
51
|
+
- The Play Data safety form and the iOS privacy nutrition label plus `PrivacyInfo.xcprivacy` must cover the native SDKs the JS packages pull in. Expo config plugins add native SDKs you never wrote a line against.
|
|
52
|
+
- Gate initialization on consent; most SDKs support initializing with sending disabled and enabling it later.
|
|
53
|
+
- Implement `beforeSend` (or the vendor's equivalent) to strip tokens, bodies and query strings, and unit-test it — it is plain JavaScript, so this is a cheap test with real value.
|
|
54
|
+
- `console.log` left in production ships strings into the device log and, with some SDK configurations, into breadcrumbs. Strip logs in release (`babel-plugin-transform-remove-console` or equivalent) or make the logger deliberate.
|
|
55
|
+
|
|
56
|
+
## Official documentation
|
|
57
|
+
|
|
58
|
+
- React Native source maps and symbolication: https://reactnative.dev/docs/symbolication
|
|
59
|
+
- Expo EAS Update: https://docs.expo.dev/eas-update/introduction/
|
|
60
|
+
- React Native Firebase Crashlytics: https://rnfirebase.io/crashlytics/usage
|
|
61
|
+
- Sentry for React Native: https://docs.sentry.io/platforms/react-native/
|
|
62
|
+
- NetInfo: https://github.com/react-native-netinfo/react-native-netinfo
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mobile-offline-sync
|
|
3
|
+
description: Designs and reviews offline-first mobile data on every stack — Flutter, native Android, native iOS, React Native and Kotlin Multiplatform. Covers the local store, the outbox of pending mutations, idempotency keys, retry and backoff, conflict resolution, reconnection, and how to test a flaky network deterministically. Use when an app must work without connectivity, when writes are lost or duplicated after reconnecting, or when local and server data disagree.
|
|
4
|
+
intents: [offline]
|
|
5
|
+
stacks: ["*"]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Mobile Offline Sync
|
|
9
|
+
|
|
10
|
+
One design for offline-capable mobile data, on every stack. This file holds the model and the failure cases; each platform's storage engines, queue runners and network-condition tooling live in its reference.
|
|
11
|
+
|
|
12
|
+
## 1. Pick the reference
|
|
13
|
+
|
|
14
|
+
| Project | Read |
|
|
15
|
+
|---|---|
|
|
16
|
+
| Flutter | [references/flutter.md](references/flutter.md) |
|
|
17
|
+
| Native Android | [references/android.md](references/android.md) |
|
|
18
|
+
| Native iOS | [references/ios.md](references/ios.md) |
|
|
19
|
+
| React Native or Expo | [references/react-native.md](references/react-native.md) |
|
|
20
|
+
| Kotlin Multiplatform | [references/kotlin-multiplatform.md](references/kotlin-multiplatform.md) |
|
|
21
|
+
|
|
22
|
+
Background scheduling — when the sync is actually allowed to run — is in [mobile-performance/references/background.md](../mobile-performance/references/background.md). Where the queue and the store belong in the app's layers is in [mobile-architecture](../mobile-architecture/SKILL.md).
|
|
23
|
+
|
|
24
|
+
## 2. Decide what "offline" means here, first
|
|
25
|
+
|
|
26
|
+
Most offline bugs come from building more than the product needs, or less. Pick one of these three before designing anything:
|
|
27
|
+
|
|
28
|
+
| Level | The app can… | Cost |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| **Read cache** | Show previously fetched data while offline; writes fail with a clear message | Low. A store and a freshness policy |
|
|
31
|
+
| **Queued writes** | Accept writes offline, apply them optimistically, send them on reconnect | Medium. Everything in §3–§6 |
|
|
32
|
+
| **Full replication** | Work indefinitely offline, with concurrent edits on several devices merged | High. Needs a server designed for it: versioning, per-record change history, or a CRDT/sync engine |
|
|
33
|
+
|
|
34
|
+
Level 2 is what most apps need and what this skill is written for. Do not build level 3 machinery for a product whose users are offline for minutes; do not sell level 1 as "works offline" when users expect their edits to survive.
|
|
35
|
+
|
|
36
|
+
## 3. The local store is the source of truth for the UI
|
|
37
|
+
|
|
38
|
+
Offline-first inverts the usual flow. The UI never reads from the network; it reads from the local store, and the network's only job is to fill and drain that store.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
UI ──reads──▶ local store ◀──writes── sync engine ◀──▶ server
|
|
42
|
+
▲ ▲
|
|
43
|
+
└──writes────────┘ (optimistic, immediately visible)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
What this requires:
|
|
47
|
+
|
|
48
|
+
- **A real database, not a cache of responses.** Rows with your own primary keys, queryable, with a schema you migrate. Caching JSON blobs keyed by URL cannot answer "show me this user's unsent orders".
|
|
49
|
+
- **Reactive reads.** The store emits on change (Flow, Stream, publisher, subscription) so an applied mutation or a completed sync updates every screen without a manual refresh.
|
|
50
|
+
- **Per-record sync state**, stored next to the record: `synced`, `pending`, `failed`, plus the server version/ETag it was last based on and the time it was fetched. Without it the UI cannot show "sending…" and the engine cannot tell a local edit from a server row.
|
|
51
|
+
- **A freshness policy.** How stale is too stale, per entity. A price list and a chat message do not have the same answer, and "refresh everything on launch" is what makes launches slow.
|
|
52
|
+
|
|
53
|
+
## 4. The outbox: every write is a durable, replayable intent
|
|
54
|
+
|
|
55
|
+
A mutation made offline is not a paused HTTP call. It is a record in a table.
|
|
56
|
+
|
|
57
|
+
An outbox entry carries: a **stable client-generated id** (UUID, created on the device), the **operation** and its payload, the **entity it targets**, the **attempt count**, the **next attempt time**, the **last error**, and the **creation time**.
|
|
58
|
+
|
|
59
|
+
Consequences that are easy to get wrong:
|
|
60
|
+
|
|
61
|
+
- **Generate ids on the client.** A record created offline needs an id immediately, because the UI shows it and later operations reference it. Use a UUID (or ULID, which sorts by time) as the real primary key and let the server accept it, rather than mapping a temporary id to a server id afterwards. If the server must assign the id, you need an explicit id-mapping table and every queued operation that references the new record must be rewritten when the id arrives — design for that deliberately or avoid it.
|
|
62
|
+
- **Order matters, per entity.** Create-then-update-then-delete on the same record must reach the server in that order. Process the queue in order per entity key; parallelize across unrelated entities only.
|
|
63
|
+
- **Collapse redundant operations before sending.** Three edits to the same field while offline are one PUT. A create followed by a delete of a record the server never saw is *nothing at all* — both entries are dropped. Doing this at drain time, not at enqueue time, keeps the UI history honest.
|
|
64
|
+
- **A failed entry must not block the queue forever.** After N attempts, move it to a dead-letter state, surface it to the user, and continue with the rest. A permanent 400 retried forever stops every later write.
|
|
65
|
+
- **The queue survives everything.** Process death, reboot, app update, low storage. That means a database table, not an in-memory list, and a migration story for the payload format when the app updates while entries are pending.
|
|
66
|
+
|
|
67
|
+
## 5. Idempotency: the network will lie to you
|
|
68
|
+
|
|
69
|
+
The dangerous failure is not "the request failed". It is "the request succeeded and the response was lost" — the device retries, and the user has two orders.
|
|
70
|
+
|
|
71
|
+
- **Send an idempotency key with every mutating request.** The client-generated operation id is that key; put it in a header (`Idempotency-Key` is the common spelling) or in the body. The server stores the key with the result and returns the *original* result for a repeat, instead of performing the operation again.
|
|
72
|
+
- **Without server support, retrying a POST is unsafe.** If the API has no idempotency mechanism, say so explicitly in the review: the choice is to add one server-side, to make the endpoint naturally idempotent (a PUT to a client-chosen id), or to accept duplicates. There is no client-only fix — a client that "checks whether it already exists" before retrying still races.
|
|
73
|
+
- **Only delete the outbox entry after a confirmed success.** Acknowledge, then delete, in that order; a crash between the two costs a retry, which idempotency makes harmless. The reverse order costs a lost write.
|
|
74
|
+
- **Deletes and increments need the same care.** `DELETE` is naturally idempotent for a given id; a relative operation ("add 1 to the counter") is not, and must be sent as an absolute value or carry a key.
|
|
75
|
+
|
|
76
|
+
## 6. Conflict resolution: name the strategy, per entity
|
|
77
|
+
|
|
78
|
+
A conflict is a server-side change to a record the device edited based on an older version. Detecting one requires a version: an ETag, a `version` integer, or an `updated_at` the server controls. **If the API returns no version, conflicts cannot be detected at all** — the last writer silently wins, and that must be stated as a finding rather than assumed to be the design.
|
|
79
|
+
|
|
80
|
+
| Strategy | Use when | Cost |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| **Server wins** | Reference data the user does not edit — catalogues, settings pushed by an admin | None; discard the local change |
|
|
83
|
+
| **Client wins (last write wins)** | Single-user data edited on one device at a time — a draft, a local preference | Silent data loss if the assumption breaks |
|
|
84
|
+
| **Field-level merge** | Records where different fields change independently — a profile, a form | Needs per-field dirty tracking in the outbox, not a whole-object PUT |
|
|
85
|
+
| **Ask the user** | The conflict is meaningful and rare — a document, an appointment | UI work, and a place to hold both versions until it is resolved |
|
|
86
|
+
| **Append-only / event log** | Values that accumulate rather than replace — comments, measurements, a ledger | Changes the data model; removes most conflicts by construction |
|
|
87
|
+
| **CRDT / sync engine** | Genuine multi-device concurrent editing | A library and a compatible backend; do not hand-roll it |
|
|
88
|
+
|
|
89
|
+
Two rules regardless of strategy: **send only the fields that changed** (a full-object PUT turns every concurrent edit into a conflict), and **never resolve a conflict by silently discarding user input** — if the local version loses, the user is told, or the version is kept somewhere recoverable.
|
|
90
|
+
|
|
91
|
+
## 7. Reconnection
|
|
92
|
+
|
|
93
|
+
- **Connectivity APIs report the interface, not reachability.** "Connected to Wi-Fi" is true on a captive portal, behind a dead VPN, and on a hotel network that resolves DNS to a login page. Treat the connectivity event as a *hint to try*, and treat a successful request as the only proof of connectivity.
|
|
94
|
+
- **Backoff with jitter.** Exponential, capped (a minute or two), with randomization. Without jitter, every device that lost the same backend retries in lockstep and re-creates the outage.
|
|
95
|
+
- **Distinguish the error classes.** Retry on timeouts, 5xx, and connection failures. Do not retry a 4xx other than 408/429 — it will fail identically forever. Honour `Retry-After` on 429 and 503.
|
|
96
|
+
- **Drain on the right triggers:** app foreground, connectivity regained, a periodic constrained background job, and an explicit user pull-to-refresh. Not a polling timer.
|
|
97
|
+
- **Single-flight the drain.** Two concurrent drains after a fast reconnect/disconnect/reconnect send everything twice; a mutex or a single worker with unique work policy prevents it.
|
|
98
|
+
|
|
99
|
+
## 8. Tell the user the truth
|
|
100
|
+
|
|
101
|
+
Offline UX fails in predictable ways, and each one is reviewable:
|
|
102
|
+
|
|
103
|
+
- [ ] The user can tell the difference between "saved on this device" and "saved on the server". A pending indicator per item, not one global banner.
|
|
104
|
+
- [ ] An optimistic update that later fails is **rolled back visibly**, with the reason and a retry — never silently reverted, never left looking saved.
|
|
105
|
+
- [ ] Stale data shows its age ("updated 2 hours ago") where staleness matters.
|
|
106
|
+
- [ ] Actions that genuinely cannot work offline are disabled with an explanation, not offered and then failed.
|
|
107
|
+
- [ ] Dead-lettered operations are reachable and retryable or discardable by the user; nothing is lost in silence.
|
|
108
|
+
- [ ] Logging out, or clearing data, with a non-empty outbox asks the user first.
|
|
109
|
+
|
|
110
|
+
## 9. Test the failure cases, not the happy path
|
|
111
|
+
|
|
112
|
+
An offline feature that was only tested by toggling airplane mode is untested. The cases that break real implementations:
|
|
113
|
+
|
|
114
|
+
1. Write offline → kill the process → relaunch → reconnect. The write must still be sent.
|
|
115
|
+
2. Write → the response is lost after the server committed → retry. Exactly one record must exist.
|
|
116
|
+
3. Two writes to the same record offline → reconnect. The server must end up with the final state, in order.
|
|
117
|
+
4. Write offline → the same record is changed on the server → reconnect. The configured conflict strategy must be what actually happens.
|
|
118
|
+
5. Reconnect with a large queue → the app is backgrounded mid-drain. The drain resumes and does not duplicate.
|
|
119
|
+
6. A permanently rejected operation (validation error). The queue continues; the user is told.
|
|
120
|
+
7. Clock skew: the device's clock is hours off. Nothing that decides ordering or expiry may depend on the device clock.
|
|
121
|
+
8. App update with pending entries whose payload format changed.
|
|
122
|
+
|
|
123
|
+
Each reference names the platform's tools for forcing these conditions — network link conditioners, emulator network profiles, and the fake/test doubles that make cases 2 and 4 deterministic instead of a race. Test-harness patterns are in [mobile-test](../mobile-test/SKILL.md).
|
|
124
|
+
|
|
125
|
+
## 10. Evidence and severity
|
|
126
|
+
|
|
127
|
+
Point to `file:line`, and name which of the nine cases in §9 the code fails.
|
|
128
|
+
|
|
129
|
+
- **Critical:** data loss or duplication — writes dropped on process death, retries without idempotency, a conflict strategy that discards user input silently, an outbox held in memory.
|
|
130
|
+
- **High:** the feature does not work as promised — the queue blocked by one failed entry, no backoff (battery and a self-inflicted outage), no conflict detection where concurrent edits are expected, optimistic updates never rolled back.
|
|
131
|
+
- **Medium:** friction and waste — no operation collapsing, refetching everything on every launch, a connectivity check trusted as reachability.
|
|
132
|
+
- **Low:** polish — missing per-item pending state, no age on stale data.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Offline sync on native Android
|
|
2
|
+
|
|
3
|
+
Android ships every piece this needs: Room for the store and the outbox, WorkManager for a drain that survives process death and reboot, and `ConnectivityManager` for the trigger. The mistakes are almost always in how they are combined, not in the APIs themselves.
|
|
4
|
+
|
|
5
|
+
## The store and the outbox
|
|
6
|
+
|
|
7
|
+
Room, with the sync state on the entity and a separate outbox table:
|
|
8
|
+
|
|
9
|
+
```kotlin
|
|
10
|
+
@Entity(tableName = "orders")
|
|
11
|
+
data class OrderEntity(
|
|
12
|
+
@PrimaryKey val id: String, // client-generated UUID
|
|
13
|
+
val total: Long,
|
|
14
|
+
val syncStatus: SyncStatus, // SYNCED, PENDING, FAILED
|
|
15
|
+
val serverVersion: String?, // ETag or version from the last successful fetch
|
|
16
|
+
val updatedAt: Long,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
@Entity(tableName = "outbox")
|
|
20
|
+
data class OutboxEntry(
|
|
21
|
+
@PrimaryKey val id: String, // operation UUID = idempotency key
|
|
22
|
+
val entityType: String,
|
|
23
|
+
val entityId: String,
|
|
24
|
+
val operation: String, // CREATE | UPDATE | DELETE
|
|
25
|
+
val payloadJson: String, // changed fields only
|
|
26
|
+
val attempts: Int = 0,
|
|
27
|
+
val nextAttemptAt: Long,
|
|
28
|
+
val lastError: String? = null,
|
|
29
|
+
val createdAt: Long,
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Write the optimistic change and the outbox entry inside one `@Transaction` DAO method. Expose reads as `Flow<List<OrderEntity>>` so the UI updates when either the optimistic write or the sync lands.
|
|
34
|
+
|
|
35
|
+
For paged data backed by a server, Paging 3's `RemoteMediator` is the supported pattern: the `PagingSource` reads Room, the mediator fills Room from the network, and the UI never sees a network state machine.
|
|
36
|
+
|
|
37
|
+
## The drain
|
|
38
|
+
|
|
39
|
+
`WorkManager`, as **unique** work, with backoff delegated to the platform:
|
|
40
|
+
|
|
41
|
+
```kotlin
|
|
42
|
+
val request = OneTimeWorkRequestBuilder<SyncWorker>()
|
|
43
|
+
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
|
|
44
|
+
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
|
45
|
+
.build()
|
|
46
|
+
|
|
47
|
+
WorkManager.getInstance(context).enqueueUniqueWork(
|
|
48
|
+
"sync-outbox",
|
|
49
|
+
ExistingWorkPolicy.KEEP, // a burst of reconnect events must not queue five drains
|
|
50
|
+
request,
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Inside the worker:
|
|
55
|
+
|
|
56
|
+
- Process entries **in order per entity**; parallelize only across unrelated entities.
|
|
57
|
+
- Return `Result.retry()` for transient failures — WorkManager applies the backoff policy and re-runs after process death or reboot. Returning `Result.failure()` discards the work.
|
|
58
|
+
- Handle a permanently rejected entry by dead-lettering it in the database and **continuing**, not by failing the whole worker.
|
|
59
|
+
- Enqueue the drain with `ExistingWorkPolicy.KEEP` (or `APPEND_OR_REPLACE` when ordering across enqueues matters) so reconnect bursts do not start concurrent drains.
|
|
60
|
+
- `setExpedited` only when the user is waiting; an ordinary background drain does not qualify and the quota is limited.
|
|
61
|
+
|
|
62
|
+
Constraints and foreground-service rules are in [background.md](../../mobile-performance/references/background.md). A periodic sync has a 15-minute floor and is never exact.
|
|
63
|
+
|
|
64
|
+
## Idempotency over Retrofit/OkHttp
|
|
65
|
+
|
|
66
|
+
Put the entry's id on the request as the idempotency key, and make sure **OkHttp's own retries do not hide a failure you need to record**:
|
|
67
|
+
|
|
68
|
+
```kotlin
|
|
69
|
+
@POST("orders")
|
|
70
|
+
suspend fun createOrder(
|
|
71
|
+
@Header("Idempotency-Key") key: String,
|
|
72
|
+
@Body body: OrderPayload,
|
|
73
|
+
): Response<OrderDto>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`retryOnConnectionFailure` (on by default) retries connection-level failures transparently. That is safe *only* because the key makes the call idempotent; without server-side key handling it is a duplicate-creation mechanism. An `Interceptor` that retries on 5xx compounds the problem — keep retry scheduling in WorkManager, where it survives the process, rather than in an interceptor, where it does not.
|
|
77
|
+
|
|
78
|
+
## Conflict detection
|
|
79
|
+
|
|
80
|
+
Send the version you based the edit on and let the server reject:
|
|
81
|
+
|
|
82
|
+
```kotlin
|
|
83
|
+
@PUT("orders/{id}")
|
|
84
|
+
suspend fun updateOrder(
|
|
85
|
+
@Path("id") id: String,
|
|
86
|
+
@Header("If-Match") version: String,
|
|
87
|
+
@Header("Idempotency-Key") key: String,
|
|
88
|
+
@Body body: Map<String, Any?>, // changed fields only
|
|
89
|
+
): Response<OrderDto>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A `412 Precondition Failed` is the conflict signal; apply the strategy chosen per entity in the skill's §6. If the API has no ETag or version field, record it as a finding — conflicts are undetectable and the design is last-write-wins by accident.
|
|
93
|
+
|
|
94
|
+
## Connectivity as a trigger
|
|
95
|
+
|
|
96
|
+
```kotlin
|
|
97
|
+
connectivityManager.registerNetworkCallback(
|
|
98
|
+
NetworkRequest.Builder()
|
|
99
|
+
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
100
|
+
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) // not just "an interface"
|
|
101
|
+
.build(),
|
|
102
|
+
callback,
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`NET_CAPABILITY_VALIDATED` excludes most captive portals, which a plain `NET_CAPABILITY_INTERNET` check does not. Even so, treat it as a hint: enqueue the WorkManager request and let its `NetworkType.CONNECTED` constraint be the real gate. Also drain on `ON_START` of the process lifecycle and on explicit user refresh.
|
|
107
|
+
|
|
108
|
+
## Testing
|
|
109
|
+
|
|
110
|
+
- **Room** has an in-memory builder (`Room.inMemoryDatabaseBuilder`), so ordering, collapsing, dead-lettering and transaction atomicity are JVM unit tests.
|
|
111
|
+
- **WorkManager** ships `androidx.work:work-testing`: `WorkManagerTestInitHelper` with a `SynchronousExecutor` plus `TestDriver.setAllConstraintsMet`/`setInitialDelayMet` makes the drain deterministic, including the retry path.
|
|
112
|
+
- **The API** should be faked at the repository boundary, or served by MockWebServer when you need real HTTP semantics — MockWebServer can return a 200 the client never receives (`SocketPolicy.DISCONNECT_AFTER_REQUEST`), which is exactly the lost-response case.
|
|
113
|
+
- **Device conditions:** the emulator's cellular/network delay and speed settings, `adb shell svc wifi disable` / `svc data disable`, and `adb shell cmd connectivity airplane-mode enable` for a scripted offline window.
|
|
114
|
+
- Process death: `adb shell am kill <package>` mid-drain, then relaunch and assert exactly one server-side record.
|
|
115
|
+
|
|
116
|
+
## Official documentation
|
|
117
|
+
|
|
118
|
+
- Offline-first app architecture: https://developer.android.com/topic/architecture/data-layer/offline-first
|
|
119
|
+
- Room: https://developer.android.com/training/data-storage/room
|
|
120
|
+
- WorkManager: https://developer.android.com/topic/libraries/architecture/workmanager
|
|
121
|
+
- Testing WorkManager: https://developer.android.com/topic/libraries/architecture/workmanager/how-to/integration-testing
|
|
122
|
+
- Monitoring connectivity status: https://developer.android.com/training/monitoring-device-state/connectivity-status-type
|
|
123
|
+
- Paging with a network and database: https://developer.android.com/topic/libraries/architecture/paging/v3-network-db
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Offline sync in Flutter
|
|
2
|
+
|
|
3
|
+
Flutter has no built-in persistence, so the first decision is the store. The second is where the sync engine lives: it must outlive any widget, so it belongs in a repository/service registered in the app's DI container — never in a `State` object, and never behind a `ChangeNotifier` that a route disposes.
|
|
4
|
+
|
|
5
|
+
## The local store
|
|
6
|
+
|
|
7
|
+
| Package | Fits when | Notes |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `drift` | You want SQL, migrations and typed reactive queries | `Stream` per query out of the box; the usual default for a real offline store |
|
|
10
|
+
| `sqflite` | You want SQLite with no code generation | You write the SQL, the mapping and the change notifications yourself |
|
|
11
|
+
| `objectbox`, `isar`, `realm` | Object graphs, heavy local querying | Check the package's current maintenance status and platform support before adopting it |
|
|
12
|
+
| `hive` / `shared_preferences` | Key–value settings, a small cache | Not a store for records you query, filter or sync per field |
|
|
13
|
+
| `cloud_firestore` | The backend is Firestore | Offline persistence and conflict handling are built in; see [mobile-firebase](../../mobile-firebase/SKILL.md) |
|
|
14
|
+
|
|
15
|
+
Whatever the choice, model the sync state as columns on the record — `syncStatus`, `serverVersion`, `updatedAt`, `lastError` — and expose reads as `Stream`s so the UI reflects an applied mutation without a refresh call.
|
|
16
|
+
|
|
17
|
+
## The outbox
|
|
18
|
+
|
|
19
|
+
A table, not a list:
|
|
20
|
+
|
|
21
|
+
```dart
|
|
22
|
+
// drift
|
|
23
|
+
class Outbox extends Table {
|
|
24
|
+
TextColumn get id => text()(); // client UUID = idempotency key
|
|
25
|
+
TextColumn get entityType => text()();
|
|
26
|
+
TextColumn get entityId => text()();
|
|
27
|
+
TextColumn get operation => text()(); // create | update | delete
|
|
28
|
+
TextColumn get payload => text()(); // JSON of changed fields only
|
|
29
|
+
IntColumn get attempts => integer().withDefault(const Constant(0))();
|
|
30
|
+
DateTimeColumn get nextAttemptAt => dateTime()();
|
|
31
|
+
TextColumn get lastError => text().nullable()();
|
|
32
|
+
DateTimeColumn get createdAt => dateTime()();
|
|
33
|
+
@override Set<Column> get primaryKey => {id};
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Enqueue the outbox entry and apply the optimistic local change **in one transaction**. If they are separate writes, a crash between them leaves the UI showing an edit that will never be sent, or sends an edit the UI never showed.
|
|
38
|
+
|
|
39
|
+
## Draining
|
|
40
|
+
|
|
41
|
+
```dart
|
|
42
|
+
Future<void> drain() async {
|
|
43
|
+
if (_draining) return; // single-flight: reconnect events arrive in bursts
|
|
44
|
+
_draining = true;
|
|
45
|
+
try {
|
|
46
|
+
for (final entry in await _outbox.dueEntriesInOrder()) {
|
|
47
|
+
try {
|
|
48
|
+
await _api.send(entry, idempotencyKey: entry.id);
|
|
49
|
+
await _db.transaction(() async {
|
|
50
|
+
await _outbox.delete(entry.id);
|
|
51
|
+
await _records.markSynced(entry.entityId);
|
|
52
|
+
});
|
|
53
|
+
} on ApiException catch (e) when (e.isPermanent) {
|
|
54
|
+
await _outbox.deadLetter(entry.id, e.message); // do not block the queue
|
|
55
|
+
} on Exception catch (e) {
|
|
56
|
+
await _outbox.scheduleRetry(entry.id, backoffFor(entry.attempts + 1), e.toString());
|
|
57
|
+
if (e is SocketException) break; // offline again: stop early
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
_draining = false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Points that reviews catch: the `_draining` guard, deleting the entry only after the call returned, doing the delete and the record update in one transaction, and breaking out of the loop when the device is offline instead of failing every remaining entry and burning its attempt count.
|
|
67
|
+
|
|
68
|
+
## Triggers and connectivity
|
|
69
|
+
|
|
70
|
+
- `connectivity_plus` reports the **interface**, not reachability — Wi-Fi with a captive portal reports Wi-Fi. Use its stream as a trigger to try, and treat a successful request as the only proof. `internet_connection_checker_plus` adds an actual reachability probe if you want one, but a real API call is just as good a probe.
|
|
71
|
+
- `WidgetsBindingObserver.didChangeAppLifecycleState` → drain on `resumed`.
|
|
72
|
+
- Background drains go through the `workmanager` plugin (or an equivalent) with the platform constraints from [background.md](../../mobile-performance/references/background.md) — remember the background isolate has no access to your DI container and must build its own, and that iOS gives no guaranteed schedule.
|
|
73
|
+
- Never a `Timer.periodic`.
|
|
74
|
+
|
|
75
|
+
## Retries
|
|
76
|
+
|
|
77
|
+
Dart has no built-in backoff. Either use `package:retry` or compute it yourself, and always add jitter:
|
|
78
|
+
|
|
79
|
+
```dart
|
|
80
|
+
Duration backoffFor(int attempt) {
|
|
81
|
+
final base = Duration(seconds: 1 << attempt.clamp(0, 6)); // 1s … 64s
|
|
82
|
+
final jitter = Random().nextInt(base.inMilliseconds ~/ 2);
|
|
83
|
+
return base + Duration(milliseconds: jitter);
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Retry on `SocketException`, `TimeoutException`, and 5xx/408/429. Do not retry other 4xx — dead-letter them. If the HTTP client is Dio, `dio_smart_retry` or a custom `Interceptor` can hold this, but keep the *scheduling* in the outbox: an interceptor that retries in-process loses the retry when the app is killed.
|
|
88
|
+
|
|
89
|
+
## Testing the failure cases
|
|
90
|
+
|
|
91
|
+
- Fake the API layer, not `http`: a `FakeApi` that can return success-after-committing (case 2 in the skill's §9) and a stale-version conflict (case 4) makes those tests deterministic. Mocking at the socket level cannot express "the server committed but the response was lost".
|
|
92
|
+
- `drift` runs on an in-memory database (`NativeDatabase.memory()`), so outbox behaviour — ordering, collapsing, dead-lettering, transaction atomicity — is a fast unit test with no device.
|
|
93
|
+
- Process-death (case 1) is an integration test: enqueue, close the database, reopen it, drain.
|
|
94
|
+
- Use `fakeAsync` or an injected clock for backoff timing; never `await Future.delayed` in tests.
|
|
95
|
+
- Device-level network throttling: the iOS Network Link Conditioner and the Android emulator's network profile settings.
|
|
96
|
+
|
|
97
|
+
## Official documentation
|
|
98
|
+
|
|
99
|
+
- Flutter persistence cookbook: https://docs.flutter.dev/cookbook/persistence
|
|
100
|
+
- drift: https://drift.simonbinder.eu/
|
|
101
|
+
- connectivity_plus: https://pub.dev/packages/connectivity_plus
|
|
102
|
+
- workmanager: https://pub.dev/packages/workmanager
|
|
103
|
+
- Cloud Firestore offline data: https://firebase.google.com/docs/firestore/manage-data/enable-offline
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Offline sync on native iOS
|
|
2
|
+
|
|
3
|
+
iOS gives you two very different routes: **let the system sync for you** with Core Data + CloudKit, or **own the sync** with a local store, an outbox and `URLSession`. Choosing the first when the backend is not CloudKit, or hand-rolling the second when CloudKit would have done, is the most expensive mistake in this area.
|
|
4
|
+
|
|
5
|
+
## Choose the route first
|
|
6
|
+
|
|
7
|
+
| Route | Fits when |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `NSPersistentCloudKitContainer` (Core Data + CloudKit) | Apple-only product, per-user private data, no custom backend. Apple handles transport, retries, merge policy and multi-device |
|
|
10
|
+
| SwiftData with CloudKit | Same, on a newer API surface; check the deployment target and the feature gaps against Core Data before committing |
|
|
11
|
+
| Local store + your own outbox | There is a REST/GraphQL backend, or Android/web clients share the data. This is the rest of this document |
|
|
12
|
+
| A sync SDK (Firestore, Realm/Atlas Device Sync, PowerSync…) | The backend is that product; offline behaviour comes with it — read its conflict rules rather than inventing your own |
|
|
13
|
+
|
|
14
|
+
## The local store
|
|
15
|
+
|
|
16
|
+
- **Core Data** or **SwiftData** for object graphs with relationships and change notification. Enable `NSPersistentHistoryTrackingKey` when a background context or an extension writes, so the UI context can merge remote-ish changes correctly.
|
|
17
|
+
- **GRDB** or raw SQLite when you want SQL, explicit migrations and value types; `ValueObservation` gives reactive reads.
|
|
18
|
+
- `UserDefaults` is for settings. It is not a store for syncable records, and it is not transactional.
|
|
19
|
+
|
|
20
|
+
Keep sync state on the record: `syncStatus`, `serverVersion` (ETag), `updatedAt`, `lastError`.
|
|
21
|
+
|
|
22
|
+
## The outbox
|
|
23
|
+
|
|
24
|
+
A table/entity, written **in the same context save** as the optimistic change:
|
|
25
|
+
|
|
26
|
+
```swift
|
|
27
|
+
struct OutboxEntry: Codable, Identifiable {
|
|
28
|
+
let id: UUID // operation id = idempotency key
|
|
29
|
+
let entityType: String
|
|
30
|
+
let entityID: String
|
|
31
|
+
let operation: Operation // create / update / delete
|
|
32
|
+
let payload: Data // changed fields only
|
|
33
|
+
var attempts: Int
|
|
34
|
+
var nextAttemptAt: Date
|
|
35
|
+
var lastError: String?
|
|
36
|
+
let createdAt: Date
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
In Core Data, perform the optimistic write and the outbox insert on one background context and save once. Two saves mean a crash can leave one without the other.
|
|
41
|
+
|
|
42
|
+
Use an actor for the drain so concurrent triggers cannot interleave:
|
|
43
|
+
|
|
44
|
+
```swift
|
|
45
|
+
actor SyncEngine {
|
|
46
|
+
private var isDraining = false
|
|
47
|
+
|
|
48
|
+
func drain() async {
|
|
49
|
+
guard !isDraining else { return }
|
|
50
|
+
isDraining = true
|
|
51
|
+
defer { isDraining = false }
|
|
52
|
+
|
|
53
|
+
for entry in await outbox.dueEntriesInOrder() {
|
|
54
|
+
do {
|
|
55
|
+
try await api.send(entry, idempotencyKey: entry.id.uuidString)
|
|
56
|
+
try await store.confirm(entry) // delete entry + mark record synced, one save
|
|
57
|
+
} catch let error as APIError where error.isPermanent {
|
|
58
|
+
try? await outbox.deadLetter(entry, reason: error.message)
|
|
59
|
+
} catch {
|
|
60
|
+
try? await outbox.scheduleRetry(entry, after: backoff(entry.attempts + 1))
|
|
61
|
+
if error.isOffline { break }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## URLSession
|
|
69
|
+
|
|
70
|
+
- **`waitsForConnectivity`** on the session configuration makes a request wait for connectivity instead of failing immediately — useful for a user-initiated send, but it does not replace the outbox: the app can still be terminated while waiting.
|
|
71
|
+
- **A background configuration** (`URLSessionConfiguration.background(withIdentifier:)`) hands the transfer to the system, which continues it while the app is suspended and relaunches the app to deliver completion. Use it for uploads and downloads of files; it is not suited to a chatty JSON queue, and the completion handler must work with no UI state in memory.
|
|
72
|
+
- Set `Idempotency-Key` from the entry id on every mutating request, and `If-Match` with the stored ETag on updates. A `412` is the conflict signal.
|
|
73
|
+
- `URLSession` does not retry application-level failures. Schedule retries from the outbox, not by recursing in the completion handler.
|
|
74
|
+
|
|
75
|
+
## Triggers
|
|
76
|
+
|
|
77
|
+
- **Foreground:** `scenePhase` becoming `.active`, or `UIApplication.didBecomeActiveNotification`.
|
|
78
|
+
- **Connectivity:** `NWPathMonitor` (Network framework). `path.status == .satisfied` means a usable interface, not a reachable server — use it as a hint and let the request be the proof. Check `path.isExpensive` and `path.isConstrained` before draining a large queue on cellular or Low Data Mode.
|
|
79
|
+
- **Background:** `BGAppRefreshTaskRequest` for short opportunistic drains, `BGProcessingTaskRequest` for a large one while charging, and a background `URLSession` for transfers that must complete. There is no guaranteed schedule — see [background.md](../../mobile-performance/references/background.md). Always schedule the next request at the end of a run, set the `expirationHandler`, and call `setTaskCompleted(success:)`.
|
|
80
|
+
- **Never** a `Timer` that polls.
|
|
81
|
+
|
|
82
|
+
## Conflict resolution
|
|
83
|
+
|
|
84
|
+
With your own backend: store the ETag, send `If-Match`, apply the per-entity strategy from the skill's §6 on `412`.
|
|
85
|
+
|
|
86
|
+
With Core Data and CloudKit, the merge policy *is* the strategy, and it is not always the one you want: `NSMergeByPropertyObjectTrumpMergePolicy` keeps local values per property, `...StoreTrumpMergePolicy` keeps the persisted ones, and `NSOverwriteMergePolicy`/`NSRollbackMergePolicy` are whole-object. Choose it explicitly on each context rather than inheriting `NSErrorMergePolicy` and discovering conflicts as save failures in production.
|
|
87
|
+
|
|
88
|
+
## Testing
|
|
89
|
+
|
|
90
|
+
- Put the network behind a protocol and inject a fake in tests; `URLProtocol` subclassing works when you need the real `URLSession` path, including a response that arrives after the server committed.
|
|
91
|
+
- Core Data with an in-memory store (`NSInMemoryStoreType`, or `/dev/null` for a SQLite store) makes outbox ordering, collapsing and transaction atomicity fast unit tests. SwiftData takes an in-memory `ModelConfiguration`.
|
|
92
|
+
- Inject a clock rather than reading `Date()` in backoff and expiry logic, so timing is deterministic.
|
|
93
|
+
- Device conditions: the **Network Link Conditioner** (Settings → Developer on device, or the Additional Tools package on the Mac) for loss and latency profiles, and Xcode's simulated network conditions in the debug gauges.
|
|
94
|
+
- Background task runs can be triggered from the debugger with the documented LLDB call to `_simulateLaunchForTaskWithIdentifier:` — the only practical way to test a `BGTask` path without waiting for the system.
|
|
95
|
+
|
|
96
|
+
## Official documentation
|
|
97
|
+
|
|
98
|
+
- Core Data: https://developer.apple.com/documentation/coredata
|
|
99
|
+
- Mirroring a Core Data store with CloudKit: https://developer.apple.com/documentation/coredata/mirroring_a_core_data_store_with_cloudkit
|
|
100
|
+
- SwiftData: https://developer.apple.com/documentation/swiftdata
|
|
101
|
+
- NWPathMonitor: https://developer.apple.com/documentation/network/nwpathmonitor
|
|
102
|
+
- Downloading files in the background: https://developer.apple.com/documentation/foundation/url_loading_system/downloading_files_in_the_background
|
|
103
|
+
- Background tasks: https://developer.apple.com/documentation/backgroundtasks
|