|
| 1 | +# Plan: Background-Alive Sync Path |
| 2 | + |
| 3 | +**Date:** 2026-04-02 |
| 4 | +**Status:** Implemented |
| 5 | + |
| 6 | +--- |
| 7 | + |
| 8 | +## Problem Statement |
| 9 | + |
| 10 | +When a push notification arrives while the app is **backgrounded but not terminated**, the current code falls through to `executeBackgroundSync()` and opens a second write connection even though the provider is still mounted with an existing open connection. This creates concurrent writers and relies on SQLite locking rather than single-connection ownership. |
| 11 | + |
| 12 | +--- |
| 13 | + |
| 14 | +## Three-State Model |
| 15 | + |
| 16 | +| App State | Connection | Behavior | |
| 17 | +|-----------|-----------|----------| |
| 18 | +| `active` | reuse existing | `performSync()` via `foregroundSyncCallback` | |
| 19 | +| backgrounded + alive | reuse existing | `performSync()` via `foregroundSyncCallback` — same callback | |
| 20 | +| terminated | open new | `executeBackgroundSync` → `registerBackgroundSyncCallback` | |
| 21 | + |
| 22 | +Both the active and backgrounded-alive cases call the same `foregroundSyncCallback` (`() => performSyncRef.current?.() ?? Promise.resolve()`). This is correct because `performSync` already has all necessary guards built in (`isSyncingRef`, `writeDbRef`, `isSyncReady`, Android network check). React state updates (`isSyncing`, `lastSyncTime`, etc.) queued while backgrounded apply harmlessly on next render when the user returns. |
| 23 | + |
| 24 | +The distinction between active and backgrounded-alive is only relevant at the **notification layer** in the example app (see Phase 3). |
| 25 | + |
| 26 | +--- |
| 27 | + |
| 28 | +## Why Not a Separate `backgroundAliveSyncCallback` |
| 29 | + |
| 30 | +The callback would be identical to `foregroundSyncCallback`. Two module-level variables holding the same function serves no purpose. `performSync` already guards against all the failure modes that would require a different implementation. |
| 31 | + |
| 32 | +--- |
| 33 | + |
| 34 | +## Will `useOnTableUpdate` Fire When Backgrounded? |
| 35 | + |
| 36 | +OP-SQLite's `updateHook` is a SQLite C callback that fires synchronously on the thread executing the write. When the app is backgrounded but alive, the JS runtime thread is still active and running the background task. The hook dispatch should work. |
| 37 | + |
| 38 | +**Caveat:** Depends on whether OP-SQLite dispatches the hook synchronously on the current thread or schedules it. If scheduled, delivery may be delayed until foregrounding. Behavior is correct either way — timing may vary. |
| 39 | + |
| 40 | +**Verification:** manually confirm `useOnTableUpdate` fires during a background-alive sync in testing. |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## Signal: How to Detect "Provider Is Mounted" |
| 45 | + |
| 46 | +`foregroundSyncCallback` is set by `usePushNotificationSync` inside a React hook. React hooks only run when the component tree is mounted. In a terminated → background launch there is no component tree, so the callback is never set. |
| 47 | + |
| 48 | +Therefore: **`getForegroundSyncCallback() !== null` is a reliable proxy for "provider is mounted, `writeDbRef.current` is valid."** |
| 49 | + |
| 50 | +--- |
| 51 | + |
| 52 | +## Implementation Plan |
| 53 | + |
| 54 | +### Phase 1 — Update `pushNotificationSyncTask.ts` (one-line change) |
| 55 | + |
| 56 | +Remove the `AppState.currentState === 'active'` guard. The callback's existence is sufficient — it implies the provider is mounted and the connection is live. |
| 57 | + |
| 58 | +```typescript |
| 59 | +// Before: |
| 60 | +if (AppState.currentState === 'active' && foregroundCallback) { |
| 61 | + |
| 62 | +// After: |
| 63 | +if (foregroundCallback) { |
| 64 | +``` |
| 65 | +
|
| 66 | +Full updated task: |
| 67 | +
|
| 68 | +```typescript |
| 69 | +const foregroundCallback = getForegroundSyncCallback(); |
| 70 | + |
| 71 | +/** FOREGROUND / BACKGROUND-ALIVE MODE */ |
| 72 | +// foregroundCallback being non-null means the provider is mounted |
| 73 | +// (React hooks only run when the component tree is alive). |
| 74 | +// Safe for both active and backgrounded states — performSync guards internally. |
| 75 | +if (foregroundCallback) { |
| 76 | + logger.info('📲 Provider is mounted, using existing sync'); |
| 77 | + try { |
| 78 | + await foregroundCallback(); |
| 79 | + logger.info('✅ Sync completed'); |
| 80 | + } catch (syncError) { |
| 81 | + logger.error('❌ Sync failed:', syncError); |
| 82 | + } |
| 83 | + return; |
| 84 | +} |
| 85 | + |
| 86 | +/** TERMINATED / NO PROVIDER MODE */ |
| 87 | +if (!config) { |
| 88 | + logger.info('📲 No config found, skipping background sync'); |
| 89 | + return; |
| 90 | +} |
| 91 | + |
| 92 | +await executeBackgroundSync(config); |
| 93 | +``` |
| 94 | +
|
| 95 | +The `AppState` import can be removed from this file if it is no longer used elsewhere. |
| 96 | +
|
| 97 | +--- |
| 98 | +
|
| 99 | +### Phase 2 — Tests |
| 100 | +
|
| 101 | +**Update:** `src/core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts` |
| 102 | +
|
| 103 | +New cases to add: |
| 104 | +- When `foregroundCallback` is set and `AppState === 'background'`: callback is called, `executeBackgroundSync` is NOT called |
| 105 | +- When `foregroundCallback` is set and `AppState === 'inactive'`: callback is called, `executeBackgroundSync` is NOT called |
| 106 | +- When `foregroundCallback` is set and `AppState === 'active'`: callback is called (existing test, now also covers removal of the AppState guard) |
| 107 | +- When `foregroundCallback` is null and `AppState === 'background'`: `executeBackgroundSync` is called (terminated path) |
| 108 | +
|
| 109 | +--- |
| 110 | +
|
| 111 | +### Phase 3 — Update Expo Example App |
| 112 | +
|
| 113 | +`registerBackgroundSyncCallback` handles the **terminated** case — no component tree, so the app must schedule a push notification to alert the user. This stays as-is. |
| 114 | +
|
| 115 | +The **background-alive** case now goes through `performSync`, which means `useOnTableUpdate` hooks fire. The example should demonstrate sending a notification from `useOnTableUpdate` when the app is backgrounded — this is how users handle the background-alive notification scenario. |
| 116 | +
|
| 117 | +**Update `examples/sync-demo-expo/src/App.tsx`:** |
| 118 | +
|
| 119 | +Add `AppState` import from `react-native`. Update the existing `useOnTableUpdate` callback to schedule a notification when the app is not active and the operation is an INSERT: |
| 120 | +
|
| 121 | +```typescript |
| 122 | +import { ..., AppState } from 'react-native'; |
| 123 | + |
| 124 | +useOnTableUpdate<{ id: string; value: string; created_at: string }>({ |
| 125 | + tables: [TABLE_NAME], |
| 126 | + onUpdate: async (data) => { |
| 127 | + /** BACKGROUND-ALIVE NOTIFICATION */ |
| 128 | + // When app is backgrounded but alive, useOnTableUpdate fires normally. |
| 129 | + // Schedule a local notification so the user is alerted, same as the |
| 130 | + // terminated path handled by registerBackgroundSyncCallback. |
| 131 | + if (AppState.currentState !== 'active' && data.operation === 'INSERT' && data.row) { |
| 132 | + await Notifications.scheduleNotificationAsync({ |
| 133 | + content: { |
| 134 | + title: 'New item synced', |
| 135 | + body: data.row.value || 'New data is available', |
| 136 | + data: { rowId: data.row.id }, |
| 137 | + }, |
| 138 | + trigger: null, |
| 139 | + }); |
| 140 | + return; |
| 141 | + } |
| 142 | + |
| 143 | + /** FOREGROUND UI UPDATE */ |
| 144 | + const operationName = |
| 145 | + data.operation === 'INSERT' |
| 146 | + ? 'added' |
| 147 | + : data.operation === 'UPDATE' |
| 148 | + ? 'updated' |
| 149 | + : 'deleted'; |
| 150 | + |
| 151 | + if (data.row) { |
| 152 | + setRowNotification( |
| 153 | + `🔔 Row ${operationName}: "${data.row.value.substring(0, 20)}${ |
| 154 | + data.row.value.length > 20 ? '...' : '' |
| 155 | + }"` |
| 156 | + ); |
| 157 | + } else { |
| 158 | + setRowNotification(`🔔 Row ${operationName}`); |
| 159 | + } |
| 160 | + setTimeout(() => setRowNotification(null), 2000); |
| 161 | + }, |
| 162 | +}); |
| 163 | +``` |
| 164 | +
|
| 165 | +The callback needs to be `async` — add that. Also update the JSDoc comment above the `registerBackgroundSyncCallback` block and the `useOnTableUpdate` comment to explain the two-path split: |
| 166 | +
|
| 167 | +- `registerBackgroundSyncCallback` → terminated case |
| 168 | +- `useOnTableUpdate` with AppState check → background-alive case |
| 169 | +
|
| 170 | +--- |
| 171 | +
|
| 172 | +## File Impact |
| 173 | +
|
| 174 | +| File | Change | Risk | |
| 175 | +|------|--------|------| |
| 176 | +| `src/core/pushNotifications/pushNotificationSyncTask.ts` | Remove `AppState.currentState === 'active' &&` guard; remove `AppState` import if unused | Very Low | |
| 177 | +| `src/core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts` | Add background/inactive AppState test cases | None | |
| 178 | +| `examples/sync-demo-expo/src/App.tsx` | Add AppState check + notification in `useOnTableUpdate`; make callback async | Low | |
| 179 | +
|
| 180 | +No new files. No new module-level variables. No changes to `useSyncManager`, `usePushNotificationSync`, or `pushNotificationSyncCallbacks`. |
| 181 | +
|
| 182 | +--- |
| 183 | +
|
| 184 | +## Failure Scenarios Covered |
| 185 | +
|
| 186 | +| Scenario | Handled by | |
| 187 | +|----------|-----------| |
| 188 | +| Notification while app active | `foregroundCallback` → `performSync` (unchanged) | |
| 189 | +| Notification while app backgrounded, provider mounted | `foregroundCallback` → `performSync`; `useOnTableUpdate` fires → notification sent | |
| 190 | +| Notification while app terminated | `executeBackgroundSync` → `registerBackgroundSyncCallback` (unchanged) | |
| 191 | +| Concurrent sync (foreground + background-alive race) | `isSyncingRef` guard inside `performSync` | |
| 192 | +| Provider unmounted before callback fires | `foregroundCallback` is null → falls through to `executeBackgroundSync` | |
| 193 | +
|
| 194 | +--- |
| 195 | +
|
| 196 | +## Verification Criteria |
| 197 | +
|
| 198 | +- [ ] `AppState === 'background'`: `foregroundCallback` is called, `executeBackgroundSync` is NOT called |
| 199 | +- [ ] `AppState === 'inactive'`: same as above |
| 200 | +- [ ] `AppState === 'active'`: same behavior as before (existing tests still pass) |
| 201 | +- [ ] `foregroundCallback` null + any AppState: `executeBackgroundSync` is called |
| 202 | +- [ ] `useOnTableUpdate` fires during a background-alive sync (manual test) |
| 203 | +- [ ] Example app: notification is sent when `useOnTableUpdate` fires while app is backgrounded |
| 204 | +- [ ] Example app: UI notification still shows when app is active |
| 205 | +- [ ] All existing tests pass |
0 commit comments