Skip to content

Commit e72c78b

Browse files
committed
fix: reuse existing DB connection for background-alive push notification sync
1 parent 3930fcf commit e72c78b

File tree

10 files changed

+344
-41
lines changed

10 files changed

+344
-41
lines changed
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
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

.claude/plans/test-suite.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ Test utilities in `src/testUtils.tsx` provide `createTestWrapper` for provider-w
6161
| `core/pushNotifications/__tests__/isSqliteCloudNotification.test.ts` | isSqliteCloudNotification | 13 | Foreground valid/invalid URI, iOS background body, Android JSON string body, Android dataString fallback, invalid JSON, wrong URI, empty data |
6262
| `core/common/__tests__/logger.test.ts` | logger | 9 | debug=true logs info/warn, debug=false suppresses, error always logs, [SQLiteSync] prefix, ISO timestamp, default debug=false |
6363
| `core/pushNotifications/__tests__/pushNotificationSyncCallbacks.test.ts` | pushNotificationSyncCallbacks | 5 | Register/get background callback, null default, set/get/clear foreground callback |
64-
| `core/__tests__/constants.test.ts` | constants | 2 | FOREGROUND_DEBOUNCE_MS value, BACKGROUND_SYNC_TASK_NAME non-empty |
64+
| `core/__tests__/constants.test.ts` | constants | 2 | FOREGROUND_DEBOUNCE_MS value, PUSH_NOTIFICATION_SYNC_TASK_NAME non-empty |
6565

6666
### Layer 2: Core Logic (97 tests)
6767

README.md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -338,11 +338,11 @@ npx expo install expo-notifications expo-constants expo-application expo-secure-
338338
339339
### `notificationListening` Modes
340340
341-
| App State | `notificationListening="foreground"` | `notificationListening="always"` |
342-
| ---------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
343-
| Foreground | Notification triggers sync on the existing DB connection | Same behavior |
344-
| Background | Notification ignored | Background task opens a DB connection, syncs, then calls `registerBackgroundSyncCallback` if registered |
345-
| Terminated | Notification ignored | Background task wakes the app, opens a DB connection, syncs, then calls `registerBackgroundSyncCallback` if registered |
341+
| App State | `notificationListening="foreground"` | `notificationListening="always"` |
342+
| --------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
343+
| Foreground | Notification triggers sync on the existing DB connection | Same behavior |
344+
| Background (alive) | Notification ignored | Sync runs on the existing DB connection — no second connection opened. `useOnTableUpdate` hooks fire normally; `registerBackgroundSyncCallback` is **not** called |
345+
| Terminated | Notification ignored | Background task wakes the app, opens a DB connection, syncs, then calls `registerBackgroundSyncCallback` if registered |
346346
347347
> Note: If push mode cannot be used because required Expo packages are missing, notification permissions are denied, or push token retrieval fails, the provider logs a warning and falls back to polling mode.
348348
@@ -421,12 +421,15 @@ Main provider component that enables sync functionality.
421421

422422
#### Background Sync Callback
423423

424-
When using push mode with `notificationListening="always"`, you can register a callback that runs after a background sync completes.
424+
When using push mode with `notificationListening="always"`, notifications are handled differently depending on whether the app was terminated or just backgrounded.
425+
426+
**Terminated app** — use `registerBackgroundSyncCallback` at module level (outside any component). This runs after the background sync completes with a list of changed rows and a DB handle for querying:
425427

426428
```typescript
427429
import { registerBackgroundSyncCallback } from '@sqliteai/sqlite-sync-react-native';
428430
import * as Notifications from 'expo-notifications';
429431
432+
// Must be called at module level — this runs even when the app was terminated
430433
registerBackgroundSyncCallback(async ({ changes, db }) => {
431434
const newItems = changes.filter(
432435
(c) => c.table === 'tasks' && c.operation === 'INSERT'
@@ -450,6 +453,33 @@ registerBackgroundSyncCallback(async ({ changes, db }) => {
450453
});
451454
```
452455

456+
**Backgrounded but alive** — the sync runs on the existing DB connection. Your `useOnTableUpdate` hooks fire normally. Use an `AppState` check inside the hook to send a local notification instead of updating UI:
457+
458+
```typescript
459+
import { AppState } from 'react-native';
460+
import { useOnTableUpdate } from '@sqliteai/sqlite-sync-react-native';
461+
import * as Notifications from 'expo-notifications';
462+
463+
useOnTableUpdate({
464+
tables: ['tasks'],
465+
onUpdate: async (data) => {
466+
if (AppState.currentState !== 'active' && data.operation === 'INSERT' && data.row) {
467+
// App is backgrounded — notify the user instead of updating UI
468+
await Notifications.scheduleNotificationAsync({
469+
content: {
470+
title: 'New task synced',
471+
body: data.row.title || 'New data available',
472+
},
473+
trigger: null,
474+
});
475+
return;
476+
}
477+
478+
// App is in foreground — update UI normally
479+
},
480+
});
481+
```
482+
453483
#### Database Migrations With `onDatabaseReady`
454484

455485
Use `onDatabaseReady` to run migrations or setup after the database opens and before sync initialization.

examples/sync-demo-expo/src/App.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
FlatList,
1111
Modal,
1212
Clipboard,
13+
AppState,
1314
} from 'react-native';
1415
import {
1516
SQLiteSyncProvider,
@@ -34,7 +35,8 @@ const TABLE_NAME = Constants.expoConfig?.extra?.tableName;
3435

3536
/**
3637
* Register background sync handler at module level (outside components).
37-
* This is called when new data is synced while app is in background/terminated.
38+
* This is called when new data is synced while the app is TERMINATED.
39+
* For the backgrounded-but-alive case, useOnTableUpdate handles notifications instead.
3840
*/
3941
registerBackgroundSyncCallback(
4042
async ({ changes, db }: BackgroundSyncResult) => {
@@ -142,10 +144,31 @@ function TestApp({ deviceToken }: { deviceToken: string | null }) {
142144
}, [allRows, searchText]);
143145

144146
// Hook 2: useOnTableUpdate - Row-level update notifications
145-
// Fires for individual row changes with automatic row data fetching
147+
// Fires for individual row changes with automatic row data fetching.
148+
// Also handles the backgrounded-but-alive notification case: when the app is
149+
// in the background, the sync runs on the existing connection and this hook
150+
// fires normally — we schedule a local notification here instead of updating UI.
146151
useOnTableUpdate<{ id: string; value: string; created_at: string }>({
147152
tables: [TABLE_NAME],
148-
onUpdate: (data) => {
153+
onUpdate: async (data) => {
154+
/** BACKGROUND-ALIVE: schedule notification instead of updating UI */
155+
if (
156+
AppState.currentState !== 'active' &&
157+
data.operation === 'INSERT' &&
158+
data.row
159+
) {
160+
await Notifications.scheduleNotificationAsync({
161+
content: {
162+
title: 'New item synced',
163+
body: data.row.value || 'New data is available',
164+
data: { rowId: data.row.id },
165+
},
166+
trigger: null,
167+
});
168+
return;
169+
}
170+
171+
/** FOREGROUND: update in-app UI */
149172
const operationName =
150173
data.operation === 'INSERT'
151174
? 'added'

src/core/__tests__/constants.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
FOREGROUND_DEBOUNCE_MS,
3-
BACKGROUND_SYNC_TASK_NAME,
3+
PUSH_NOTIFICATION_SYNC_TASK_NAME,
44
CLOUDSYNC_BASE_URL,
55
} from '../constants';
66

@@ -9,9 +9,9 @@ describe('constants', () => {
99
expect(FOREGROUND_DEBOUNCE_MS).toBe(2000);
1010
});
1111

12-
it('BACKGROUND_SYNC_TASK_NAME is a non-empty string', () => {
13-
expect(typeof BACKGROUND_SYNC_TASK_NAME).toBe('string');
14-
expect(BACKGROUND_SYNC_TASK_NAME.length).toBeGreaterThan(0);
12+
it('PUSH_NOTIFICATION_SYNC_TASK_NAME is a non-empty string', () => {
13+
expect(typeof PUSH_NOTIFICATION_SYNC_TASK_NAME).toBe('string');
14+
expect(PUSH_NOTIFICATION_SYNC_TASK_NAME.length).toBeGreaterThan(0);
1515
});
1616

1717
it('CLOUDSYNC_BASE_URL is a non-empty string', () => {

src/core/background/__tests__/backgroundSyncRegistry.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
isBackgroundSyncAvailable,
2323
} from '../../common/optionalDependencies';
2424
import { persistConfig, clearPersistedConfig } from '../backgroundSyncConfig';
25-
import { BACKGROUND_SYNC_TASK_NAME } from '../../constants';
25+
import { PUSH_NOTIFICATION_SYNC_TASK_NAME } from '../../constants';
2626

2727
const mockConfig: BackgroundSyncConfig = {
2828
databaseId: 'db_test_database_id',
@@ -47,7 +47,7 @@ describe('registerBackgroundSync', () => {
4747
await registerBackgroundSync(mockConfig);
4848

4949
expect(ExpoNotifications.registerTaskAsync).toHaveBeenCalledWith(
50-
BACKGROUND_SYNC_TASK_NAME
50+
PUSH_NOTIFICATION_SYNC_TASK_NAME
5151
);
5252
});
5353

@@ -71,7 +71,7 @@ describe('unregisterBackgroundSync', () => {
7171
await unregisterBackgroundSync();
7272

7373
expect(ExpoNotifications.unregisterTaskAsync).toHaveBeenCalledWith(
74-
BACKGROUND_SYNC_TASK_NAME
74+
PUSH_NOTIFICATION_SYNC_TASK_NAME
7575
);
7676
});
7777

0 commit comments

Comments
 (0)