diff --git a/.claude/plans/test-suite.md b/.claude/plans/test-suite.md
new file mode 100644
index 0000000..501c046
--- /dev/null
+++ b/.claude/plans/test-suite.md
@@ -0,0 +1,114 @@
+# Test Suite
+
+**Status:** Implemented
+**Last updated:** 2026-03-20
+**Total:** 33 test files, 305 tests
+
+## How to Run
+
+```bash
+# Run all tests
+yarn test
+
+# Run with coverage report
+yarn test:coverage
+
+# Open HTML coverage report
+open coverage/lcov-report/index.html
+```
+
+## Stack
+
+- **Runner:** Jest (react-native preset)
+- **Hooks:** `renderHook` from `@testing-library/react-native`
+- **Mocks:** Co-located `__mocks__/` directories for native modules
+- **Coverage thresholds:** statements 95, branches 85, functions 95, lines 95
+
+## Architecture
+
+Tests are co-located next to source files in `__tests__/` directories, organized in 5 layers:
+
+1. **Pure functions** — no mocks needed
+2. **Core logic** — mocked native modules (op-sqlite, NetInfo, Expo)
+3. **Context consumer hooks** — wrapped in test providers
+4. **Complex hooks** — renderHook with mocked dependencies
+5. **Integration** — SQLiteSyncProvider rendering
+
+## Shared Mocks
+
+Located in `src/__mocks__/`:
+
+| Mock | What it provides |
+|------|-----------------|
+| `@op-engineering/op-sqlite` | `createMockDB()`, `open()`, `getDylibPath()` |
+| `@react-native-community/netinfo` | `addEventListener`, `fetch`, `__emit()` |
+| `react-native` | `AppState`, `Platform` |
+| `expo-notifications` | Token, permissions, listeners |
+| `expo-secure-store` | `getItemAsync`, `setItemAsync`, `deleteItemAsync` |
+| `expo-task-manager` | `defineTask`, `isTaskRegisteredAsync` |
+| `expo-constants` | `expoConfig.extra` |
+| `expo-application` | `getIosIdForVendorAsync`, `getAndroidId` |
+
+Test utilities in `src/testUtils.tsx` provide `createTestWrapper` for provider-wrapped hook tests.
+
+## Test Files (33 files, 305 tests)
+
+### Layer 1: Pure Functions (39 tests)
+
+| Test file | Source | Tests | What's tested |
+|-----------|--------|------:|---------------|
+| `core/polling/__tests__/calculateAdaptiveSyncInterval.test.ts` | calculateAdaptiveSyncInterval | 10 | Base interval, idle backoff at threshold, exponential error backoff, caps at maxInterval, error priority over idle |
+| `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 |
+| `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 |
+| `core/pushNotifications/__tests__/pushNotificationSyncCallbacks.test.ts` | pushNotificationSyncCallbacks | 5 | Register/get background callback, null default, set/get/clear foreground callback |
+| `core/__tests__/constants.test.ts` | constants | 2 | FOREGROUND_DEBOUNCE_MS value, BACKGROUND_SYNC_TASK_NAME non-empty |
+
+### Layer 2: Core Logic (97 tests)
+
+| Test file | Source | Tests | What's tested |
+|-----------|--------|------:|---------------|
+| `core/database/__tests__/createDatabase.test.ts` | createDatabase | 9 | Opens DB, WAL journal mode, write mode pragmas, read mode pragmas, returns DB, propagates open/pragma errors |
+| `core/sync/__tests__/initializeSyncExtension.test.ts` | initializeSyncExtension | 14 | Missing connectionString/auth validation, iOS/Android extension paths, version check, cloudsync_init per table, network_init, API key/accessToken auth, siteId logging |
+| `core/sync/__tests__/executeSync.test.ts` | executeSync | 14 | JS retry loop (returns 0/count, stops on changes, max attempts, transaction wrapping, malformed JSON), native retry passthrough |
+| `core/background/__tests__/backgroundSyncConfig.test.ts` | backgroundSyncConfig | 10 | Persist/get/clear config, null without SecureStore, parse errors, warn/error handling |
+| `core/background/__tests__/backgroundSyncRegistry.test.ts` | backgroundSyncRegistry | 7 | Register (persist + task), unregister (task + clear), warns when unavailable, error handling |
+| `core/background/__tests__/executeBackgroundSync.test.ts` | executeBackgroundSync | 13 | Opens DB, inits sync, executes with native retry, updateHook callback, changes collection, DB close in finally, error rethrow, close error handling |
+| `core/pushNotifications/__tests__/registerPushToken.test.ts` | registerPushToken | 12 | Skip duplicate, correct URL, accessToken/apiKey auth headers, body fields, iOS/Android device ID, non-ok response, persist after success, SecureStore read/write errors, missing expo-application |
+| `core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts` | pushNotificationSyncTask | 8 | Task definition with/without ExpoTaskManager, handler routes to background sync, skips non-SQLite notification, foreground callback when app active, error handling, skip without config |
+| `core/common/__tests__/optionalDependencies.test.ts` | optionalDependencies | 10 | Each Expo module available/null, ExpoConstants .default fallback, isBackgroundSyncAvailable (all present vs any missing) |
+
+### Layer 3: Context Consumer Hooks (10 tests)
+
+| Test file | Source | Tests | What's tested |
+|-----------|--------|------:|---------------|
+| `hooks/context/__tests__/useSqliteDb.test.ts` | useSqliteDb | 2 | Returns writeDb/readDb/initError from context, null defaults |
+| `hooks/context/__tests__/useSyncStatus.test.ts` | useSyncStatus | 2 | Returns all status fields, default values |
+| `hooks/context/__tests__/useSqliteSync.test.ts` | useSqliteSync | 2 | Returns merged contexts, triggerSync callable |
+| `core/common/__tests__/useInternalLogger.test.ts` | useInternalLogger | 2 | Returns logger from context, has info/warn/error methods |
+| `hooks/sync/__tests__/useTriggerSqliteSync.test.ts` | useTriggerSqliteSync | 2 | Returns triggerSync, calls through to context |
+
+### Layer 4: Complex Hooks (112 tests)
+
+_Includes useDatabaseInitialization which spans init + lifecycle._
+
+| Test file | Source | Tests | What's tested |
+|-----------|--------|------:|---------------|
+| `hooks/sqlite/__tests__/useSqliteExecute.test.ts` | useSqliteExecute | 10 | Undefined when no db, execute on writeDb/readDb, error state, clears error, auto-sync after write, skip on readOnly/autoSync=false, non-Error wrapping |
+| `hooks/sqlite/__tests__/useSqliteTransaction.test.ts` | useSqliteTransaction | 8 | Undefined when no writeDb, calls transaction, error state, clears error, auto-sync after commit, skip autoSync=false, non-Error wrapping |
+| `hooks/sqlite/__tests__/useOnTableUpdate.test.ts` | useOnTableUpdate | 8 | Register/remove updateHook, table filtering, null row for DELETE, empty rows, fetch error, no-op when null |
+| `hooks/sync/__tests__/useSqliteSyncQuery.test.ts` | useSqliteSyncQuery | 10 | Loading state, initial read, error, reactive subscription after debounce, callback updates, unmount cleanup, debounce clearing, stale subscription skip, unsubscribe |
+| `core/sync/__tests__/useSyncManager.test.ts` | useSyncManager | 16 | Null db/not ready guards, sync lifecycle, empty sync counters, error state, interval recalculation (polling vs push), concurrent sync prevention, Android network check, error backoff |
+| `core/sync/__tests__/useInitialSync.test.ts` | useInitialSync | 4 | Delayed trigger after 1500ms, not ready guard, once-only, cleanup on unmount |
+| `core/lifecycle/__tests__/useAppLifecycle.test.ts` | useAppLifecycle | 10 | Register/remove AppState listener, foreground sync trigger, interval reset (polling only), debounce, background state tracking |
+| `core/lifecycle/__tests__/useNetworkListener.test.ts` | useNetworkListener | 10 | Register/unsubscribe NetInfo, sync on reconnect, no sync online→online, background guard, isNetworkAvailable state, null isInternetReachable/isConnected handling |
+| `core/polling/__tests__/useAdaptivePollingSync.test.ts` | useAdaptivePollingSync | 9 | Start/stop polling, no start in push/not ready, pause on background, resume, dynamic interval, cleanup |
+| `core/pushNotifications/__tests__/usePushNotificationSync.test.ts` | usePushNotificationSync | 15 | Permission request, skip in polling, token registration, siteId retrieval, denied callback, foreground listener, sync trigger, ignore non-SQLite notification, background registration, fallback, unregister on mode switch, cleanup, handle failures |
+| `core/database/__tests__/useDatabaseInitialization.test.ts` | useDatabaseInitialization | 12 | Creates write/read DBs, initializes sync extension, onDatabaseReady callback, re-init on config change, error handling, empty name/tables validation, close errors on unmount |
+
+### Layer 5: Integration And Public Surface (19 tests)
+
+| Test file | Source | Tests | What's tested |
+|-----------|--------|------:|---------------|
+| `core/__tests__/SQLiteSyncProvider.test.tsx` | SQLiteSyncProvider | 14 | Renders children, provides writeDb/readDb, initError/syncError, onDatabaseReady, default status, syncMode, triggerSync, adaptive config, re-init triggers, mode fallback, cleanup |
+| `core/__tests__/SQLiteSyncProvider.integration.test.tsx` | SQLiteSyncProvider | 7 | Polling defaults without adaptivePolling, push foreground default, push fallback to polling, reinit on accessToken/apiKey/databaseId/table config changes |
+| `core/__tests__/publicExports.test.ts` | public API surface | 2 | Root exports smoke test, `./backgroundSync` subpath export smoke test |
diff --git a/.claude/plans/unit-test-expansion-plan.md b/.claude/plans/unit-test-expansion-plan.md
new file mode 100644
index 0000000..f6248ba
--- /dev/null
+++ b/.claude/plans/unit-test-expansion-plan.md
@@ -0,0 +1,374 @@
+# Unit Test Expansion Plan
+
+**Status:** Implemented
+**Last updated:** 2026-03-20
+**Scope:** Expand and harden the library unit-test suite before moving on to end-to-end example app integration tests
+
+## Goal
+
+Increase the value of the library test suite in two ways:
+
+1. Add missing unit and library-level integration tests for important runtime behavior
+2. Improve the existing tests by reducing unnecessary internal mocks, especially around provider and push flows
+
+This plan intentionally stops short of full end-to-end testing of the example apps. That is the next phase.
+
+## Success Criteria
+
+- Important public behaviors are tested directly, not only via wiring assertions
+- User-scoped auth, provider reinitialization, polling defaults, and push fallback are covered
+- Provider tests rely on fewer mocks and exercise more real runtime behavior
+- Race conditions and cleanup behavior are tested for reactive hooks
+- Public package exports and subpath exports have smoke coverage
+- Coverage reporting and thresholds are introduced once the expanded suite is stable
+
+## Current Observations
+
+- The current suite is broad and useful, but some high-value areas are still too mock-heavy
+- `SQLiteSyncProvider` tests currently mock nearly the full dependency graph, which limits real integration confidence
+- Push notification tests exercise branch logic well, but still mock many internal modules at once
+- There is no enforced coverage threshold in Jest config
+- Full device/native integration is intentionally deferred to the next testing phase
+
+## Workstreams
+
+### Workstream 1: Provider And Public API
+
+#### Add tests
+
+1. `SQLiteSyncProvider` polling defaults integration
+- Render with `syncMode="polling"` and no `adaptivePolling`
+- Assert default adaptive config is passed into the real sync manager path
+- Assert `currentSyncInterval` starts at `baseInterval`
+
+2. `SQLiteSyncProvider` `notificationListening` defaulting
+- Render push mode without explicit `notificationListening`
+- Assert effective value is `'foreground'`
+
+3. `SQLiteSyncProvider` auth reinitialization for `accessToken`
+- Initial render with `accessToken="user-a"`
+- Rerender with `accessToken="user-b"`
+- Assert DB/sync init reruns with the new user token
+
+4. `SQLiteSyncProvider` auth reinitialization for `apiKey`
+- Initial render with `apiKey="key-a"`
+- Rerender with `apiKey="key-b"`
+- Assert DB/sync init reruns
+
+5. `SQLiteSyncProvider` `databaseId` reinitialization
+- Change `databaseId`
+- Assert teardown and re-init
+
+6. `SQLiteSyncProvider` `databaseName` reinitialization
+- Change `databaseName`
+- Assert old DBs are closed and new ones are opened
+
+7. `SQLiteSyncProvider` `tablesToBeSynced` reinitialization
+- Change table config content
+- Assert safe re-init
+
+8. `SQLiteSyncProvider` migration ordering
+- Assert DB open happens before `onDatabaseReady`
+- Assert `onDatabaseReady` completes before sync init
+- Assert failed migration prevents sync init
+
+9. `SQLiteSyncProvider` push fallback integration
+- Render provider in push mode
+- Simulate permission denied or token-registration failure
+- Assert effective runtime mode becomes polling
+- Assert polling-related behavior is active after fallback
+
+10. Public root export smoke test
+- Import public exports from `src/index.tsx`
+- Assert documented runtime exports exist
+
+11. Public subpath export smoke test
+- Add coverage for `./backgroundSync` export path if it is intended to ship
+
+#### Improve existing tests
+
+- Refactor `src/core/__tests__/SQLiteSyncProvider.test.tsx`
+- Stop mocking all internal hooks
+- Mock only true external boundaries:
+  - DB creation / OP-SQLite
+  - sync extension/native SQL calls
+  - NetInfo
+  - AppState
+  - Expo notifications
+- Replace prop-wiring assertions with behavior assertions through contexts
+
+### Workstream 2: Query, Execute, Transaction, And Table Update Hooks
+
+#### Add tests
+
+12. `useSqliteSyncQuery` no-`readDb` behavior
+- Explicitly test intended behavior when `readDb` is null and `writeDb` exists
+
+13. `useSqliteSyncQuery` subscription replacement on rerender
+- Query/args/fireOn change should unsubscribe the old reactive subscription exactly once
+
+14. `useSqliteSyncQuery` stale async result protection
+- Query A resolves after query B replaces it
+- Old result must not overwrite new state
+
+15. `useSqliteSyncQuery` callback error behavior
+- Reactive callback throws or returns malformed data
+- Assert predictable error handling
+
+16. `useSqliteSyncQuery` `fireOn` operation filtering
+- Ensure operation-specific invalidation behaves as expected
+
+17. `useOnTableUpdate` multiple-table filtering edge cases
+- Subscribed tables trigger
+- Unrelated tables do not
+- DELETE keeps `row = null`
+
+18. `useOnTableUpdate` cleanup on config change
+- Changing tables or callback should remove old hook and install new one
+
+19. `useSqliteExecute` `isExecuting` lifecycle
+- True while request is active
+- False after success
+- False after failure
+
+20. `useSqliteExecute` read-only without `readDb`
+- Explicitly codify current behavior
+
+21. `useSqliteExecute` preserves main result when auto-sync fails
+- Assert returned result is intact
+- Assert `error` remains null
+
+22. `useSqliteTransaction` `isExecuting` lifecycle
+- True while transaction is active
+- False after success/failure
+
+23. `useSqliteTransaction` callback fidelity
+- Assert provided transaction object is the one used for multiple statements
+
+24. `useSqliteTransaction` no auto-sync after failed transaction
+- If transaction throws, `cloudsync_network_send_changes()` must not run
+
+#### Improve existing tests
+
+- Strengthen `useSqliteSyncQuery` tests to validate state transitions, not only mocked callback wiring
+- Reduce duplicated mocking in execute/transaction tests by using a more realistic fake DB helper
+
+### Workstream 3: Lifecycle, Polling, And Sync Manager
+
+#### Add tests
+
+25. `useAppLifecycle` transition matrix
+- `inactive -> active`
+- `background -> active`
+- `active -> active`
+- Only real resume transitions should trigger sync
+
+26. `useNetworkListener` initial offline startup
+- Start offline, then reconnect
+- Assert first reconnect behavior is correct
+
+27. `useNetworkListener` repeated online noise
+- Multiple online events without a new offline phase should not retrigger sync
+
+28. `useAdaptivePollingSync` timer replacement
+- Interval changes should clear prior timer and schedule a new one
+
+29. `useAdaptivePollingSync` cleanup robustness
+- Unmount clears timer cleanly
+- No duplicate timers survive rerenders
+
+30. `useSyncManager` sync mode switching
+- Behavior when params rerender from polling to push and back
+
+31. `useSyncManager` sync error recovery
+- After failures, a successful sync resets `consecutiveSyncErrors`
+
+32. `useSyncManager` Android network edge cases
+- More explicit coverage for `isConnected=true` with unusual `isInternetReachable` values
+
+#### Improve existing tests
+
+- Keep these tests mostly behavior-focused
+- Avoid introducing extra mocks because this area is already relatively strong
+
+### Workstream 4: Sync Initialization And Database Initialization
+
+#### Add tests
+
+33. `initializeSyncExtension` failure on `loadExtension`
+- Clear thrown error
+
+34. `initializeSyncExtension` failure on `cloudsync_network_init_custom`
+- Clear thrown error
+
+35. `initializeSyncExtension` failure on `cloudsync_network_set_apikey`
+- Clear thrown error
+
+36. `initializeSyncExtension` failure on `cloudsync_network_set_token`
+- Clear thrown error
+
+37. `initializeSyncExtension` auth policy test
+- Decide intended runtime behavior if both `apiKey` and `accessToken` are somehow supplied
+- Test the chosen policy explicitly
+
+38. `useDatabaseInitialization` partial DB open failure
+- Write DB succeeds, read DB fails
+- Assert cleanup and fatal error behavior
+
+39. `useDatabaseInitialization` parameter-driven reinit
+- Changing config should close prior DBs before reopening
+
+40. `useDatabaseInitialization` sync init should not run after fatal table creation failure
+- Assert no further steps continue
+
+41. `useDatabaseInitialization` empty tables behavior
+- Clarify and codify expected behavior with assertions beyond “does not crash”
+
+#### Improve existing tests
+
+- Keep `createDatabase` mocked, but use more behavior-rich fake DB instances
+- Replace “no crash” tests with stronger assertions when possible
+
+### Workstream 5: Push Registration, Push Hooks, And Background Sync
+
+#### Add tests
+
+42. `registerPushToken` missing auth
+- Assert clear error when neither `apiKey` nor `accessToken` is provided
+
+43. `registerPushToken` token re-registration policy
+- Same Expo token with different `databaseId`
+- Same Expo token with different `siteId`
+- Same Expo token with changed auth
+- Verify intended behavior explicitly
+
+44. `registerPushToken` Android device ID failure
+- `getAndroidId()` missing or throws
+
+45. `usePushNotificationSync` token registration failure path
+- Registration fails after permissions granted
+- Assert warning and fallback behavior
+
+46. `usePushNotificationSync` custom permission prompt flow
+- `renderPushPermissionPrompt`
+- `allow` proceeds to permission request
+- `deny` triggers fallback/denied flow
+
+47. `usePushNotificationSync` foreground listener cleanup
+- Unmount removes notification listener
+
+48. `usePushNotificationSync` background registration cleanup
+- `notificationListening="always"` unregisters on unmount or mode change
+
+49. `usePushNotificationSync` missing optional dependency combinations
+- Test missing `expo-notifications`
+- Test missing `expo-constants`
+- Test missing `expo-application`
+- Test missing background deps for `'always'`
+
+50. `pushNotificationSyncTask` invalid persisted config
+- Missing required fields
+- Corrupt serialized payload
+- Fails safely without crash
+
+51. `executeBackgroundSync` access-token configuration
+- Cover `accessToken` path, not only `apiKey`
+
+52. `executeBackgroundSync` failure before callback registration
+- DB still closes
+- Callback does not run
+
+53. `executeBackgroundSync` table/init failure cleanup
+- Assert `close()` still runs
+
+#### Improve existing tests
+
+- Refactor `usePushNotificationSync.test.ts` to mock fewer internal helpers
+- Keep Expo/native boundaries mocked, but prefer real internal control flow
+
+## Outcome
+
+This unit-test expansion pass was implemented. The library now has:
+
+- 33 test suites
+- 305 tests
+- provider integration coverage with fewer internal mocks
+- public export smoke coverage
+- coverage reporting with enforced thresholds
+- stronger cleanup, auth, push, and reactive-hook edge-case coverage
+
+## Recommended Implementation Order
+
+### Phase 1: Highest-value unit tests
+
+1. Provider defaults integration
+2. Provider auth reinit
+3. Provider push fallback integration
+4. Query stale result + cleanup tests
+5. Transaction “no auto-sync after failure”
+6. Push permission prompt flow
+7. Background sync `accessToken` path
+
+### Phase 2: Provider and push mock reduction
+
+8. Refactor `SQLiteSyncProvider.test.tsx`
+9. Refactor `usePushNotificationSync.test.ts`
+10. Strengthen `useDatabaseInitialization.test.ts`
+
+### Phase 3: Remaining high-value behavior gaps
+
+11. Export smoke tests
+12. Register-push-token policy edge cases
+13. Lifecycle/polling edge cases
+14. Sync-init failure matrix
+15. Background sync cleanup/failure matrix
+
+### Phase 4: Coverage measurement and enforcement
+
+16. Enable `collectCoverage`
+17. Establish baseline coverage report
+18. Add pragmatic thresholds by category:
+- statements
+- branches
+- functions
+- lines
+
+Suggested approach:
+
+- Do not add strict thresholds before the new tests are in place
+- Start with thresholds that reflect current reality
+- Tighten later as E2E coverage is added
+
+## Mock Reduction Strategy
+
+### Keep mocking
+
+- OP-SQLite native module boundaries
+- NetInfo
+- AppState
+- Expo native APIs
+- network calls such as `fetch`
+
+### Reduce mocking for
+
+- `SQLiteSyncProvider` internal hook graph
+- internal push helper modules when testing push hook behavior
+- internal database/sync coordination inside provider-level tests
+
+### Principle
+
+At the library unit-test level, mock platform/native boundaries, not your own core logic, unless the test is intentionally scoped to one pure unit.
+
+## Deliverables
+
+1. Expanded unit-test suite covering the cases listed above
+2. Refactored provider and push tests with fewer internal mocks
+3. Coverage reporting in Jest
+4. Updated `.claude/plans/test-suite.md` after implementation is complete
+
+## Explicitly Deferred To Next Step
+
+- Full end-to-end tests on `examples/sync-demo-expo`
+- Full end-to-end tests on `examples/sync-demo-bare`
+- Real-device push validation
+- Build/install/runtime validation in CI on native targets
diff --git a/.gitignore b/.gitignore
index 6a22123..f4e669b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -90,5 +90,8 @@ ios/
 # React Native Nitro Modules
 nitrogen/
 
+# Jest coverage
+coverage/
+
 # Example app environment files
 examples/*/.env
diff --git a/CLAUDE.md b/CLAUDE.md
index 19083d3..3d0e3ec 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -72,6 +72,26 @@ Every exported function, interface, and type gets JSDoc documentation:
 export function myFunction(foo: string): number { ... }
 ```
 
+### JSDoc for types and interfaces
+All interfaces and types get a JSDoc comment, and every field gets an inline `/** comment */`:
+```typescript
+/** Props for {@link MyComponent} */
+interface MyComponentProps {
+  /** Whether the dialog is visible */
+  open: boolean;
+  /** Called to close the dialog */
+  onClose: () => void;
+}
+
+/** Single navigation link in the sidebar */
+type NavItem = {
+  /** Display text */
+  label: string;
+  /** Route path */
+  href: string;
+};
+```
+
 ### Section markers inside functions
 Use `/** SECTION NAME */` to mark logical sections within complex functions:
 ```typescript
@@ -106,3 +126,88 @@ if (Platform.OS === 'android') { ... }
 - Obvious code (`// increment counter` before `count++`)
 - Code that's already clear from good naming
 - Every single line - only where it adds value
+
+## 6. Learn From Mistakes
+
+**When corrected, codify the lesson. Don't repeat the same mistake twice.**
+
+When the user corrects you or you discover a pattern/convention mid-session:
+1. Acknowledge the mistake explicitly.
+2. Propose a new rule or update to an existing section of this file that would have prevented it.
+3. After user approval, add/update the rule in CLAUDE.md so future sessions benefit.
+
+Examples of things worth capturing:
+- File/folder conventions the user enforces (e.g. "utils go under `src/lib/utils/`")
+- Naming patterns (e.g. "one function per file, kebab-case filename")
+- Architectural preferences revealed through feedback
+- Anti-patterns the user flags
+
+Do NOT add rules speculatively. Only add rules that come from actual corrections or explicit user preferences expressed during a session.
+
+## 7. Workflow Orchestration
+
+### Plan Mode Default
+
+- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
+- If something goes sideways, STOP and re-plan immediately — don't keep pushing
+- Use plan mode for verification steps, not just building
+- Write detailed specs upfront to reduce ambiguity
+
+### Subagent Strategy
+
+- Use subagents liberally to keep main context window clean
+- Offload research, exploration, and parallel analysis to subagents
+- For complex problems, throw more compute at it via subagents
+- One task per subagent for focused execution
+
+### Self-Improvement Loop
+
+- After ANY correction from the user: update relevant files in `.claude/rules/` with the pattern
+- Write rules for yourself that prevent the same mistake
+- Ruthlessly iterate on these lessons until mistake rate drops
+- Review lessons at session start for relevant project
+
+### Verification Before Done
+
+- Never mark a task complete without proving it works
+- Diff behavior between main and your changes when relevant
+- Ask yourself: "Would a staff engineer approve this?"
+- Run tests, check logs, demonstrate correctness
+
+### Demand Elegance (Balanced)
+
+- For non-trivial changes: pause and ask "is there a more elegant way?"
+- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
+- Skip this for simple, obvious fixes — don't over-engineer
+- Challenge your own work before presenting it
+
+### Autonomous Bug Fixing
+
+- When given a bug report: just fix it. Don't ask for hand-holding
+- Point at logs, errors, failing tests — then resolve them
+- Zero context switching required from the user
+- Go fix failing CI tests without being told how
+
+## 8. Plan Files
+
+**Store implementation plans in `.claude/plans/<feature-name>.md`**
+
+- Plans live in `.claude/plans/` so Claude can reference them across sessions
+- Use descriptive kebab-case names: `test-suite-design.md`, `push-notification-refactor.md`
+- Include date and approval status at the top
+- These are working documents — update them as the plan evolves
+
+## 9. Task Management
+
+1. **Plan First**: Write plan to `.claude/todo/<feature-or-task-name>.md` with checkable items
+2. **Verify Plan**: Check in before starting implementation
+3. **Track Progress**: Mark items complete as you go
+4. **Explain Changes**: High-level summary at each step
+5. **Document Results**: Add review section to `.claude/todo/<feature-or-task-name>.md`
+6. **Capture Lessons**: Update relevant files in `.claude/rules/` after corrections
+
+## 10. Core Principles
+
+- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
+- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
+- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
diff --git a/README.md b/README.md
index 40bc5db..3e3f1ca 100644
--- a/README.md
+++ b/README.md
@@ -4,58 +4,69 @@
 [![npm version](https://img.shields.io/npm/v/@sqliteai/sqlite-sync-react-native.svg)](https://www.npmjs.com/package/@sqliteai/sqlite-sync-react-native)
 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
 
-Build real-time, collaborative mobile apps that work seamlessly offline and automatically sync when online. Powered by [SQLite Sync](https://github.com/sqliteai/sqlite-sync).
+Offline-first React Native sync for SQLite Cloud. This library gives you a local SQLite database on-device, keeps it usable offline, and synchronizes changes with SQLite Cloud when connectivity is available.
 
-## ✨ Features
+Powered by [SQLite Sync](https://github.com/sqliteai/sqlite-sync) and [OP-SQLite](https://github.com/OP-Engineering/op-sqlite).
 
-- 🧩 **Offline-First, Automatic Sync**  
-  Wrap your app with `SQLiteSyncProvider` to get a local database with automatic, bi-directional cloud synchronization. Your app works fully offline, and all local changes are synced seamlessly when online.
+## Table of Contents
 
-- 🪝 **React Hooks with Reactive Queries**
-  Use `useSqliteSyncQuery` for table-level reactivity and `useOnTableUpdate` for row-level notifications. Your UI automatically updates when data changes, locally or from the cloud — no manual refresh needed.
+- [Compatibility](#compatibility)
+- [Choose Your Auth Model](#choose-your-auth-model)
+- [Choose Your Sync Mode](#choose-your-sync-mode)
+- [Installation](#installation)
+- [Quick Start](#quick-start)
+- [Sync Behavior](#sync-behavior)
+- [API Reference](#api-reference)
+- [Error Handling](#error-handling)
+- [Debug Logging](#debug-logging)
+- [Known Issues & Improvements](#known-issues--improvements)
+- [Examples](#examples)
+- [Links](#links)
 
-- 🔧 **Zero-Configuration Extension Loading**  
-  The SQLite Sync extension is automatically loaded and configured for you.
-  No manual setup required — just access the full [SQLite Sync API](https://github.com/sqliteai/sqlite-sync/blob/main/API.md) directly through the `writeDb` / `readDb` instances.
+## Compatibility
 
-- 📱 **Native-Only, Ultra-Fast**  
-  Under the hood, we use OP-SQLite — a low-level, JSI-enabled SQLite engine for React Native. With OP-SQLite, database operations run at near-native speed on iOS and Android.
+| Requirement    | Status / Minimum                                                                                            |
+| -------------- | ----------------------------------------------------------------------------------------------------------- |
+| React Native   | Native projects and Expo development builds                                                                 |
+| iOS            | 13.0+                                                                                                       |
+| Android        | API 26+                                                                                                     |
+| Web            | Not supported                                                                                               |
+| Expo Go        | Not supported                                                                                               |
+| SQLite engine  | [`@op-engineering/op-sqlite`](https://github.com/OP-Engineering/op-sqlite) `^15.1.14`                       |
+| Network status | [`@react-native-community/netinfo`](https://github.com/react-native-netinfo/react-native-netinfo) `^11.0.0` |
+| Cloud backend  | [SQLite Cloud](https://sqlitecloud.io/)                                                                     |
 
-## 📚 Table of Contents
+Optional Expo dependencies for push mode:
 
-- [Requirements](#-requirements)
-- [Installation](#-installation)
-- [Quick Start](#-quick-start)
-- [Sync Behavior](#-sync-behavior)
-- [API Reference](#-api-reference)
-  - [SQLiteSyncProvider](#sqlitesyncprovider)
-  - [Contexts](#contexts)
-  - [Hooks](#hooks)
-- [Error Handling](#-error-handling)
-- [Debug Logging](#-debug-logging)
-- [Known Issues & Improvements](#-known-issues--improvements)
-- [Examples](#-examples)
-- [Links](#-links)
+- [`expo-notifications`](https://docs.expo.dev/versions/latest/sdk/notifications/)
+- [`expo-constants`](https://docs.expo.dev/versions/latest/sdk/constants/)
+- [`expo-application`](https://docs.expo.dev/versions/latest/sdk/application/)
+- [`expo-secure-store`](https://docs.expo.dev/versions/latest/sdk/securestore/)
+- [`expo-task-manager`](https://docs.expo.dev/versions/latest/sdk/task-manager/)
 
-## 📋 Requirements
+Notes:
 
-- iOS **13.0+**
-- Android **API 26+**
-- [`@op-engineering/op-sqlite`](https://github.com/OP-Engineering/op-sqlite) **^15.1.14**
-- [`@react-native-community/netinfo`](https://github.com/react-native-netinfo/react-native-netinfo) **^11.0.0**
-- [SQLite Cloud](https://sqlitecloud.io/) account
-- **Optional (for push mode):**
-  - [`expo-notifications`](https://docs.expo.dev/versions/latest/sdk/notifications/) - Push notification handling
-  - [`expo-constants`](https://docs.expo.dev/versions/latest/sdk/constants/) - EAS project ID for push tokens
-  - [`expo-application`](https://docs.expo.dev/versions/latest/sdk/application/) - Device ID for push token registration
-  - [`expo-secure-store`](https://docs.expo.dev/versions/latest/sdk/securestore/) - Persisting background sync config and push token state
-  - [`expo-task-manager`](https://docs.expo.dev/versions/latest/sdk/task-manager/) - Background/terminated notification sync
+- `expo-notifications`, `expo-constants`, and `expo-application` are needed for push token registration.
+- `expo-task-manager` and `expo-secure-store` are additionally required for `notificationListening="always"`.
+- Testing push notifications require a real device. Simulators and emulators are not enough for a full push flow.
 
-> **Note:** This library is **native-only** (iOS/Android). Web is not supported.
+## Choose Your Auth Model
 
-## 📦 Installation
+| Auth prop     | Use when                                       | Notes                            |
+| ------------- | ---------------------------------------------- | -------------------------------- |
+| `apiKey`      | Your app uses database-level access            | Simpler setup                    |
+| `accessToken` | Your app uses SQLite Cloud access tokens / RLS | Use this for signed-in user auth |
 
-### 1. Install Dependencies
+## Choose Your Sync Mode
+
+| Mode      | Best for                                       | Requirements                    | Tradeoffs                                |
+| --------- | ---------------------------------------------- | ------------------------------- | ---------------------------------------- |
+| `polling` | Most apps, easiest setup, predictable behavior | No Expo push packages required  | Checks periodically instead of instantly |
+| `push`    | Apps that need near real-time sync triggers    | Expo push setup and permissions | More setup, may fall back to polling     |
+
+## Installation
+
+### 1. Install Required Dependencies
 
 ```bash
 npm install @sqliteai/sqlite-sync-react-native @op-engineering/op-sqlite @react-native-community/netinfo
@@ -63,7 +74,7 @@ npm install @sqliteai/sqlite-sync-react-native @op-engineering/op-sqlite @react-
 yarn add @sqliteai/sqlite-sync-react-native @op-engineering/op-sqlite @react-native-community/netinfo
 ```
 
-**Optional: For push mode (Expo projects only)**
+Optional Expo packages for push mode:
 
 ```bash
 npx expo install expo-notifications expo-constants expo-application expo-secure-store expo-task-manager
@@ -71,66 +82,40 @@ npx expo install expo-notifications expo-constants expo-application expo-secure-
 
 ### 2. Platform Setup
 
-#### iOS
-
-```bash
-cd ios && pod install
-```
-
-#### Android
-
-No additional setup required. Native modules are linked automatically.
-
 #### Expo
 
-If using Expo, you must use **development builds** (Expo Go is not supported).
+If you use Expo, you must use development builds. Expo Go is not supported because this library depends on native modules.
 
-**1. Set Android `minSdkVersion` to 26** (Expo defaults to 24, which will cause a build failure):
+Set Android `minSdkVersion` to `26`:
 
 ```bash
 npx expo install expo-build-properties
 ```
 
-Add to your `app.json` or `app.config.js` plugins:
+Add this plugin to `app.json` or `app.config.js`:
 
 ```json
 ["expo-build-properties", { "android": { "minSdkVersion": 26 } }]
 ```
 
-**2. Build and run:**
-
-```bash
-# Generate native directories
-npx expo prebuild
-
-# Run on iOS/Android
-npx expo run:ios
-# or
-npx expo run:android
-```
-
-## 🚀 Quick Start
+## Quick Start
 
 ### 1. Set Up SQLite Cloud
 
-1. **Create an account**  
-   Sign up at the [SQLite Cloud Dashboard](https://dashboard.sqlitecloud.io/).
+1. Create an account at the [SQLite Cloud Dashboard](https://dashboard.sqlitecloud.io/).
+2. Create a database by following the [database creation guide](https://docs.sqlitecloud.io/docs/create-database).
+3. Create your tables in SQLite Cloud.
+4. Enable OffSync by following the [OffSync setup guide](https://docs.sqlitecloud.io/docs/offsync#:~:text=in%20the%20cloud.-,Configuring%20OffSync,-You%20can%20enable).
+5. Copy your `databaseId` and either your `apiKey` or plan to provide the current signed-in user's `accessToken`.
 
-2. **Create a database**  
-   Follow the [database creation guide](https://docs.sqlitecloud.io/docs/create-database).
+Schema requirements matter:
 
-   > Ensure your tables have identical schemas in both local and cloud databases. See [SQLite Sync Best Practices](https://docs.sqlitecloud.io/docs/sqlite-sync-best-practices) for schema requirements (e.g., all non-primary-key `NOT NULL` columns must have a `DEFAULT` value).
-
-3. **Enable OffSync**  
-   Configure OffSync by following the [OffSync setup guide](https://docs.sqlitecloud.io/docs/offsync#:~:text=in%20the%20cloud.-,Configuring%20OffSync,-You%20can%20enable).
-
-4. **Get credentials**  
-   Copy your **connection string** and **API key** from the dashboard.
-   - Alternatively, you can use [access tokens](https://docs.sqlitecloud.io/docs/access-tokens) for [Row-Level Security](https://docs.sqlitecloud.io/docs/rls).
+- Your local table schema must exactly match the cloud table schema.
+- For SQLite Sync schema best practices, see [SQLite Sync Best Practices](https://docs.sqlitecloud.io/docs/sqlite-sync-best-practices).
 
 ### 2. Wrap Your App
 
-The `SQLiteSyncProvider` needs a `createTableSql` statement for each table you want to sync. This is required because tables must exist before SQLiteSync initialization.
+The provider needs a `createTableSql` statement for every table you want synchronized. That SQL is executed locally before sync initialization.
 
 ```typescript
 import { SQLiteSyncProvider } from '@sqliteai/sqlite-sync-react-native';
@@ -138,11 +123,10 @@ import { SQLiteSyncProvider } from '@sqliteai/sqlite-sync-react-native';
 export default function App() {
   return (
     <SQLiteSyncProvider
-      connectionString="sqlitecloud://your-host.sqlite.cloud:8860/your-database"
+      databaseId="db_xxxxxxxxxxxxxxxxxxxxxxxx"
       databaseName="myapp.db"
       apiKey="your-api-key"
       syncMode="polling"
-      adaptivePolling={{ baseInterval: 5000 }}
       tablesToBeSynced={[
         {
           name: 'tasks',
@@ -163,9 +147,9 @@ export default function App() {
 }
 ```
 
-### 3. Read Data
+### 3. Read Data Reactively
 
-Use `useSqliteSyncQuery` to run a reactive query that automatically updates when the table changes — no manual refresh needed.
+Use `useSqliteSyncQuery` for UI reads that should automatically update when data changes locally or from sync.
 
 ```typescript
 import { useSqliteSyncQuery } from '@sqliteai/sqlite-sync-react-native';
@@ -177,7 +161,11 @@ interface Task {
 }
 
 function TaskList() {
-  const { data: tasks, isLoading, error } = useSqliteSyncQuery<Task>({
+  const {
+    data: tasks,
+    isLoading,
+    error,
+  } = useSqliteSyncQuery<Task>({
     query: 'SELECT * FROM tasks ORDER BY created_at DESC',
     arguments: [],
     fireOn: [{ table: 'tasks' }],
@@ -196,9 +184,9 @@ function TaskList() {
 }
 ```
 
-### 4. Write Data
+### 4. Write Data With Transactions
 
-Use `useSqliteTransaction` to insert or update rows. Transactions automatically trigger reactive queries to re-run.
+Use `useSqliteTransaction` for app writes that should trigger reactive queries.
 
 ```typescript
 import { useSqliteTransaction } from '@sqliteai/sqlite-sync-react-native';
@@ -213,16 +201,15 @@ function AddTaskButton() {
         [title]
       );
     });
-    // The reactive query in TaskList updates automatically!
   };
 
   return <Button title="Add Task" onPress={() => addTask('New Task')} />;
 }
 ```
 
-### 5. Sync & Status
+### 5. Show Sync Status And Manual Sync
 
-Use `useTriggerSqliteSync` to manually trigger a sync, and `useSyncStatus` to display sync state in your UI.
+Use `useTriggerSqliteSync` for manual sync and `useSyncStatus` to render status.
 
 ```typescript
 import {
@@ -232,10 +219,11 @@ import {
 
 function SyncControls() {
   const { triggerSync } = useTriggerSqliteSync();
-  const { isSyncing, lastSyncTime, syncError } = useSyncStatus();
+  const { isSyncing, lastSyncTime, syncError, syncMode } = useSyncStatus();
 
   return (
     <View>
+      <Text>Mode: {syncMode}</Text>
       <Button
         title={isSyncing ? 'Syncing...' : 'Sync Now'}
         onPress={triggerSync}
@@ -250,165 +238,208 @@ function SyncControls() {
 }
 ```
 
-## 🔄 Sync Behavior
+## Sync Behavior
 
-The library provides intelligent, lifecycle-aware synchronization that adapts to app state, network conditions, and sync activity.
+The library provides lifecycle-aware synchronization that adapts to app state, network availability, and previous sync activity.
 
 ### Sync Triggers
 
-Synchronization happens automatically in response to meaningful events:
-
-**Primary Triggers:**
-
-- **App start** → immediate sync
-- **App resume from background** → immediate sync (debounced to 5s)
-- **Network reconnection** → immediate sync
+Primary triggers:
 
-**Secondary Triggers:**
+- App start -> immediate sync
+- App resume from background -> immediate sync, debounced to 5 seconds
+- Network reconnection -> immediate sync
 
-_Polling Mode:_
+Secondary triggers:
 
-- **Periodic polling while foreground** → interval adapts based on activity
-- **No polling when backgrounded**
+- Polling mode: Periodic polling while app is foregrounded
+- Push mode: Push notification from SQLite Cloud triggers sync when server changes are available
 
-_Push Mode (Expo only):_
+### Adaptive Polling Algorithm
 
-- **Push notification from SQLite Cloud** → immediate sync when changes detected on server
+In polling mode, the sync interval changes based on activity:
 
-### Adaptive Polling Algorithm
+1. Default state: use `baseInterval` (default `5000`)
+2. Idle backoff: after `emptyThreshold` consecutive empty syncs (default `5`), the interval increases gradually based on `idleBackoffMultiplier`
+3. Error backoff: on sync failures, the interval increases more aggressively based on `errorBackoffMultiplier`
+4. Reset on activity: any sync with changes resets the interval to `baseInterval`
+5. Foreground priority: resuming the app triggers an immediate sync and resets the interval
 
-In polling mode, the sync interval intelligently adjusts based on activity:
+Example idle progression:
 
-1. **Default State:** Uses `baseInterval` (default: 5s)
-2. **Idle Backoff:** After `emptyThreshold` consecutive empty syncs (default: 5), interval increases gradually
-   - Example: 5s → 7.5s → 11.25s → ... (capped at `maxInterval`)
-3. **Error Backoff:** On sync failures, interval doubles exponentially
-   - Example: 5s → 10s → 20s → 40s → ... (capped at `maxInterval`)
-4. **Reset on Activity:** Any sync with changes resets interval to `baseInterval`
-5. **Foreground Priority:** App resume triggers immediate sync and resets interval
+```text
+5s -> 7.5s -> 11.25s -> ...
+```
 
-**Example Timeline:**
+Example error progression:
 
+```text
+5s -> 10s -> 20s -> 40s -> ...
 ```
-App Start:        Sync (0 changes) → Next in 5s
-5s later:         Sync (0 changes) → Next in 5s
-10s later:        Sync (0 changes) → Next in 5s
-15s later:        Sync (0 changes) → Next in 5s
-20s later:        Sync (0 changes) → Next in 5s
-25s later:        Sync (0 changes) → Next in 7.5s (idle backoff started)
-32.5s later:      Sync (0 changes) → Next in 11.25s
-43.75s later:     Sync (5 changes) → Next in 5s (reset to base)
+
+Example timeline:
+
+```text
+App Start:        Sync (0 changes) -> Next in 5s
+5s later:         Sync (0 changes) -> Next in 5s
+10s later:        Sync (0 changes) -> Next in 5s
+15s later:        Sync (0 changes) -> Next in 5s
+20s later:        Sync (0 changes) -> Next in 5s
+25s later:        Sync (0 changes) -> Next in 7.5s
+32.5s later:      Sync (0 changes) -> Next in 11.25s
+43.75s later:     Sync (5 changes) -> Next in 5s
 App backgrounded: Polling paused
-App foregrounded: Sync immediately → Next in 5s
+App foregrounded: Sync immediately -> Next in 5s
 ```
 
-### Push Mode (Expo Only)
+### Push Mode
 
-Push notifications from SQLite Cloud trigger sync when there are changes to be synced. Sync still happens on foreground/network reconnect for reliability.
+Push notifications from SQLite Cloud trigger sync when there are changes to fetch. Sync still happens on app start, foreground, and network reconnect for reliability.
 
-**Requirements:**
+Requirements:
 
-- `expo-notifications` - for push notification handling
-- `expo-constants` - for EAS project ID required by push tokens
+- `expo-notifications` for push notification handling
+- `expo-constants` for EAS project ID lookup
+- `expo-application` for device ID during push token registration
+- `expo-task-manager` for background/terminated notification handling when `notificationListening="always"`
+- `expo-secure-store` for persisted background sync config when `notificationListening="always"`
 
-**Graceful Degradation:**
+Setup:
 
-- If packages not installed → warning logged, push mode disabled
-- If permissions denied → automatic fallback to polling mode
-- If token retrieval fails → automatic fallback to polling mode
+1. Install the Expo packages listed above:
 
-## 🎯 API Reference
+```bash
+npx expo install expo-notifications expo-constants expo-application expo-secure-store expo-task-manager
+```
 
-### `SQLiteSyncProvider`
+2. If you use `notificationListening="always"`, configure background notifications in `app.json` / `app.config.js`:
 
-Main provider component that enables sync functionality.
+```json
+{
+  "expo": {
+    "plugins": [
+      ["expo-notifications", { "enableBackgroundRemoteNotifications": true }]
+    ],
+    "ios": {
+      "infoPlist": {
+        "UIBackgroundModes": ["remote-notification"]
+      }
+    }
+  }
+}
+```
 
-#### Props
+3. Configure push credentials by following the [Expo Push Notifications setup guide](https://docs.expo.dev/push-notifications/push-notifications-setup/).
 
-| Prop                            | Type                        | Required | Description                                                |
-| ------------------------------- | --------------------------- | -------- | ---------------------------------------------------------- |
-| `connectionString`              | `string`                    | ✅       | SQLite Cloud connection string                             |
-| `databaseName`                  | `string`                    | ✅       | Local database file name                                   |
-| `tablesToBeSynced`              | `TableConfig[]`             | ✅       | Array of tables to sync                                    |
-| `apiKey`                        | `string`                    | \*       | API key for authentication                                 |
-| `accessToken`                   | `string`                    | \*       | Access token for RLS authentication                        |
-| `syncMode`                      | `'polling' \| 'push'`       | ❌       | Sync mode (default: `'polling'`)                           |
-| `adaptivePolling`               | `AdaptivePollingConfig`     | ❌       | Adaptive polling configuration (polling mode only)         |
-| `notificationListening`         | `'foreground' \| 'always'`  | ❌       | When to listen for push notifications (push mode only)     |
-| `renderPushPermissionPrompt`    | `(props: { allow: () => void; deny: () => void }) => ReactNode` | ❌ | Render prop for permission prompt UI (push mode only) |
-| `onDatabaseReady`               | `(db: DB) => Promise<void>` | ❌       | Callback after DB opens, before sync init (for migrations) |
-| `debug`                         | `boolean`                   | ❌       | Enable debug logging (default: `false`)                    |
-| `children`                      | `ReactNode`                 | ✅       | Child components                                           |
-
-\* Either `apiKey` or `accessToken` is required
+- iOS: use a paid Apple Developer account, register the physical iOS device you want to test on, and let EAS set up push notifications and generate an APNs key for the app during your first development build
+- Android: create a Firebase project, add an Android app in Firebase with the same package name as your Expo app, set up FCM V1 credentials, then place the generated `google-services.json` in your project and connect those credentials to Expo
 
-#### Sync Modes
+4. If you use [Expo enhanced security](https://docs.expo.dev/push-notifications/sending-notifications/#additional-security), configure your Expo access token in SQLite Cloud Dashboard > OffSync > Configuration. The Expo access token is optional unless enhanced security is enabled.
+
+### `notificationListening` Modes
+
+| App State  | `notificationListening="foreground"`                     | `notificationListening="always"`                                                                                       |
+| ---------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| Foreground | Notification triggers sync on the existing DB connection | Same behavior                                                                                                          |
+| Background | Notification ignored                                     | Background task opens a DB connection, syncs, then calls `registerBackgroundSyncCallback` if registered                |
+| Terminated | Notification ignored                                     | Background task wakes the app, opens a DB connection, syncs, then calls `registerBackgroundSyncCallback` if registered |
+
+> 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.
 
-The library supports two sync modes:
+## API Reference
 
-**Polling Mode (Default)**
-Adaptive polling with intelligent interval adjustments:
+### `SQLiteSyncProvider`
 
-- Syncs on app foreground, network reconnect
-- Backs off when idle (no changes detected)
-- Exponential backoff on errors
-- Pauses when app is backgrounded
+Main provider component that enables sync functionality.
 
-**Push Mode (Expo only)**
-Uses push notifications from SQLite Cloud:
+#### Props
 
-- Automatically falls back to polling if permissions denied
-- Still syncs on foreground/network reconnect for reliability
+| Prop                         | Type                                                            | Required      | Description                                           |
+| ---------------------------- | --------------------------------------------------------------- | ------------- | ----------------------------------------------------- |
+| `databaseId`                 | `string`                                                        | Yes           | SQLite Sync database ID used by runtime sync APIs     |
+| `databaseName`               | `string`                                                        | Yes           | Local database file name                              |
+| `tablesToBeSynced`           | `TableConfig[]`                                                 | Yes           | Array of tables to sync                               |
+| `apiKey`                     | `string`                                                        | Conditionally | API key authentication                                |
+| `accessToken`                | `string`                                                        | Conditionally | Signed-in user access token authentication            |
+| `syncMode`                   | `'polling' \| 'push'`                                           | Yes           | Sync mode                                             |
+| `adaptivePolling`            | `AdaptivePollingConfig`                                         | No            | Polling configuration; defaults are used when omitted |
+| `notificationListening`      | `'foreground' \| 'always'`                                      | No            | Push listening behavior                               |
+| `renderPushPermissionPrompt` | `(props: { allow: () => void; deny: () => void }) => ReactNode` | No            | Custom pre-permission UI for push mode                |
+| `onDatabaseReady`            | `(db: DB) => Promise<void>`                                     | No            | Called after DB opens and before sync init            |
+| `debug`                      | `boolean`                                                       | No            | Enable debug logs                                     |
+| `children`                   | `ReactNode`                                                     | Yes           | App content                                           |
 
-**Example configurations:**
+#### Sync Modes
 
 ```typescript
-// Polling mode with default settings (recommended)
+// Polling mode with runtime defaults
 <SQLiteSyncProvider
+  databaseId="db_xxxxxxxxxxxxxxxxxxxxxxxx"
+  databaseName="myapp.db"
+  apiKey="your-api-key"
+  tablesToBeSynced={[...]}
   syncMode="polling"
-  // Uses defaults: baseInterval=5s, maxInterval=5min, emptyThreshold=5
 >
 
 // Polling mode with custom intervals
 <SQLiteSyncProvider
+  databaseId="db_xxxxxxxxxxxxxxxxxxxxxxxx"
+  databaseName="myapp.db"
+  apiKey="your-api-key"
+  tablesToBeSynced={[...]}
   syncMode="polling"
   adaptivePolling={{
-    baseInterval: 3000,    // 3s base interval
-    maxInterval: 60000,    // 1min maximum backoff
-    emptyThreshold: 3      // Back off after 3 empty syncs
+    baseInterval: 3000,
+    maxInterval: 60000,
+    emptyThreshold: 3
   }}
 >
 
-// Push mode (requires expo-notifications)
+// Push mode - foreground only
 <SQLiteSyncProvider
+  databaseId="db_xxxxxxxxxxxxxxxxxxxxxxxx"
+  databaseName="myapp.db"
+  apiKey="your-api-key"
+  tablesToBeSynced={[...]}
   syncMode="push"
-  // Automatically falls back to polling if permissions denied
+  notificationListening="foreground"
+>
+
+// Push mode - foreground + background + terminated
+<SQLiteSyncProvider
+  databaseId="db_xxxxxxxxxxxxxxxxxxxxxxxx"
+  databaseName="myapp.db"
+  apiKey="your-api-key"
+  tablesToBeSynced={[...]}
+  syncMode="push"
+  notificationListening="always"
+  renderPushPermissionPrompt={({ allow, deny }) => (
+    <YourCustomPermissionDialog onAllow={allow} onDeny={deny} />
+  )}
 >
 ```
 
-#### Background Sync Callback (Push Mode)
+#### Background Sync Callback
 
-When using push mode with `notificationListening: 'always'`, you can register a callback that runs after background sync completes. This is useful for showing local notifications about new data:
+When using push mode with `notificationListening="always"`, you can register a callback that runs after a background sync completes.
 
 ```typescript
 import { registerBackgroundSyncCallback } from '@sqliteai/sqlite-sync-react-native';
 import * as Notifications from 'expo-notifications';
 
-// Register at module level (outside components)
 registerBackgroundSyncCallback(async ({ changes, db }) => {
-  // Find new inserts in your table
   const newItems = changes.filter(
     (c) => c.table === 'tasks' && c.operation === 'INSERT'
   );
 
   if (newItems.length === 0) return;
 
-  // Query the synced data
   const result = await db.execute(
-    `SELECT * FROM tasks WHERE rowid IN (${newItems.map((c) => c.rowId).join(',')})`
+    `SELECT * FROM tasks WHERE rowid IN (${newItems
+      .map((c) => c.rowId)
+      .join(',')})`
   );
 
-  // Show a local notification
   await Notifications.scheduleNotificationAsync({
     content: {
       title: `${newItems.length} new tasks synced`,
@@ -419,22 +450,20 @@ registerBackgroundSyncCallback(async ({ changes, db }) => {
 });
 ```
 
-#### Database Migrations with `onDatabaseReady`
+#### Database Migrations With `onDatabaseReady`
 
-Use `onDatabaseReady` to run migrations or other setup after the database opens but before sync initialization:
+Use `onDatabaseReady` to run migrations or setup after the database opens and before sync initialization.
 
 ```typescript
 <SQLiteSyncProvider
-  connectionString="..."
+  databaseId="db_xxxxxxxxxxxxxxxxxxxxxxxx"
   databaseName="myapp.db"
   apiKey="..."
   tablesToBeSynced={[...]}
   onDatabaseReady={async (db) => {
-    // Check current schema version
     const { rows } = await db.execute('PRAGMA user_version');
     const version = rows?.[0]?.user_version ?? 0;
 
-    // Run migrations
     if (version < 1) {
       await db.execute('ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 0');
       await db.execute('PRAGMA user_version = 1');
@@ -443,12 +472,16 @@ Use `onDatabaseReady` to run migrations or other setup after the database opens
 >
 ```
 
-#### Custom Push Permission UI with `renderPushPermissionPrompt`
+#### Custom Push Permission UI With `renderPushPermissionPrompt`
 
-When using push mode, the system will prompt the user for notification permissions. Use `renderPushPermissionPrompt` to show your own UI before the system prompt appears. The render prop receives `allow` and `deny` callbacks — no Promise or ref boilerplate needed.
+Use `renderPushPermissionPrompt` to show your own UI before the system notification permission prompt appears.
 
 ```tsx
 <SQLiteSyncProvider
+  databaseId="db_xxxxxxxxxxxxxxxxxxxxxxxx"
+  databaseName="myapp.db"
+  apiKey="your-api-key"
+  tablesToBeSynced={[...]}
   syncMode="push"
   renderPushPermissionPrompt={({ allow, deny }) => (
     <Modal visible animationType="fade" transparent>
@@ -463,7 +496,6 @@ When using push mode, the system will prompt the user for notification permissio
       </View>
     </Modal>
   )}
-  // ...other props
 >
   <YourApp />
 </SQLiteSyncProvider>
@@ -471,52 +503,34 @@ When using push mode, the system will prompt the user for notification permissio
 
 #### `AdaptivePollingConfig`
 
-Configuration for adaptive polling behavior (polling mode only).
-
 ```typescript
 interface AdaptivePollingConfig {
-  /**
-   * Base sync interval in milliseconds
-   * Default: 5000 (5 seconds)
-   */
   baseInterval?: number;
-
-  /**
-   * Maximum interval during backoff in milliseconds
-   * Default: 300000 (5 minutes)
-   */
   maxInterval?: number;
-
-  /**
-   * Number of consecutive empty syncs before backing off
-   * Default: 5
-   */
   emptyThreshold?: number;
-
-  /**
-   * Multiplier for idle backoff (when no changes detected)
-   * Default: 1.5 (gentle backoff)
-   */
   idleBackoffMultiplier?: number;
-
-  /**
-   * Multiplier for error backoff (when sync fails)
-   * Default: 2.0 (aggressive backoff)
-   */
   errorBackoffMultiplier?: number;
 }
 ```
 
+Defaults:
+
+- `baseInterval`: `5000`
+- `maxInterval`: `300000`
+- `emptyThreshold`: `5`
+- `idleBackoffMultiplier`: `1.5`
+- `errorBackoffMultiplier`: `2.0`
+
 #### `TableConfig`
 
 ```typescript
 interface TableConfig {
-  name: string; // Table name (must match cloud table)
-  createTableSql: string; // CREATE TABLE SQL statement
+  name: string;
+  createTableSql: string;
 }
 ```
 
-**Example:**
+Example:
 
 ```typescript
 {
@@ -532,35 +546,20 @@ interface TableConfig {
 }
 ```
 
-**Important:**
+Important:
 
-- Always include `IF NOT EXISTS` to prevent errors if the table already exists
-- The table schema must match exactly to your remote table schema in SQLite Cloud
-- The library executes this SQL during initialization, before SQLiteSync setup
+- Include `IF NOT EXISTS`
+- Match the remote schema exactly
+- The library executes this SQL during initialization before SQLite Sync setup
 
 #### `ReactiveQueryConfig`
 
-Configuration for reactive queries with table-level granularity.
-
 ```typescript
 interface ReactiveQueryConfig {
-  /**
-   * The SQL query to execute
-   */
   query: string;
-
-  /**
-   * Query parameters/arguments (optional)
-   */
   arguments?: any[];
-
-  /**
-   * Tables to monitor for changes
-   */
   fireOn: Array<{
-    /** Table name to monitor */
     table: string;
-    /** Optional: specific operation to monitor (INSERT, UPDATE, or DELETE) */
     operation?: 'INSERT' | 'UPDATE' | 'DELETE';
   }>;
 }
@@ -568,76 +567,43 @@ interface ReactiveQueryConfig {
 
 #### `TableUpdateData<T>`
 
-Row-level update event data from op-sqlite's updateHook.
-
 ```typescript
 interface TableUpdateData<T = any> {
-  /**
-   * The table that was modified
-   */
   table: string;
-
-  /**
-   * The type of operation that occurred
-   *
-   * Possible values:
-   * - 'DELETE' - Row was deleted
-   * - 'INSERT' - Row was inserted
-   * - 'UPDATE' - Row was updated
-   */
   operation: 'INSERT' | 'UPDATE' | 'DELETE';
-
-  /**
-   * SQLite's internal rowid (NOT your table's primary key)
-   */
   rowId: number;
-
-  /**
-   * The row data retrieved from the database
-   *
-   * The hook automatically queries the database to fetch the row data.
-   * For DELETE operations, this will be null since the row no longer exists.
-   */
   row: T | null;
 }
 ```
 
-#### `TableUpdateConfig<T>`
+Notes:
+
+- `rowId` is SQLite's internal `rowid`, not your application primary key
+- For `DELETE`, `row` is `null`
 
-Configuration for row-level table update listeners.
+#### `TableUpdateConfig<T>`
 
 ```typescript
 interface TableUpdateConfig<T = any> {
-  /**
-   * List of table names to monitor for changes
-   */
   tables: string[];
-
-  /**
-   * Callback function executed when a monitored table is updated
-   *
-   * Receives detailed information about the row-level change
-   * including the operation type (INSERT/UPDATE/DELETE) and row data
-   */
   onUpdate: (data: TableUpdateData<T>) => void;
 }
 ```
 
 ### Contexts
 
-The library provides three separate React Contexts for optimized re-renders:
+The library exposes three React contexts.
 
 #### `SQLiteDbContext`
 
-Provides database connections and initialization errors. **Rarely changes** (only on init/error).
+Provides database connections and fatal initialization errors. This context changes rarely.
 
-**Dual Connection Architecture:**
-The library opens **two connections** to the same database file for optimal performance:
+Dual-connection architecture:
 
-- **writeDb** - Write connection for sync operations, reactive queries, update hooks, and write operations
-- **readDb** - Read-only connection for read-only queries (optional for performance)
+- `writeDb`: write connection used for sync operations, reactive subscriptions, update hooks, and writes
+- `readDb`: read-only connection for read-only queries
 
-Both connections use SQLite's WAL (Write-Ahead Logging) mode to enable concurrent read/write access without blocking.
+Both connections target the same database file and use WAL mode for concurrent access.
 
 ```typescript
 import { useContext } from 'react';
@@ -646,17 +612,15 @@ import { SQLiteDbContext } from '@sqliteai/sqlite-sync-react-native';
 const { writeDb, readDb, initError } = useContext(SQLiteDbContext);
 ```
 
-**Values:**
-
-| Property    | Type            | Description                                                                                                            |
-| ----------- | --------------- | ---------------------------------------------------------------------------------------------------------------------- |
-| `writeDb`   | `DB \| null`    | Write [op-sqlite](https://op-engineering.github.io/op-sqlite/docs/api) connection with SQLite Sync extension loaded    |
-| `readDb`    | `DB \| null`    | Read-only [op-sqlite](https://op-engineering.github.io/op-sqlite/docs/api) connection for read-only queries (optional) |
-| `initError` | `Error \| null` | Fatal database error (connections unavailable)                                                                         |
+| Property    | Type            | Description                                                                                               |
+| ----------- | --------------- | --------------------------------------------------------------------------------------------------------- |
+| `writeDb`   | `DB \| null`    | Write [op-sqlite](https://op-engineering.github.io/op-sqlite/docs/api) connection with SQLite Sync loaded |
+| `readDb`    | `DB \| null`    | Read-only [op-sqlite](https://op-engineering.github.io/op-sqlite/docs/api) connection                     |
+| `initError` | `Error \| null` | Fatal database initialization error                                                                       |
 
 #### `SQLiteSyncStatusContext`
 
-Provides sync status information. **Changes frequently** (on every sync).
+Provides sync status information. This context changes frequently.
 
 ```typescript
 import { useContext } from 'react';
@@ -666,25 +630,23 @@ const { isSyncing, lastSyncTime, syncError, syncMode, currentSyncInterval } =
   useContext(SQLiteSyncStatusContext);
 ```
 
-**Values:**
-
-| Property                | Type                  | Description                                                          |
-| ----------------------- | --------------------- | -------------------------------------------------------------------- |
-| `syncMode`              | `'polling' \| 'push'` | Current sync mode (may differ from prop if push fallback to polling) |
-| `isSyncReady`           | `boolean`             | Whether sync is configured and ready                                 |
-| `isSyncing`             | `boolean`             | Whether sync is currently in progress                                |
-| `lastSyncTime`          | `number \| null`      | Timestamp of last successful sync                                    |
-| `lastSyncChanges`       | `number`              | Number of changes in last sync                                       |
-| `syncError`             | `Error \| null`       | Recoverable sync error (db works offline-only)                       |
-| `currentSyncInterval`   | `number \| null`      | Current polling interval in ms (null in push mode)                   |
-| `consecutiveEmptySyncs` | `number`              | Number of consecutive syncs with no changes                          |
-| `consecutiveSyncErrors` | `number`              | Number of consecutive sync errors                                    |
-| `isAppInBackground`     | `boolean`             | Whether app is currently in background                               |
-| `isNetworkAvailable`    | `boolean`             | Whether network connection is available                              |
+| Property                | Type                  | Description                                      |
+| ----------------------- | --------------------- | ------------------------------------------------ |
+| `syncMode`              | `'polling' \| 'push'` | Effective runtime sync mode                      |
+| `isSyncReady`           | `boolean`             | Whether sync is configured and ready             |
+| `isSyncing`             | `boolean`             | Whether sync is in progress                      |
+| `lastSyncTime`          | `number \| null`      | Timestamp of last successful sync                |
+| `lastSyncChanges`       | `number`              | Number of changes in last sync                   |
+| `syncError`             | `Error \| null`       | Recoverable sync error                           |
+| `currentSyncInterval`   | `number \| null`      | Current polling interval, or `null` in push mode |
+| `consecutiveEmptySyncs` | `number`              | Consecutive syncs with no changes                |
+| `consecutiveSyncErrors` | `number`              | Consecutive sync errors                          |
+| `isAppInBackground`     | `boolean`             | Whether the app is currently backgrounded        |
+| `isNetworkAvailable`    | `boolean`             | Whether network connectivity is available        |
 
 #### `SQLiteSyncActionsContext`
 
-Provides stable sync action functions. **Never changes**.
+Provides stable sync action functions.
 
 ```typescript
 import { useContext } from 'react';
@@ -693,48 +655,62 @@ import { SQLiteSyncActionsContext } from '@sqliteai/sqlite-sync-react-native';
 const { triggerSync } = useContext(SQLiteSyncActionsContext);
 ```
 
-**Values:**
-
-| Property      | Type                  | Description                                   |
-| ------------- | --------------------- | --------------------------------------------- |
-| `triggerSync` | `() => Promise<void>` | Function to manually trigger a sync operation |
+| Property      | Type                  | Description             |
+| ------------- | --------------------- | ----------------------- |
+| `triggerSync` | `() => Promise<void>` | Manually trigger a sync |
 
-**Note:** Most users should use the [specialized hooks](#hooks) instead of accessing contexts directly.
+Most applications should prefer the specialized hooks instead of consuming contexts directly.
 
 ### Hooks
 
-The library provides specialized hooks for different use cases. Choose the right hook based on what data your component needs to avoid unnecessary re-renders.
-
 #### `useSqliteDb()`
 
-Access the database connections and initialization errors **without subscribing to sync updates**. This hook is optimized for components that only need database access and won't re-render on every sync.
+Access the database connections and initialization errors without subscribing to sync status updates.
 
 ```typescript
-const { writeDb, readDb, initError } = useSqliteDb();
+useSqliteDb(): {
+  writeDb: DB | null;
+  readDb: DB | null;
+  initError: Error | null;
+}
 ```
 
-**Returns:**
-
-- `writeDb`: Write database connection
-- `readDb`: Read-only database connection
-- `initError`: Fatal initialization error
+```typescript
+const { writeDb, readDb, initError } = useSqliteDb();
+```
 
-**Best for:** Components that need database access but don't need sync status.
+Returns:
 
-**Note:** In most cases, you should use the specialized hooks (`useSqliteExecute`, `useSqliteTransaction`, `useSqliteSyncQuery`) instead of accessing database connections directly.
+- `writeDb`: write database connection
+- `readDb`: read-only database connection
+- `initError`: fatal initialization error
 
-**About the database connections:**
+Use this when:
 
-Both `writeDb` and `readDb` are `DB` instances from [`@op-engineering/op-sqlite`](https://op-engineering.github.io/op-sqlite/docs/api). The `writeDb` connection has the **SQLite Sync extension loaded**. This means you can:
+- You need direct DB access
+- You do not want re-renders on sync state changes
 
-- Use the full [op-sqlite API](https://op-engineering.github.io/op-sqlite/docs/api) for standard database operations
-- Use any [SQLite Sync functions](https://github.com/sqliteai/sqlite-sync/blob/main/API.md) like `cloudsync_uuid()`, `cloudsync_changes()`, etc.
-- Use `writeDb` for all sync operations, reactive queries, and writes
-- Use `readDb` for read-only queries (optional performance optimization)
+> Note: `writeDb` and `readDb` are `DB` instances from [`@op-engineering/op-sqlite`](https://op-engineering.github.io/op-sqlite/docs/api). The `writeDb` connection has SQLite Sync loaded, so you can call standard OP-SQLite APIs and SQLite Sync functions such as `cloudsync_uuid()` or `cloudsync_changes()`.
 
 #### `useSyncStatus()`
 
-Access sync status information, use this when you need to display sync state in your UI.
+Access sync status information for UI state.
+
+```typescript
+useSyncStatus(): {
+  syncMode: 'polling' | 'push';
+  isSyncReady: boolean;
+  isSyncing: boolean;
+  lastSyncTime: number | null;
+  lastSyncChanges: number;
+  syncError: Error | null;
+  currentSyncInterval: number | null;
+  consecutiveEmptySyncs: number;
+  consecutiveSyncErrors: number;
+  isAppInBackground: boolean;
+  isNetworkAvailable: boolean;
+}
+```
 
 ```typescript
 const { syncMode, isSyncing, lastSyncTime, syncError, currentSyncInterval } =
@@ -755,54 +731,66 @@ return (
 );
 ```
 
-**Returns:**
+Use this when:
 
-- `syncMode`: Current sync mode (`'polling'` or `'push'`)
-- `isSyncReady`: Whether sync is configured
-- `isSyncing`: Whether sync is in progress
-- `lastSyncTime`: Last successful sync timestamp
-- `lastSyncChanges`: Number of changes in last sync
-- `syncError`: Recoverable sync error
-- `currentSyncInterval`: Current polling interval (null in push mode)
-- `consecutiveEmptySyncs`: Number of consecutive syncs with no changes
-- `consecutiveSyncErrors`: Number of consecutive sync errors
-- `isAppInBackground`: Whether app is in background
-- `isNetworkAvailable`: Whether network is available
-
-**Best for:** Status displays, sync indicators, monitoring components.
+- You need to render sync state
+- You want to inspect the effective runtime `syncMode`
 
 #### `useSqliteSync()`
 
-Access all sync functionality (database + status + actions). This is a convenience hook that combines all contexts.
+Convenience hook that combines DB access, sync status, and actions.
 
-**Note:** This hook will re-render on every sync operation. If you only need `db`/`initError`, use `useSqliteDb()` instead.
+```typescript
+useSqliteSync(): {
+  writeDb: DB | null;
+  readDb: DB | null;
+  initError: Error | null;
+  syncMode: 'polling' | 'push';
+  isSyncReady: boolean;
+  isSyncing: boolean;
+  lastSyncTime: number | null;
+  lastSyncChanges: number;
+  syncError: Error | null;
+  currentSyncInterval: number | null;
+  consecutiveEmptySyncs: number;
+  consecutiveSyncErrors: number;
+  isAppInBackground: boolean;
+  isNetworkAvailable: boolean;
+  triggerSync: () => Promise<void>;
+}
+```
 
 ```typescript
-const { db, initError, isSyncing, lastSyncTime, triggerSync } = useSqliteSync();
+const { writeDb, initError, isSyncing, lastSyncTime, triggerSync } =
+  useSqliteSync();
 
 return (
   <View>
-    <Text>Database: {db ? 'Ready' : 'Loading'}</Text>
+    <Text>Database: {writeDb ? 'Ready' : 'Loading'}</Text>
     <Button onPress={triggerSync} disabled={isSyncing} />
   </View>
 );
 ```
 
-**Returns:** All properties from `useSqliteDb()` + `useSyncStatus()` + `triggerSync` function
+Use this when:
 
-**Best for:** Components that need access to everything (use sparingly to avoid unnecessary re-renders).
+- You want one hook for everything
+- You accept more frequent re-renders
 
 #### `useTriggerSqliteSync()`
 
 Manually trigger a sync operation.
 
-**How it works:** This hook is a convenience wrapper that exposes the `triggerSync` function from the Provider. The actual sync logic lives in `SQLiteSyncProvider` to ensure that `isSyncing`, `lastSyncTime`, and `lastSyncChanges` state are updated correctly, allowing all hooks (`useSqliteSyncQuery`) to react properly.
+```typescript
+useTriggerSqliteSync(): {
+  triggerSync: () => Promise<void>;
+}
+```
 
 ```typescript
 const { triggerSync } = useTriggerSqliteSync();
-const { isSyncing } = useSyncStatus(); // isSyncing comes from useSyncStatus
+const { isSyncing } = useSyncStatus();
 
-// Trigger sync on button press
 <Button
   onPress={triggerSync}
   disabled={isSyncing}
@@ -812,41 +800,35 @@ const { isSyncing } = useSyncStatus(); // isSyncing comes from useSyncStatus
 
 #### `useSqliteSyncQuery(config)`
 
-Execute a reactive query with table-level granularity using op-sqlite's `reactiveExecute`.
-
-**How it works:** This hook uses [op-sqlite's reactive queries](https://op-engineering.github.io/op-sqlite/docs/reactive_queries) to automatically re-run the query when specified tables are modified via transactions. Always uses **writeDb** to ensure queries see sync changes immediately.
-
-**Key Features:**
+Execute a reactive query using OP-SQLite's `reactiveExecute`.
 
-- **Automatic updates:** Query re-runs when monitored tables change
-- **Initial data loading:** Executes query immediately when database is ready
+How it works:
 
-**Parameters:**
+- Performs the initial fetch with `readDb` when available
+- Installs the reactive subscription on `writeDb`
+- Re-runs the query when watched tables change
 
 ```typescript
 interface ReactiveQueryConfig {
-  query: string; // SQL query to execute
-  arguments?: any[]; // Query parameters (optional)
+  query: string;
+  arguments?: any[];
   fireOn: Array<{
-    // Tables to monitor
     table: string;
-    operation?: 'INSERT' | 'UPDATE' | 'DELETE'; // Optional: specific operation
+    operation?: 'INSERT' | 'UPDATE' | 'DELETE';
   }>;
 }
 ```
 
-**Returns:**
-
 ```typescript
-{
-  data: T[];              // Query results
-  isLoading: boolean;     // True during initial load
-  error: Error | null;    // Query error
-  unsubscribe: () => void; // Manually unsubscribe (auto-cleanup on unmount)
+useSqliteSyncQuery<T = any>(config: ReactiveQueryConfig): {
+  data: T[];
+  isLoading: boolean;
+  error: Error | null;
+  unsubscribe: () => void;
 }
 ```
 
-**Example:**
+Example:
 
 ```typescript
 const { data, isLoading, error } = useSqliteSyncQuery<Task>({
@@ -863,54 +845,49 @@ return (
 );
 ```
 
-**Important: Use Transactions for Writes**
+Important:
 
-Reactive queries **only fire on committed transactions**. When writing data, always use transactions:
+- Reactive queries fire on committed transactions
+- Use `useSqliteTransaction()` for writes that should invalidate reactive queries
 
 ```typescript
 const { execute } = useSqliteExecute();
 const { executeTransaction } = useSqliteTransaction();
 
-// ✅ This will trigger reactive queries
+// Triggers reactive queries
 await executeTransaction(async (tx) => {
   await tx.execute('INSERT INTO tasks (id, title) VALUES (?, ?)', [id, title]);
 });
 
-// ❌ This will NOT trigger reactive queries
+// Does not trigger reactive queries
 await execute('INSERT INTO tasks (id, title) VALUES (?, ?)', [id, title]);
 ```
 
-The library automatically wraps sync operations in transactions, so reactive queries update when cloud changes arrive.
+Sync operations are already wrapped in transactions internally, so cloud-driven changes will update reactive queries.
 
 #### `useOnTableUpdate(config)`
 
-Listen for row-level changes (INSERT, UPDATE, DELETE) on specified tables using op-sqlite's `updateHook`.
-
-**How it works:** This hook uses op-sqlite's `updateHook` to receive individual row change notifications. Unlike reactive queries which re-run the entire query, this hook fires for each row modification and automatically fetches the complete row data for you. Always uses **writeDb** to ensure update hooks see sync changes immediately.
-
-**Key Features:**
-
-- **Row-level granularity:** Callback fires for each individual row change
-- **Operation details:** Know exactly what operation (INSERT/UPDATE/DELETE) occurred
-- **Automatic row fetching:** Row data is queried and provided in the callback
-
-**Parameters:**
+Listen for row-level `INSERT`, `UPDATE`, and `DELETE` events using OP-SQLite's `updateHook`.
 
 ```typescript
 interface TableUpdateConfig<T = any> {
-  tables: string[]; // Tables to monitor
-  onUpdate: (data: TableUpdateData<T>) => void; // Callback function
+  tables: string[];
+  onUpdate: (data: TableUpdateData<T>) => void;
 }
 
 interface TableUpdateData<T = any> {
-  table: string; // Table that was modified
-  operation: 'INSERT' | 'UPDATE' | 'DELETE'; // Operation type
-  rowId: number; // SQLite internal rowid (not your primary key)
-  row: T | null; // Row data (null for DELETE operations)
+  table: string;
+  operation: 'INSERT' | 'UPDATE' | 'DELETE';
+  rowId: number;
+  row: T | null;
 }
 ```
 
-**Example:**
+```typescript
+useOnTableUpdate<T = any>(config: TableUpdateConfig<T>): void
+```
+
+Example:
 
 ```typescript
 interface Task {
@@ -926,57 +903,55 @@ useOnTableUpdate<Task>({
     console.log(`Operation: ${data.operation}`);
 
     if (data.row) {
-      // Row data is automatically fetched and typed
       Toast.show(
         `Task "${data.row.title}" was ${data.operation.toLowerCase()}d`
       );
 
-      // Update analytics
       analytics.track('task_modified', {
         operation: data.operation,
         taskId: data.row.id,
       });
     } else {
-      // DELETE operations have null row
       console.log('Row was deleted');
     }
   },
 });
 ```
 
-**Note:** For DELETE operations, `row` will be `null` since the row no longer exists in the database.
+Note:
 
-#### `useSqliteExecute()`
+- For `DELETE`, `row` is `null`
+- `rowId` is SQLite internal state, not your domain ID
 
-Execute SQL commands with configurable connection selection (write or read-only).
-
-**How it works:** This hook provides an imperative way to execute SQL commands. Unlike `useSqliteSyncQuery` (which is declarative and reactive), this hook ensures all requests are processed by SQLite in the order they're called.
+#### `useSqliteExecute()`
 
-**Connection Selection:**
+Execute SQL imperatively with configurable connection selection.
 
-- By default, uses **writeDb** (sees sync changes, can write)
-- Pass `{ readOnly: true }` to use **readDb** (read-only queries)
+Connection selection:
 
-**Parameters:**
+- Default: `writeDb`
+- Pass `{ readOnly: true }` to use `readDb`
 
 ```typescript
 interface SqliteExecuteOptions {
-  readOnly?: boolean; // Use read-only connection (default: false)
+  readOnly?: boolean;
+  autoSync?: boolean;
 }
 ```
 
-**Returns:**
-
 ```typescript
-{
-  execute: (sql: string, params?: any[], options?: SqliteExecuteOptions) =>
-    Promise<QueryResult | undefined>;
-  isExecuting: boolean; // True while executing
-  error: Error | null; // Execution error
+useSqliteExecute(): {
+  execute: (
+    sql: string,
+    params?: any[],
+    options?: SqliteExecuteOptions
+  ) => Promise<QueryResult | undefined>;
+  isExecuting: boolean;
+  error: Error | null;
 }
 ```
 
-**Example:**
+Example:
 
 ```typescript
 import { useSqliteExecute } from '@sqliteai/sqlite-sync-react-native';
@@ -986,7 +961,6 @@ function TaskManager() {
 
   const addTask = async (title: string) => {
     try {
-      // Write operation (uses writeDb by default)
       const result = await execute(
         'INSERT INTO tasks (id, title) VALUES (cloudsync_uuid(), ?)',
         [title]
@@ -999,7 +973,6 @@ function TaskManager() {
 
   const getTask = async (id: string) => {
     try {
-      // Read operation (explicitly use readDb for performance)
       const result = await execute('SELECT * FROM tasks WHERE id = ?', [id], {
         readOnly: true,
       });
@@ -1022,25 +995,39 @@ function TaskManager() {
 }
 ```
 
-**Important:** Direct `execute()` calls do NOT trigger reactive queries. To trigger reactive queries, use `useSqliteTransaction()` instead.
+Important:
 
-> **Note:** This hook automatically syncs changes to the cloud after each write, so your data is pushed immediately. If you use op-sqlite's `db.execute()` directly instead, changes will **not** be synced automatically — you would need to call `db.execute('SELECT cloudsync_network_send_changes()')` manually.
+- Direct `execute()` writes do not trigger reactive queries
+- Use `useSqliteTransaction()` when you need reactive invalidation
 
-#### `useSqliteTransaction()`
+This hook automatically sends local write changes to the cloud unless you disable it:
 
-Execute SQL commands within a transaction for atomic write operations.
+```typescript
+await execute(
+  'INSERT INTO local_cache (key, value) VALUES (?, ?)',
+  [key, value],
+  { autoSync: false }
+);
+```
+
+If you call `db.execute()` directly through OP-SQLite, automatic cloud send does not happen for you.
 
-**Returns:**
+#### `useSqliteTransaction()`
+
+Execute SQL commands within a transaction for atomic writes.
 
 ```typescript
-{
-  executeTransaction: (fn: (tx: Transaction) => Promise<void>) => Promise<void>;
-  isExecuting: boolean; // True while executing
-  error: Error | null; // Transaction error
+useSqliteTransaction(): {
+  executeTransaction: (
+    fn: (tx: Transaction) => Promise<void>,
+    options?: { autoSync?: boolean }
+  ) => Promise<void>;
+  isExecuting: boolean;
+  error: Error | null;
 }
 ```
 
-**Example:**
+Example:
 
 ```typescript
 import { useSqliteTransaction } from '@sqliteai/sqlite-sync-react-native';
@@ -1050,8 +1037,6 @@ function TaskManager() {
 
   const addTaskWithLog = async (title: string) => {
     try {
-      // Execute multiple writes atomically
-      // This will trigger reactive queries when it commits
       await executeTransaction(async (tx) => {
         await tx.execute(
           'INSERT INTO tasks (id, title) VALUES (cloudsync_uuid(), ?)',
@@ -1068,6 +1053,18 @@ function TaskManager() {
     }
   };
 
+  const addLocalOnlyTask = async (title: string) => {
+    await executeTransaction(
+      async (tx) => {
+        await tx.execute('INSERT INTO tasks (id, title) VALUES (?, ?)', [
+          'local-id',
+          title,
+        ]);
+      },
+      { autoSync: false }
+    );
+  };
+
   return (
     <Button
       title="Add Task"
@@ -1078,78 +1075,106 @@ function TaskManager() {
 }
 ```
 
-**Important:** Transactions automatically trigger reactive queries when they commit successfully. This is the recommended way to write data when using `useSqliteSyncQuery`.
+Important:
 
-> **Note:** This hook automatically syncs changes to the cloud after each transaction commits, so your data is pushed immediately. If you use op-sqlite's `db.transaction()` directly instead, changes will **not** be synced automatically — you would need to call `db.execute('SELECT cloudsync_network_send_changes()')` manually.
+- Transactions trigger reactive queries on successful commit
+- This is the recommended write path for data displayed with `useSqliteSyncQuery`
+- The hook auto-sends changes to the cloud after commit unless `autoSync` is `false`
 
-## 🚨 Error Handling
+To skip automatic sync:
 
-The library separates **fatal database errors** from **recoverable sync errors** to enable true offline-first operation.
+```typescript
+await executeTransaction(
+  async (tx) => {
+    await tx.execute('INSERT INTO local_cache (key, value) VALUES (?, ?)', [
+      key,
+      value,
+    ]);
+  },
+  { autoSync: false }
+);
+```
+
+> Note: If you use `db.transaction()` directly through OP-SQLite, automatic cloud send does not happen for you.
+
+## Error Handling
+
+The library separates fatal database errors from recoverable sync errors so the app can continue to work offline whenever possible.
 
 ### Database Errors (`initError`)
 
-**Fatal errors** that prevent the database from working at all. The app cannot function when these occur.
+These are fatal. The database is unavailable.
 
 ```typescript
-const { initError, db } = useSqliteDb();
+const { initError, writeDb } = useSqliteDb();
 
 if (initError) {
   return <ErrorScreen message="Database unavailable" />;
 }
 ```
 
-**Common causes:**
+Common causes:
 
-- Unsupported platform (web, Windows, etc.)
+- Unsupported platform
 - Missing database name
-- Failed to open database file
+- Failed to open the database file
 - Failed to create tables
 
-**When this happens:** The `db` instance will be `null` and the app cannot work offline or online.
+When this happens:
+
+- `writeDb` is `null`
+- `readDb` is `null`
+- The app cannot operate offline or online
 
 ### Sync Errors (`syncError`)
 
-**Recoverable errors** that prevent syncing but allow full offline database access.
+These are recoverable. The local database still works.
 
 ```typescript
-const { db } = useSqliteDb();
+const { writeDb } = useSqliteDb();
 const { syncError } = useSyncStatus();
 
-// Database still works offline even with syncError!
-if (db) {
-  await db.execute('INSERT INTO tasks ...');
+if (writeDb) {
+  await writeDb.execute('INSERT INTO tasks ...');
 }
 
-// Show non-blocking warning
 {
   syncError && <Banner warning={syncError.message} />;
 }
 ```
 
-**Common causes:**
+Common causes:
 
-- Missing or invalid connection string
-- Missing or invalid API key/access token
-- SQLiteSync extension failed to load
+- Invalid `databaseId`
+- Invalid `apiKey` or `accessToken`
+- SQLite Sync extension failed to load
 - Network initialization failed
 - Temporary network connectivity issues
 
-**When this happens:** The `db` instance is available and fully functional for local-only operations. Sync will be retried on the next interval or when credentials are provided.
+When this happens:
+
+- `writeDb` and `readDb` remain available
+- The app still works offline
+- Sync retries later when conditions improve
 
-**Important:** Sync errors automatically clear on the next successful sync.
+Sync errors clear automatically after the next successful sync.
 
-## 🐛 Debug Logging
+## Debug Logging
 
-Enable detailed logging during development:
+Enable verbose development logging with the `debug` prop:
 
 ```typescript
 <SQLiteSyncProvider
-  // ... other props
-  debug={__DEV__} // Enable in development only
+  databaseId="db_xxxxxxxxxxxxxxxxxxxxxxxx"
+  databaseName="myapp.db"
+  apiKey="your-api-key"
+  tablesToBeSynced={[...]}
+  syncMode="polling"
+  debug={__DEV__}
 >
 ```
 
-**When enabled, logs:**
+When enabled, logs include:
 
 - Database initialization steps
 - Extension loading
@@ -1158,35 +1183,51 @@ Enable detailed logging during development:
 - Sync operations
 - Change counts
 
-## ⚠️ Known Issues & Improvements
+## Known Issues & Improvements
+
+### Reactive Queries And Write Connection Contention
+
+Issue:
 
-### Reactive Queries & Write Connection Contention
+`useSqliteSyncQuery` uses `writeDb` with OP-SQLite's `reactiveExecute` so queries see sync changes immediately. Under heavy write or sync activity, the write connection can become a bottleneck.
 
-**Issue:** `useSqliteSyncQuery` uses `writeDb` with op-sqlite's `reactiveExecute` to ensure queries see sync changes immediately. However, when data changes frequently (e.g., during active sync), the write connection can become a bottleneck.
+Potential improvement:
 
-**Potential improvement:** Have `cloudsync_network_sync()` return the list of updated tables. This would allow reactive queries to use `readDb` instead, with manual invalidation when sync completes for specific tables. Trade-off: manual invalidation logic vs. op-sqlite's built-in `reactiveExecute`.
+Have `cloudsync_network_sync()` return updated table names so reactive queries could read from `readDb` and invalidate manually.
 
-### Optimistic Updates & Sync Blocking
+### Optimistic Updates And Sync Blocking
 
-**Issue:** With push notifications, sync can be triggered frequently. Since sync operations use `writeDb`, a user writing to the local database at the same time may experience delayed UI updates while waiting for the sync transaction to complete.
+Issue:
 
-**Potential improvement:** Implement optimistic updates where local writes update the UI immediately, independent of sync status. This requires careful handling of conflict resolution when sync completes.
+In push-heavy scenarios, sync can occupy `writeDb` frequently enough that local writes may feel delayed.
+
+Potential improvement:
+
+Introduce optimistic UI updates independent of sync completion, with conflict-resolution rules when sync finishes.
 
 ### First Install Empty State
 
-**Issue:** On first app install, the initial sync may not return changes immediately (server may need time to prepare data). The app stops showing the loader and displays empty content, then shows the actual data on the second sync interval.
+Issue:
+
+On first install, the initial sync may not immediately return data, so the app can briefly render an empty state before a later sync populates it.
+
+Potential improvement:
 
-**Potential improvement:** Keep showing a loader until the first successful sync with data, or until a timeout since there might not be any data to fetch 
+Keep showing a loader until the first successful sync with data, or until a timeout expires.
 
-## 📖 Examples
+## Examples
 
-Check out the [examples](./examples) directory for complete working examples:
+See the [`examples`](./examples) directory:
 
-- **[sync-demo-expo](./examples/sync-demo-expo)** - Expo development build with sync demonstration using push notifications
-- **[sync-demo-bare](./examples/sync-demo-bare)** - Bare React Native project with sync demonstration using polling
+- [`examples/sync-demo-expo`](./examples/sync-demo-expo): Expo development build using push notifications
+- [`examples/sync-demo-bare`](./examples/sync-demo-bare): Bare React Native example using polling
 
-## 🔗 Links
+## Links
 
-- [SQLite Sync Documentation](https://docs.sqlitecloud.io/docs/sqlite-sync-introduction) - Detailed sync docs with API references and best practices
-- [SQLite Cloud Dashboard](https://dashboard.sqlitecloud.io/) - Manage your databases
+- [SQLite Sync - Library Documentation](https://docs.sqlitecloud.io/docs/sqlite-sync-introduction)
+- [SQLite Sync - Dashboard Documentation](https://docs.sqlitecloud.io/docs/offsync)
+- [SQLite Cloud Dashboard](https://dashboard.sqlitecloud.io/)
+- [SQLite Sync Best Practices](https://docs.sqlitecloud.io/docs/sqlite-sync-best-practices)
+- [SQLite Cloud Access Tokens](https://docs.sqlitecloud.io/docs/access-tokens)
+- [SQLite Cloud RLS](https://docs.sqlitecloud.io/docs/rls)
 - [OP-SQLite API Reference](https://op-engineering.github.io/op-sqlite/docs/api)
diff --git a/android/build.gradle b/android/build.gradle
index b3351a8..d169219 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -43,5 +43,5 @@ repositories {
 dependencies {
   implementation 'com.facebook.react:react-native:+'
   // SQLite Cloud Sync from Maven Central
-  implementation "ai.sqlite:sync.dev:${cloudsyncVersion}"
+  implementation "ai.sqlite:sync:${cloudsyncVersion}"
 }
diff --git a/examples/sync-demo-bare/.env.example b/examples/sync-demo-bare/.env.example
index c27cb2f..4030072 100644
--- a/examples/sync-demo-bare/.env.example
+++ b/examples/sync-demo-bare/.env.example
@@ -1,17 +1,16 @@
 # SQLite Cloud Configuration
 # Copy this file to .env and fill in your actual values
 
-# Your SQLite Cloud connection string
-# Get this from: https://dashboard.sqlitecloud.io/ > Your Project > Configuration
-# Format: sqlitecloud://your-host.sqlite.cloud:8860/your-database-name
-SQLITE_CLOUD_CONNECTION_STRING=
+# Your CloudSync database ID
+# Get this from: https://dashboard.sqlitecloud.io/ > Your Database > OffSync / Configuration
+SQLITE_CLOUD_DATABASE_ID=
 
 # Your SQLite Cloud API Key
-# Get this from: https://dashboard.sqlitecloud.io/ > Your Project > Configuration
+# Get this from: https://dashboard.sqlitecloud.io/ > Your Database > Configuration
 SQLITE_CLOUD_API_KEY=
 
 # Local database name (can be any name you prefer)
-DATABASE_NAME=sync-demo.db
+DATABASE_NAME=
 
 # Table name to sync (must match the table created in SQLite Cloud)
-TABLE_NAME=test_table
+TABLE_NAME=
diff --git a/examples/sync-demo-bare/README.md b/examples/sync-demo-bare/README.md
index 43f67dc..b8b44d7 100644
--- a/examples/sync-demo-bare/README.md
+++ b/examples/sync-demo-bare/README.md
@@ -41,8 +41,7 @@ Before running the example, you need to set up a SQLite Cloud database:
 
 5. **Get your credentials**
    - Navigate to your database's **Configuration** tab
-   - Copy your **Connection String** (format: `sqlitecloud://your-host.sqlite.cloud:8860/your-database`)
-   - Copy your **API Key**
+   - Copy your **Database ID** and **API Key**
 
 ### 2. Set Up React Native Environment
 
@@ -60,7 +59,7 @@ Follow the [React Native environment setup guide](https://reactnative.dev/docs/s
 2. **Fill in your credentials** in the `.env` file:
 
    ```env
-   SQLITE_CLOUD_CONNECTION_STRING=sqlitecloud://your-host.sqlite.cloud:8860/your-database
+   SQLITE_CLOUD_DATABASE_ID=db_xxxxxxxxxxxxxxxxxxxxxxxx
    SQLITE_CLOUD_API_KEY=your-api-key-here
    DATABASE_NAME=sync-demo.db
    TABLE_NAME=test_table
diff --git a/examples/sync-demo-bare/babel.config.js b/examples/sync-demo-bare/babel.config.js
index 0dab34c..c4cab14 100644
--- a/examples/sync-demo-bare/babel.config.js
+++ b/examples/sync-demo-bare/babel.config.js
@@ -15,7 +15,7 @@ module.exports = function (api) {
           'module:react-native-dotenv',
           {
             moduleName: '@env',
-            path: '.env',
+            path: path.resolve(__dirname, '.env'),
             safe: false,
             allowUndefined: true,
           },
diff --git a/examples/sync-demo-bare/src/App.tsx b/examples/sync-demo-bare/src/App.tsx
index 8d2c325..1fd341a 100644
--- a/examples/sync-demo-bare/src/App.tsx
+++ b/examples/sync-demo-bare/src/App.tsx
@@ -19,7 +19,7 @@ import {
   useSqliteTransaction,
 } from '@sqliteai/sqlite-sync-react-native';
 import {
-  SQLITE_CLOUD_CONNECTION_STRING,
+  SQLITE_CLOUD_DATABASE_ID,
   SQLITE_CLOUD_API_KEY,
   DATABASE_NAME,
   TABLE_NAME,
@@ -279,7 +279,7 @@ function TestApp() {
 
 export default function App() {
   if (
-    !SQLITE_CLOUD_CONNECTION_STRING ||
+    !SQLITE_CLOUD_DATABASE_ID ||
     !SQLITE_CLOUD_API_KEY ||
     !DATABASE_NAME ||
     !TABLE_NAME
@@ -299,7 +299,7 @@ export default function App() {
 
   return (
     <SQLiteSyncProvider
-      connectionString={SQLITE_CLOUD_CONNECTION_STRING}
+      databaseId={SQLITE_CLOUD_DATABASE_ID}
       databaseName={DATABASE_NAME}
       tablesToBeSynced={[
         {
diff --git a/examples/sync-demo-bare/types/env.d.ts b/examples/sync-demo-bare/types/env.d.ts
index e347f50..a8d7b16 100644
--- a/examples/sync-demo-bare/types/env.d.ts
+++ b/examples/sync-demo-bare/types/env.d.ts
@@ -1,5 +1,5 @@
 declare module '@env' {
-  export const SQLITE_CLOUD_CONNECTION_STRING: string;
+  export const SQLITE_CLOUD_DATABASE_ID: string;
   export const SQLITE_CLOUD_API_KEY: string;
   export const DATABASE_NAME: string;
   export const TABLE_NAME: string;
diff --git a/examples/sync-demo-expo/.env.example b/examples/sync-demo-expo/.env.example
index 96b2023..5373e4c 100644
--- a/examples/sync-demo-expo/.env.example
+++ b/examples/sync-demo-expo/.env.example
@@ -1,25 +1,24 @@
 # SQLite Cloud Configuration
 # Copy this file to .env and fill in your actual values
 
-# Your SQLite Cloud connection string
-# Get this from: https://dashboard.sqlitecloud.io/ > Your Project > Configuration
-# Format: sqlitecloud://your-host.sqlite.cloud:8860/your-database-name
-SQLITE_CLOUD_CONNECTION_STRING=
+# Your CloudSync database ID
+# Get this from: https://dashboard.sqlitecloud.io/ > Your Database > OffSync / Configuration
+SQLITE_CLOUD_DATABASE_ID=
 
 # Your SQLite Cloud API Key
-# Get this from: https://dashboard.sqlitecloud.io/ > Your Project > Configuration
+# Get this from: https://dashboard.sqlitecloud.io/
 SQLITE_CLOUD_API_KEY=
 
 # Local database name (can be any name you prefer)
-DATABASE_NAME=sync-demo.db
+DATABASE_NAME=
 
 # Table name to sync (must match the table created in SQLite Cloud)
-TABLE_NAME=test_table
+TABLE_NAME=
 
 # Expo/EAS Configuration
 # Get your project ID from: https://expo.dev/ > Your Project > Overview
 EAS_PROJECT_ID=
 
 # App identifiers (use your own reverse domain format)
-IOS_BUNDLE_IDENTIFIER=com.yourcompany.sqlitesyncexample
-ANDROID_PACKAGE=com.yourcompany.sqlitesyncexample
+IOS_BUNDLE_IDENTIFIER=
+ANDROID_PACKAGE=
diff --git a/examples/sync-demo-expo/README.md b/examples/sync-demo-expo/README.md
index 742ed88..046cc7c 100644
--- a/examples/sync-demo-expo/README.md
+++ b/examples/sync-demo-expo/README.md
@@ -39,8 +39,7 @@ Before running the example, you need to set up a SQLite Cloud database:
 
 5. **Get your credentials**
    - Navigate to your database's **Configuration** tab
-   - Copy your **Connection String** (format: `sqlitecloud://your-host.sqlite.cloud:8860/your-database`)
-   - Copy your **API Key**
+   - Copy your **Project ID**, **Database ID**, and **API Key**
 
 ### 2. Set Up React Native Environment
 
@@ -53,7 +52,6 @@ Follow the [React Native environment setup guide](https://reactnative.dev/docs/s
    ```bash
    cd examples/sync-demo-expo
    cp .env.example .env
-   cp eas.json.example eas.json
    ```
 
 2. **Set up an Expo project** (for push notifications)
@@ -66,7 +64,7 @@ Follow the [React Native environment setup guide](https://reactnative.dev/docs/s
 
    ```env
    # SQLite Cloud credentials
-   SQLITE_CLOUD_CONNECTION_STRING=sqlitecloud://your-host.sqlite.cloud:8860/your-database
+   SQLITE_CLOUD_DATABASE_ID=db_xxxxxxxxxxxxxxxxxxxxxxxx
    SQLITE_CLOUD_API_KEY=your-api-key-here
    DATABASE_NAME=sync-demo.db
    TABLE_NAME=test_table
@@ -79,25 +77,46 @@ Follow the [React Native environment setup guide](https://reactnative.dev/docs/s
 
    **Note**: The `TABLE_NAME` must match the table you created in SQLite Cloud and enabled for OffSync.
 
-4. **Set up push notification credentials**
+### 4. Set Up Push Notifications
 
-   ```bash
-   # Configure iOS APNs credentials
-   eas credentials -p ios
+Push notifications enable **real-time sync** — your app receives instant updates when data changes in the cloud. Without push notifications, the library falls back to polling mode.
 
-   # Configure Android FCM credentials (optional)
-   eas credentials -p android
-   ```
+EAS will guide you through creating/uploading an APNs key. See the [Expo Push Notifications setup guide](https://docs.expo.dev/push-notifications/push-notifications-setup/) for detailed instructions.
+
+#### a. Configure iOS
+
+```bash
+eas credentials -p ios
+```
+
+#### b. Configure Android
+
+1. Go to [Firebase Console](https://console.firebase.google.com/)
+2. Create a new project (or use an existing one)
+3. Add an Android app with your package name (matching `ANDROID_PACKAGE` in `.env`)
+4. Download `google-services.json` and place it in this directory (`examples/sync-demo-expo/`)
+5. Run `eas credentials -p android` to configure FCM credentials
 
-5. **Configure Firebase for Android (optional)**
+#### c. Configure your Expo Access Token on the SQLite Cloud Dashboard
 
-   If you want push notifications on Android:
+If you have [Expo enhanced security](https://docs.expo.dev/push-notifications/sending-notifications/#additional-security) enabled, you need to provide your Expo access token so that SQLite Cloud can send push notifications to your devices.
 
-   - Create a Firebase project at [Firebase Console](https://console.firebase.google.com/)
-   - Add an Android app with your package name
-   - Download `google-services.json` and place it in this directory
+1. Go to [Expo Access Tokens settings](https://expo.dev/accounts/[your-account]/settings/access-tokens)
+2. Generate a new access token (or use an existing one)
+3. Open the [SQLite Cloud Dashboard](https://dashboard.sqlitecloud.io/)
+4. Navigate to your database > **OffSync** > **Configuration** tab
+5. Under the **Push Notifications** section, paste your Expo access token in the **Access Token** field and click **Save**
 
-### 4. Install Dependencies
+> **Note:** The access token is only required if you have Expo enhanced security enabled. Without it, push notifications work out of the box. The access token adds an extra layer of security to prevent unauthorized push notifications.
+
+#### d. Verify push notification status
+
+On the SQLite Cloud Dashboard (**OffSync** > **Configuration**), check:
+
+- **Status**: Should show "Working"
+- **Access Token**: Should show "Configured" (if you added one)
+
+### 5. Install Dependencies
 
 ```bash
 # From repository root
@@ -106,6 +125,8 @@ yarn install
 
 ## Running the Example
 
+> Push notifications require a **real device**. They do not work on simulators/emulators.
+
 From the repository root:
 
 ```bash
@@ -127,12 +148,14 @@ The demo app demonstrates:
 ## Try It Out
 
 **Test Push Notification Sync:**
+
 1. Run the app on a real device (push notifications don't work on simulators)
 2. Grant push notification permissions when prompted
 3. Add data from the SQLite Cloud dashboard
 4. Watch the app sync instantly via push notification
 
 **Test Permission Denial Fallback:**
+
 1. Deny push notification permissions when prompted
 2. The app automatically falls back to polling mode
 3. Data still syncs, just on a polling interval instead of instantly
diff --git a/examples/sync-demo-expo/app.config.js b/examples/sync-demo-expo/app.config.js
index dd625d8..a54f1ee 100644
--- a/examples/sync-demo-expo/app.config.js
+++ b/examples/sync-demo-expo/app.config.js
@@ -10,6 +10,10 @@ export default {
     userInterfaceStyle: 'light',
     newArchEnabled: true,
     extra: {
+      sqliteCloudDatabaseId: process.env.SQLITE_CLOUD_DATABASE_ID,
+      sqliteCloudApiKey: process.env.SQLITE_CLOUD_API_KEY,
+      databaseName: process.env.DATABASE_NAME,
+      tableName: process.env.TABLE_NAME,
       eas: {
         projectId: process.env.EAS_PROJECT_ID,
       },
diff --git a/examples/sync-demo-expo/babel.config.js b/examples/sync-demo-expo/babel.config.js
index aa360d9..3cb3bb7 100644
--- a/examples/sync-demo-expo/babel.config.js
+++ b/examples/sync-demo-expo/babel.config.js
@@ -15,7 +15,7 @@ module.exports = function (api) {
           'module:react-native-dotenv',
           {
             moduleName: '@env',
-            path: '.env',
+            path: path.resolve(__dirname, '.env'),
             safe: false,
             allowUndefined: true,
           },
diff --git a/examples/sync-demo-expo/eas.json.example b/examples/sync-demo-expo/eas.json.example
deleted file mode 100644
index d882eae..0000000
--- a/examples/sync-demo-expo/eas.json.example
+++ /dev/null
@@ -1,18 +0,0 @@
-{
-  "cli": {
-    "version": ">= 13.2.0"
-  },
-  "build": {
-    "development": {
-      "developmentClient": true,
-      "distribution": "internal"
-    },
-    "preview": {
-      "distribution": "internal"
-    },
-    "production": {}
-  },
-  "submit": {
-    "production": {}
-  }
-}
diff --git a/examples/sync-demo-expo/metro.config.js b/examples/sync-demo-expo/metro.config.js
index 630cef5..ccc144f 100644
--- a/examples/sync-demo-expo/metro.config.js
+++ b/examples/sync-demo-expo/metro.config.js
@@ -1,8 +1,9 @@
 const path = require('path');
 const { getDefaultConfig } = require('@expo/metro-config');
-const { withMetroConfig } = require('react-native-monorepo-config');
 
 const root = path.resolve(__dirname, '../..');
+const rootNodeModules = path.join(root, 'node_modules');
+const localNodeModules = path.join(__dirname, 'node_modules');
 
 /**
  * Metro configuration
@@ -10,10 +11,16 @@ const root = path.resolve(__dirname, '../..');
  *
  * @type {import('metro-config').MetroConfig}
  */
-const config = withMetroConfig(getDefaultConfig(__dirname), {
-  root,
-  dirname: __dirname,
-});
+const config = getDefaultConfig(__dirname);
+
+config.projectRoot = __dirname;
+config.watchFolders = [root];
+
+config.resolver.nodeModulesPaths = [localNodeModules, rootNodeModules];
+config.resolver.disableHierarchicalLookup = true;
+config.resolver.extraNodeModules = {
+  '@sqliteai/sqlite-sync-react-native': root,
+};
 
 config.resolver.unstable_enablePackageExports = true;
 
diff --git a/examples/sync-demo-expo/react-native.config.js b/examples/sync-demo-expo/react-native.config.js
new file mode 100644
index 0000000..042b473
--- /dev/null
+++ b/examples/sync-demo-expo/react-native.config.js
@@ -0,0 +1,10 @@
+module.exports = {
+  dependencies: {
+    '@op-engineering/op-sqlite': {
+      root: '../../node_modules/@op-engineering/op-sqlite',
+    },
+    '@react-native-community/netinfo': {
+      root: '../../node_modules/@react-native-community/netinfo',
+    },
+  },
+};
diff --git a/examples/sync-demo-expo/src/App.tsx b/examples/sync-demo-expo/src/App.tsx
index cf5476e..c972402 100644
--- a/examples/sync-demo-expo/src/App.tsx
+++ b/examples/sync-demo-expo/src/App.tsx
@@ -23,12 +23,13 @@ import {
   type BackgroundSyncResult,
 } from '@sqliteai/sqlite-sync-react-native';
 import * as Notifications from 'expo-notifications';
-import {
-  SQLITE_CLOUD_CONNECTION_STRING,
-  SQLITE_CLOUD_API_KEY,
-  DATABASE_NAME,
-  TABLE_NAME,
-} from '@env';
+import Constants from 'expo-constants';
+
+const SQLITE_CLOUD_DATABASE_ID =
+  Constants.expoConfig?.extra?.sqliteCloudDatabaseId;
+const SQLITE_CLOUD_API_KEY = Constants.expoConfig?.extra?.sqliteCloudApiKey;
+const DATABASE_NAME = Constants.expoConfig?.extra?.databaseName;
+const TABLE_NAME = Constants.expoConfig?.extra?.tableName;
 
 /**
  * Register background sync handler at module level (outside components).
@@ -392,7 +393,7 @@ export default function App() {
   }, []);
 
   if (
-    !SQLITE_CLOUD_CONNECTION_STRING ||
+    !SQLITE_CLOUD_DATABASE_ID ||
     !SQLITE_CLOUD_API_KEY ||
     !DATABASE_NAME ||
     !TABLE_NAME
@@ -412,7 +413,7 @@ export default function App() {
 
   return (
     <SQLiteSyncProvider
-      connectionString={SQLITE_CLOUD_CONNECTION_STRING}
+      databaseId={SQLITE_CLOUD_DATABASE_ID}
       databaseName={DATABASE_NAME}
       tablesToBeSynced={[
         {
diff --git a/examples/sync-demo-expo/types/env.d.ts b/examples/sync-demo-expo/types/env.d.ts
index e347f50..a8d7b16 100644
--- a/examples/sync-demo-expo/types/env.d.ts
+++ b/examples/sync-demo-expo/types/env.d.ts
@@ -1,5 +1,5 @@
 declare module '@env' {
-  export const SQLITE_CLOUD_CONNECTION_STRING: string;
+  export const SQLITE_CLOUD_DATABASE_ID: string;
   export const SQLITE_CLOUD_API_KEY: string;
   export const DATABASE_NAME: string;
   export const TABLE_NAME: string;
diff --git a/package.json b/package.json
index a9ebebe..12929b4 100644
--- a/package.json
+++ b/package.json
@@ -58,7 +58,8 @@
     "typecheck": "tsc",
     "lint": "eslint \"**/*.{js,ts,tsx}\"",
     "release": "release-it --only-version",
-    "test": "jest"
+    "test": "jest",
+    "test:coverage": "jest --coverage --runInBand"
   },
   "keywords": [
     "react-native",
@@ -90,8 +91,10 @@
     "@react-native/babel-preset": "0.81.1",
     "@react-native/eslint-config": "^0.83.0",
     "@release-it/conventional-changelog": "^10.0.1",
+    "@testing-library/react-native": "^13.3.3",
     "@types/jest": "^29.5.14",
     "@types/react": "^19.1.12",
+    "@types/react-test-renderer": "^19",
     "del-cli": "^6.0.0",
     "eslint": "^9.35.0",
     "eslint-config-prettier": "^10.1.8",
@@ -101,6 +104,7 @@
     "react": "19.1.0",
     "react-native": "0.81.5",
     "react-native-builder-bob": "^0.40.16",
+    "react-test-renderer": "19.1.0",
     "release-it": "^19.0.4",
     "typescript": "^5.9.2"
   },
@@ -179,6 +183,20 @@
   },
   "jest": {
     "preset": "react-native",
+    "collectCoverageFrom": [
+      "src/**/*.{ts,tsx}",
+      "!src/**/*.d.ts",
+      "!src/**/__tests__/**",
+      "!src/**/__mocks__/**"
+    ],
+    "coverageThreshold": {
+      "global": {
+        "branches": 85,
+        "functions": 95,
+        "lines": 95,
+        "statements": 95
+      }
+    },
     "modulePathIgnorePatterns": [
       "<rootDir>/examples/sync-demo-expo/node_modules",
       "<rootDir>/examples/sync-demo-bare/node_modules",
diff --git a/setup.sh b/setup.sh
index 8ef640a..a3b55a9 100755
--- a/setup.sh
+++ b/setup.sh
@@ -23,7 +23,7 @@ echo "[sqlite-sync] Downloading CloudSync.xcframework v${VERSION}..."
 mkdir -p "$IOS_DIR"
 
 # Download and extract
-DOWNLOAD_URL="https://github.com/sqliteai/sqlite-sync-dev/releases/download/${VERSION}/cloudsync-apple-xcframework-${VERSION}.zip"
+DOWNLOAD_URL="https://github.com/sqliteai/sqlite-sync/releases/download/${VERSION}/cloudsync-apple-xcframework-${VERSION}.zip"
 TEMP_ZIP="$IOS_DIR/cloudsync.zip"
 
 curl -L -o "$TEMP_ZIP" "$DOWNLOAD_URL"
diff --git a/src/__mocks__/@op-engineering/op-sqlite.ts b/src/__mocks__/@op-engineering/op-sqlite.ts
new file mode 100644
index 0000000..f5fcc35
--- /dev/null
+++ b/src/__mocks__/@op-engineering/op-sqlite.ts
@@ -0,0 +1,29 @@
+const createMockTx = () => ({
+  execute: jest.fn().mockResolvedValue({ rows: [] }),
+});
+
+export const createMockDB = () => ({
+  execute: jest.fn().mockResolvedValue({ rows: [] }),
+  transaction: jest.fn(async (fn: any) => {
+    const tx = createMockTx();
+    await fn(tx);
+    return tx;
+  }),
+  close: jest.fn(),
+  loadExtension: jest.fn(),
+  updateHook: jest.fn(),
+  reactiveExecute: jest.fn(() => jest.fn()),
+});
+
+export const open = jest.fn(() => createMockDB());
+export const getDylibPath = jest.fn(
+  (_bundleId: string, _name: string) => '/mock/path/CloudSync'
+);
+
+export type DB = ReturnType<typeof createMockDB>;
+export type QueryResult = {
+  rows?: Record<string, any>[];
+  insertId?: number;
+  rowsAffected?: number;
+};
+export type Transaction = ReturnType<typeof createMockTx>;
diff --git a/src/__mocks__/@react-native-community/netinfo.ts b/src/__mocks__/@react-native-community/netinfo.ts
new file mode 100644
index 0000000..a9a8dd5
--- /dev/null
+++ b/src/__mocks__/@react-native-community/netinfo.ts
@@ -0,0 +1,24 @@
+type NetInfoCallback = (state: any) => void;
+const listeners: NetInfoCallback[] = [];
+
+const NetInfo = {
+  addEventListener: jest.fn((callback: NetInfoCallback) => {
+    listeners.push(callback);
+    return jest.fn(() => {
+      const idx = listeners.indexOf(callback);
+      if (idx >= 0) listeners.splice(idx, 1);
+    });
+  }),
+  fetch: jest.fn().mockResolvedValue({
+    isConnected: true,
+    isInternetReachable: true,
+  }),
+  __simulateChange: (state: any) => {
+    listeners.forEach((cb) => cb(state));
+  },
+  __clearListeners: () => {
+    listeners.length = 0;
+  },
+};
+
+export default NetInfo;
diff --git a/src/__mocks__/expo-application.ts b/src/__mocks__/expo-application.ts
new file mode 100644
index 0000000..94146b1
--- /dev/null
+++ b/src/__mocks__/expo-application.ts
@@ -0,0 +1,2 @@
+export const getIosIdForVendorAsync = jest.fn().mockResolvedValue('mock-ios-vendor-id');
+export const getAndroidId = jest.fn(() => 'mock-android-id');
diff --git a/src/__mocks__/expo-constants.ts b/src/__mocks__/expo-constants.ts
new file mode 100644
index 0000000..281cbbf
--- /dev/null
+++ b/src/__mocks__/expo-constants.ts
@@ -0,0 +1,4 @@
+export default {
+  expoConfig: { extra: { eas: { projectId: 'mock-project-id' } } },
+  easConfig: { projectId: 'mock-project-id' },
+};
diff --git a/src/__mocks__/expo-notifications.ts b/src/__mocks__/expo-notifications.ts
new file mode 100644
index 0000000..3c9f459
--- /dev/null
+++ b/src/__mocks__/expo-notifications.ts
@@ -0,0 +1,7 @@
+export const getPermissionsAsync = jest.fn().mockResolvedValue({ status: 'granted' });
+export const requestPermissionsAsync = jest.fn().mockResolvedValue({ status: 'granted' });
+export const getExpoPushTokenAsync = jest.fn().mockResolvedValue({ data: 'ExponentPushToken[mock]' });
+export const getDevicePushTokenAsync = jest.fn().mockResolvedValue({ data: 'mock-device-token' });
+export const addNotificationReceivedListener = jest.fn(() => ({ remove: jest.fn() }));
+export const registerTaskAsync = jest.fn().mockResolvedValue(undefined);
+export const unregisterTaskAsync = jest.fn().mockResolvedValue(undefined);
diff --git a/src/__mocks__/expo-secure-store.ts b/src/__mocks__/expo-secure-store.ts
new file mode 100644
index 0000000..09a4ffc
--- /dev/null
+++ b/src/__mocks__/expo-secure-store.ts
@@ -0,0 +1,5 @@
+const store: Record<string, string> = {};
+export const getItemAsync = jest.fn(async (key: string) => store[key] ?? null);
+export const setItemAsync = jest.fn(async (key: string, value: string) => { store[key] = value; });
+export const deleteItemAsync = jest.fn(async (key: string) => { delete store[key]; });
+export const __clearStore = () => { Object.keys(store).forEach((k) => delete store[k]); };
diff --git a/src/__mocks__/expo-task-manager.ts b/src/__mocks__/expo-task-manager.ts
new file mode 100644
index 0000000..d02198d
--- /dev/null
+++ b/src/__mocks__/expo-task-manager.ts
@@ -0,0 +1 @@
+export const defineTask = jest.fn();
diff --git a/src/core/SQLiteSyncProvider.tsx b/src/core/SQLiteSyncProvider.tsx
index 83a6953..270a3d6 100644
--- a/src/core/SQLiteSyncProvider.tsx
+++ b/src/core/SQLiteSyncProvider.tsx
@@ -43,11 +43,11 @@ import { usePushNotificationSync } from './pushNotifications/usePushNotification
  *      - Still syncs on foreground and network reconnect for reliability
  *
  * 4. **Reactive Configuration:**
- *    - Changes to critical props (`connectionString`, `apiKey`, `tablesToBeSynced`) will trigger
+ *    - Changes to critical props (`databaseId`, `apiKey`, `tablesToBeSynced`) will trigger
  *      a safe teardown (closing DB) and re-initialization to ensure auth consistency.
  *    - Configuration objects are serialized internally to prevent unnecessary re-renders.
  *
- * @param props.connectionString - SQLite Cloud connection string
+ * @param props.databaseId - CloudSync database ID
  * @param props.databaseName - Local filename (e.g., 'app.db')
  * @param props.tablesToBeSynced - Array of table configs. (Changes to content trigger re-init)
  * @param props.syncMode - Sync mode: 'polling' (default) or 'push'
@@ -57,7 +57,7 @@ import { usePushNotificationSync } from './pushNotifications/usePushNotification
  * @param props.debug - Enable console logging
  */
 export function SQLiteSyncProvider({
-  connectionString,
+  databaseId,
   databaseName,
   tablesToBeSynced,
   adaptivePolling,
@@ -117,7 +117,7 @@ export function SQLiteSyncProvider({
     initError,
     syncError: initSyncError,
   } = useDatabaseInitialization({
-    connectionString,
+    databaseId,
     databaseName,
     tablesToBeSynced,
     apiKey,
@@ -229,11 +229,9 @@ export function SQLiteSyncProvider({
     syncMode: effectiveSyncMode,
   });
 
-  /** PUSH PERMISSIONS DENIED HANDLER */
-  const handlePermissionsDenied = useCallback(() => {
-    logger.warn(
-      '⚠️ Falling back to polling mode due to denied push permissions'
-    );
+  /** PUSH FALLBACK HANDLER */
+  const handleFallbackToPolling = useCallback(() => {
+    logger.warn('⚠️ Falling back to polling mode because push setup failed');
     setEffectiveSyncMode('polling');
   }, [logger]);
 
@@ -245,9 +243,9 @@ export function SQLiteSyncProvider({
     syncMode: effectiveSyncMode,
     notificationListening,
     logger,
-    onPermissionsDenied: handlePermissionsDenied,
+    onPermissionsDenied: handleFallbackToPolling,
     renderPushPermissionPrompt,
-    connectionString,
+    databaseId,
     databaseName,
     tablesToBeSynced,
     apiKey,
diff --git a/src/core/__tests__/SQLiteSyncProvider.integration.test.tsx b/src/core/__tests__/SQLiteSyncProvider.integration.test.tsx
new file mode 100644
index 0000000..d318d2f
--- /dev/null
+++ b/src/core/__tests__/SQLiteSyncProvider.integration.test.tsx
@@ -0,0 +1,431 @@
+jest.mock('../database/createDatabase');
+jest.mock('../sync/initializeSyncExtension');
+jest.mock('../sync/executeSync');
+jest.mock('@react-native-community/netinfo');
+jest.mock('../common/optionalDependencies', () => ({
+  ExpoNotifications: {
+    getPermissionsAsync: jest.fn(),
+    requestPermissionsAsync: jest.fn(),
+    getDevicePushTokenAsync: jest.fn(),
+    getExpoPushTokenAsync: jest.fn(),
+    addNotificationReceivedListener: jest.fn(() => ({ remove: jest.fn() })),
+  },
+  ExpoConstants: {
+    expoConfig: { extra: { eas: { projectId: 'test-project-id' } } },
+  },
+  ExpoApplication: {
+    getIosIdForVendorAsync: jest.fn().mockResolvedValue('device-id'),
+    getAndroidId: jest.fn().mockReturnValue('android-id'),
+  },
+  ExpoSecureStore: {
+    getItemAsync: jest.fn().mockResolvedValue(null),
+    setItemAsync: jest.fn().mockResolvedValue(undefined),
+  },
+  ExpoTaskManager: null,
+  isBackgroundSyncAvailable: jest.fn().mockReturnValue(false),
+}));
+jest.mock('../pushNotifications/registerPushToken', () => ({
+  registerPushToken: jest.fn().mockResolvedValue(undefined),
+}));
+
+import { useContext } from 'react';
+import { AppState } from 'react-native';
+import NetInfo from '@react-native-community/netinfo';
+import { render, act } from '@testing-library/react-native';
+import { SQLiteSyncProvider } from '../SQLiteSyncProvider';
+import { SQLiteSyncStatusContext } from '../../contexts/SQLiteSyncStatusContext';
+import { createDatabase } from '../database/createDatabase';
+import { initializeSyncExtension } from '../sync/initializeSyncExtension';
+import { executeSync } from '../sync/executeSync';
+import { createMockDB } from '../../__mocks__/@op-engineering/op-sqlite';
+import { ExpoNotifications } from '../common/optionalDependencies';
+
+const mockExpoNotifications = ExpoNotifications as any;
+
+function CaptureStatus({
+  onChange,
+}: {
+  onChange: (value: any) => void;
+}) {
+  const value = useContext(SQLiteSyncStatusContext);
+  onChange(value);
+  return null;
+}
+
+describe('SQLiteSyncProvider integration', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    jest.spyOn(console, 'log').mockImplementation();
+    jest.spyOn(console, 'warn').mockImplementation();
+    jest.spyOn(console, 'error').mockImplementation();
+    jest.spyOn(AppState, 'addEventListener').mockReturnValue({
+      remove: jest.fn(),
+    } as any);
+
+    (NetInfo.addEventListener as jest.Mock).mockImplementation(() => jest.fn());
+    (NetInfo.fetch as jest.Mock).mockResolvedValue({
+      isConnected: true,
+      isInternetReachable: true,
+    });
+    (executeSync as jest.Mock).mockResolvedValue(0);
+    (initializeSyncExtension as jest.Mock).mockResolvedValue(undefined);
+    mockExpoNotifications.getPermissionsAsync.mockResolvedValue({
+      status: 'granted',
+    });
+    mockExpoNotifications.requestPermissionsAsync.mockResolvedValue({
+      status: 'granted',
+    });
+    mockExpoNotifications.getDevicePushTokenAsync.mockResolvedValue({
+      data: 'device-token',
+    });
+    mockExpoNotifications.getExpoPushTokenAsync.mockResolvedValue({
+      data: 'ExponentPushToken[test]',
+    });
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+    (NetInfo as any).__clearListeners?.();
+  });
+
+  it('uses polling defaults when adaptivePolling is omitted', async () => {
+    const writeDb = createMockDB();
+    const readDb = createMockDB();
+    let latestStatus: any;
+
+    (createDatabase as jest.Mock)
+      .mockResolvedValueOnce(writeDb)
+      .mockResolvedValueOnce(readDb);
+
+    render(
+      <SQLiteSyncProvider
+        databaseId="db_test"
+        databaseName="test.db"
+        apiKey="api-key"
+        syncMode="polling"
+        tablesToBeSynced={[
+          {
+            name: 'users',
+            createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)',
+          },
+        ]}
+      >
+        <CaptureStatus onChange={(value) => (latestStatus = value)} />
+      </SQLiteSyncProvider>
+    );
+
+    await act(async () => {});
+
+    expect(latestStatus.syncMode).toBe('polling');
+    expect(latestStatus.currentSyncInterval).toBe(5000);
+  });
+
+  it('defaults push mode to foreground notification listening', async () => {
+    const writeDb = createMockDB();
+    const readDb = createMockDB();
+
+    writeDb.execute = jest
+      .fn()
+      .mockResolvedValue({ rows: [{ 'cloudsync_siteid()': 'site-123' }] });
+    (createDatabase as jest.Mock)
+      .mockResolvedValueOnce(writeDb)
+      .mockResolvedValueOnce(readDb);
+
+    render(
+      <SQLiteSyncProvider
+        databaseId="db_test"
+        databaseName="test.db"
+        apiKey="api-key"
+        syncMode="push"
+        tablesToBeSynced={[
+          {
+            name: 'users',
+            createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)',
+          },
+        ]}
+      >
+        <CaptureStatus onChange={() => {}} />
+      </SQLiteSyncProvider>
+    );
+
+    await act(async () => {});
+
+    expect(
+      mockExpoNotifications.addNotificationReceivedListener
+    ).toHaveBeenCalled();
+  });
+
+  it('falls back from push mode to polling when permissions are denied', async () => {
+    const writeDb = createMockDB();
+    const readDb = createMockDB();
+    let latestStatus: any;
+
+    (createDatabase as jest.Mock)
+      .mockResolvedValueOnce(writeDb)
+      .mockResolvedValueOnce(readDb);
+    mockExpoNotifications.getPermissionsAsync.mockResolvedValue({
+      status: 'denied',
+    });
+    mockExpoNotifications.requestPermissionsAsync.mockResolvedValue({
+      status: 'denied',
+    });
+
+    render(
+      <SQLiteSyncProvider
+        databaseId="db_test"
+        databaseName="test.db"
+        apiKey="api-key"
+        syncMode="push"
+        tablesToBeSynced={[
+          {
+            name: 'users',
+            createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)',
+          },
+        ]}
+      >
+        <CaptureStatus onChange={(value) => (latestStatus = value)} />
+      </SQLiteSyncProvider>
+    );
+
+    await act(async () => {});
+    await act(async () => {});
+
+    expect(latestStatus.syncMode).toBe('polling');
+    expect(latestStatus.currentSyncInterval).toBe(5000);
+  });
+
+  it('reinitializes and closes old databases when accessToken changes', async () => {
+    const writeDb1 = createMockDB();
+    const readDb1 = createMockDB();
+    const writeDb2 = createMockDB();
+    const readDb2 = createMockDB();
+
+    (createDatabase as jest.Mock)
+      .mockResolvedValueOnce(writeDb1)
+      .mockResolvedValueOnce(readDb1)
+      .mockResolvedValueOnce(writeDb2)
+      .mockResolvedValueOnce(readDb2);
+
+    const { rerender } = render(
+      <SQLiteSyncProvider
+        databaseId="db_test"
+        databaseName="test.db"
+        accessToken="token-a"
+        syncMode="polling"
+        tablesToBeSynced={[
+          {
+            name: 'users',
+            createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)',
+          },
+        ]}
+      >
+        <CaptureStatus onChange={() => {}} />
+      </SQLiteSyncProvider>
+    );
+
+    await act(async () => {});
+
+    rerender(
+      <SQLiteSyncProvider
+        databaseId="db_test"
+        databaseName="test.db"
+        accessToken="token-b"
+        syncMode="polling"
+        tablesToBeSynced={[
+          {
+            name: 'users',
+            createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)',
+          },
+        ]}
+      >
+        <CaptureStatus onChange={() => {}} />
+      </SQLiteSyncProvider>
+    );
+
+    await act(async () => {});
+
+    expect(writeDb1.close).toHaveBeenCalled();
+    expect(readDb1.close).toHaveBeenCalled();
+    expect(initializeSyncExtension).toHaveBeenCalledWith(
+      writeDb2,
+      expect.objectContaining({ accessToken: 'token-b' }),
+      expect.anything()
+    );
+  });
+
+  it('reinitializes when apiKey changes', async () => {
+    const writeDb1 = createMockDB();
+    const readDb1 = createMockDB();
+    const writeDb2 = createMockDB();
+    const readDb2 = createMockDB();
+
+    (createDatabase as jest.Mock)
+      .mockResolvedValueOnce(writeDb1)
+      .mockResolvedValueOnce(readDb1)
+      .mockResolvedValueOnce(writeDb2)
+      .mockResolvedValueOnce(readDb2);
+
+    const { rerender } = render(
+      <SQLiteSyncProvider
+        databaseId="db_test"
+        databaseName="test.db"
+        apiKey="key-a"
+        syncMode="polling"
+        tablesToBeSynced={[
+          {
+            name: 'users',
+            createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)',
+          },
+        ]}
+      >
+        <CaptureStatus onChange={() => {}} />
+      </SQLiteSyncProvider>
+    );
+
+    await act(async () => {});
+
+    rerender(
+      <SQLiteSyncProvider
+        databaseId="db_test"
+        databaseName="test.db"
+        apiKey="key-b"
+        syncMode="polling"
+        tablesToBeSynced={[
+          {
+            name: 'users',
+            createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)',
+          },
+        ]}
+      >
+        <CaptureStatus onChange={() => {}} />
+      </SQLiteSyncProvider>
+    );
+
+    await act(async () => {});
+
+    expect(writeDb1.close).toHaveBeenCalled();
+    expect(readDb1.close).toHaveBeenCalled();
+    expect(initializeSyncExtension).toHaveBeenCalledWith(
+      writeDb2,
+      expect.objectContaining({ apiKey: 'key-b' }),
+      expect.anything()
+    );
+  });
+
+  it('reinitializes when databaseId changes', async () => {
+    const writeDb1 = createMockDB();
+    const readDb1 = createMockDB();
+    const writeDb2 = createMockDB();
+    const readDb2 = createMockDB();
+
+    (createDatabase as jest.Mock)
+      .mockResolvedValueOnce(writeDb1)
+      .mockResolvedValueOnce(readDb1)
+      .mockResolvedValueOnce(writeDb2)
+      .mockResolvedValueOnce(readDb2);
+
+    const { rerender } = render(
+      <SQLiteSyncProvider
+        databaseId="db_a"
+        databaseName="test.db"
+        apiKey="api-key"
+        syncMode="polling"
+        tablesToBeSynced={[
+          {
+            name: 'users',
+            createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)',
+          },
+        ]}
+      >
+        <CaptureStatus onChange={() => {}} />
+      </SQLiteSyncProvider>
+    );
+
+    await act(async () => {});
+
+    rerender(
+      <SQLiteSyncProvider
+        databaseId="db_b"
+        databaseName="test.db"
+        apiKey="api-key"
+        syncMode="polling"
+        tablesToBeSynced={[
+          {
+            name: 'users',
+            createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)',
+          },
+        ]}
+      >
+        <CaptureStatus onChange={() => {}} />
+      </SQLiteSyncProvider>
+    );
+
+    await act(async () => {});
+
+    expect(writeDb1.close).toHaveBeenCalled();
+    expect(readDb1.close).toHaveBeenCalled();
+    expect(initializeSyncExtension).toHaveBeenCalledWith(
+      writeDb2,
+      expect.objectContaining({ databaseId: 'db_b' }),
+      expect.anything()
+    );
+  });
+
+  it('reinitializes when table config changes', async () => {
+    const writeDb1 = createMockDB();
+    const readDb1 = createMockDB();
+    const writeDb2 = createMockDB();
+    const readDb2 = createMockDB();
+
+    (createDatabase as jest.Mock)
+      .mockResolvedValueOnce(writeDb1)
+      .mockResolvedValueOnce(readDb1)
+      .mockResolvedValueOnce(writeDb2)
+      .mockResolvedValueOnce(readDb2);
+
+    const { rerender } = render(
+      <SQLiteSyncProvider
+        databaseId="db_test"
+        databaseName="test.db"
+        apiKey="api-key"
+        syncMode="polling"
+        tablesToBeSynced={[
+          {
+            name: 'users',
+            createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)',
+          },
+        ]}
+      >
+        <CaptureStatus onChange={() => {}} />
+      </SQLiteSyncProvider>
+    );
+
+    await act(async () => {});
+
+    rerender(
+      <SQLiteSyncProvider
+        databaseId="db_test"
+        databaseName="test.db"
+        apiKey="api-key"
+        syncMode="polling"
+        tablesToBeSynced={[
+          {
+            name: 'users',
+            createTableSql:
+              'CREATE TABLE IF NOT EXISTS users (id TEXT, email TEXT)',
+          },
+        ]}
+      >
+        <CaptureStatus onChange={() => {}} />
+      </SQLiteSyncProvider>
+    );
+
+    await act(async () => {});
+
+    expect(writeDb1.close).toHaveBeenCalled();
+    expect(readDb1.close).toHaveBeenCalled();
+    expect(writeDb2.execute).toHaveBeenCalledWith(
+      'CREATE TABLE IF NOT EXISTS users (id TEXT, email TEXT)'
+    );
+  });
+});
diff --git a/src/core/__tests__/SQLiteSyncProvider.test.tsx b/src/core/__tests__/SQLiteSyncProvider.test.tsx
new file mode 100644
index 0000000..a56d819
--- /dev/null
+++ b/src/core/__tests__/SQLiteSyncProvider.test.tsx
@@ -0,0 +1,306 @@
+jest.mock('../database/useDatabaseInitialization');
+jest.mock('../sync/useSyncManager');
+jest.mock('../sync/useInitialSync');
+jest.mock('../lifecycle/useAppLifecycle');
+jest.mock('../lifecycle/useNetworkListener');
+jest.mock('../polling/useAdaptivePollingSync');
+jest.mock('../pushNotifications/usePushNotificationSync');
+
+import React, { useContext } from 'react';
+import { renderHook, act } from '@testing-library/react-native';
+import { SQLiteSyncProvider } from '../SQLiteSyncProvider';
+import { SQLiteDbContext } from '../../contexts/SQLiteDbContext';
+import { SQLiteSyncStatusContext } from '../../contexts/SQLiteSyncStatusContext';
+import { SQLiteSyncActionsContext } from '../../contexts/SQLiteSyncActionsContext';
+import { SQLiteInternalContext } from '../../contexts/SQLiteInternalContext';
+import { useDatabaseInitialization } from '../database/useDatabaseInitialization';
+import { useSyncManager } from '../sync/useSyncManager';
+import { useInitialSync } from '../sync/useInitialSync';
+import { useAppLifecycle } from '../lifecycle/useAppLifecycle';
+import { useNetworkListener } from '../lifecycle/useNetworkListener';
+import { useAdaptivePollingSync } from '../polling/useAdaptivePollingSync';
+import { usePushNotificationSync } from '../pushNotifications/usePushNotificationSync';
+
+const mockPerformSync = jest.fn().mockResolvedValue(undefined);
+
+beforeEach(() => {
+  jest.spyOn(console, 'log').mockImplementation();
+  jest.spyOn(console, 'warn').mockImplementation();
+  jest.spyOn(console, 'error').mockImplementation();
+
+  (useDatabaseInitialization as jest.Mock).mockReturnValue({
+    writeDb: { execute: jest.fn() },
+    readDb: { execute: jest.fn() },
+    writeDbRef: { current: null },
+    isSyncReady: true,
+    initError: null,
+    syncError: null,
+  });
+
+  (useSyncManager as jest.Mock).mockReturnValue({
+    performSync: mockPerformSync,
+    performSyncRef: { current: mockPerformSync },
+    isSyncing: false,
+    lastSyncTime: null,
+    lastSyncChanges: 0,
+    consecutiveEmptySyncs: 0,
+    consecutiveSyncErrors: 0,
+    syncError: null,
+    setConsecutiveEmptySyncs: jest.fn(),
+  });
+
+  (useInitialSync as jest.Mock).mockReturnValue(undefined);
+  (useAppLifecycle as jest.Mock).mockReturnValue({
+    appState: 'active',
+    isInBackground: false,
+  });
+  (useNetworkListener as jest.Mock).mockReturnValue({
+    isNetworkAvailable: true,
+  });
+  (useAdaptivePollingSync as jest.Mock).mockReturnValue(undefined);
+  (usePushNotificationSync as jest.Mock).mockReturnValue({
+    permissionPromptNode: null,
+  });
+});
+
+afterEach(() => {
+  jest.restoreAllMocks();
+});
+
+const defaultProps = {
+  databaseId: 'db_test_database_id',
+  databaseName: 'test.db',
+  tablesToBeSynced: [
+    {
+      name: 'users',
+      createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)',
+    },
+  ],
+  apiKey: 'test-key',
+};
+
+const createWrapper = (props?: Partial<typeof defaultProps>) => {
+  const mergedProps = { ...defaultProps, ...props } as any;
+  return ({ children }: { children: React.ReactNode }) => (
+    <SQLiteSyncProvider {...mergedProps}>{children}</SQLiteSyncProvider>
+  );
+};
+
+describe('SQLiteSyncProvider', () => {
+  it('provides db context with writeDb and readDb', () => {
+    const wrapper = createWrapper();
+    const { result } = renderHook(() => useContext(SQLiteDbContext), {
+      wrapper,
+    });
+
+    expect(result.current.writeDb).not.toBeNull();
+    expect(result.current.readDb).not.toBeNull();
+    expect(result.current.initError).toBeNull();
+  });
+
+  it('provides sync status context', () => {
+    const wrapper = createWrapper();
+    const { result } = renderHook(() => useContext(SQLiteSyncStatusContext), {
+      wrapper,
+    });
+
+    expect(result.current.isSyncReady).toBe(true);
+    expect(result.current.isSyncing).toBe(false);
+    expect(result.current.syncMode).toBe('polling');
+    expect(result.current.isNetworkAvailable).toBe(true);
+    expect(result.current.isAppInBackground).toBe(false);
+  });
+
+  it('provides sync actions context with triggerSync', () => {
+    const wrapper = createWrapper();
+    const { result } = renderHook(() => useContext(SQLiteSyncActionsContext), {
+      wrapper,
+    });
+
+    expect(typeof result.current.triggerSync).toBe('function');
+  });
+
+  it('provides internal context with logger', () => {
+    const wrapper = createWrapper();
+    const { result } = renderHook(() => useContext(SQLiteInternalContext), {
+      wrapper,
+    });
+
+    expect(result.current.logger).toBeDefined();
+    expect(typeof result.current.logger.info).toBe('function');
+  });
+
+  it('passes database sync metadata to useDatabaseInitialization', () => {
+    const wrapper = createWrapper();
+    renderHook(() => useContext(SQLiteDbContext), { wrapper });
+
+    expect(useDatabaseInitialization).toHaveBeenCalledWith(
+      expect.objectContaining({
+        databaseId: 'db_test_database_id',
+        databaseName: 'test.db',
+      })
+    );
+  });
+
+  it('passes syncMode polling to useAdaptivePollingSync', () => {
+    const wrapper = createWrapper();
+    renderHook(() => useContext(SQLiteDbContext), { wrapper });
+
+    expect(useAdaptivePollingSync).toHaveBeenCalledWith(
+      expect.objectContaining({ syncMode: 'polling' })
+    );
+  });
+
+  it('passes isSyncReady to sub-hooks', () => {
+    const wrapper = createWrapper();
+    renderHook(() => useContext(SQLiteDbContext), { wrapper });
+
+    expect(useSyncManager).toHaveBeenCalledWith(
+      expect.objectContaining({ isSyncReady: true })
+    );
+    expect(useInitialSync).toHaveBeenCalledWith(
+      expect.objectContaining({ isSyncReady: true })
+    );
+  });
+
+  it('exposes initError from database initialization', () => {
+    const initError = new Error('db failed');
+    (useDatabaseInitialization as jest.Mock).mockReturnValue({
+      writeDb: null,
+      readDb: null,
+      writeDbRef: { current: null },
+      isSyncReady: false,
+      initError,
+      syncError: null,
+    });
+
+    const wrapper = createWrapper();
+    const { result } = renderHook(() => useContext(SQLiteDbContext), {
+      wrapper,
+    });
+
+    expect(result.current.initError?.message).toBe('db failed');
+  });
+
+  it('merges syncError from init and sync manager', () => {
+    const syncError = new Error('sync failed');
+    (useDatabaseInitialization as jest.Mock).mockReturnValue({
+      writeDb: null,
+      readDb: null,
+      writeDbRef: { current: null },
+      isSyncReady: false,
+      initError: null,
+      syncError,
+    });
+
+    const wrapper = createWrapper();
+    const { result } = renderHook(() => useContext(SQLiteSyncStatusContext), {
+      wrapper,
+    });
+
+    expect(result.current.syncError?.message).toBe('sync failed');
+  });
+
+  it('passes push mode to usePushNotificationSync', () => {
+    const pushWrapper = ({ children }: { children: React.ReactNode }) => (
+      <SQLiteSyncProvider {...defaultProps} syncMode="push" apiKey="test-key">
+        {children}
+      </SQLiteSyncProvider>
+    );
+    renderHook(() => useContext(SQLiteDbContext), { wrapper: pushWrapper });
+
+    expect(usePushNotificationSync).toHaveBeenCalledWith(
+      expect.objectContaining({
+        syncMode: 'push',
+        databaseId: 'db_test_database_id',
+      })
+    );
+  });
+
+  it('falls back to polling when push permissions denied', () => {
+    // Capture the onPermissionsDenied callback
+    let capturedOnPermissionsDenied: (() => void) | undefined;
+    (usePushNotificationSync as jest.Mock).mockImplementation((params: any) => {
+      capturedOnPermissionsDenied = params.onPermissionsDenied;
+      return { permissionPromptNode: null };
+    });
+
+    const pushWrapper = ({ children }: { children: React.ReactNode }) => (
+      <SQLiteSyncProvider {...defaultProps} syncMode="push" apiKey="test-key">
+        {children}
+      </SQLiteSyncProvider>
+    );
+    renderHook(() => useContext(SQLiteSyncStatusContext), {
+      wrapper: pushWrapper,
+    });
+
+    // Trigger permission denied callback inside act to trigger re-render
+    expect(capturedOnPermissionsDenied).toBeDefined();
+    act(() => {
+      capturedOnPermissionsDenied!();
+    });
+
+    // After re-render, the effective sync mode should be polling
+    // Check that at least one call after the permission denied had syncMode: 'polling'
+    const calls = (usePushNotificationSync as jest.Mock).mock.calls;
+    const lastPollingCall = calls.find(
+      (call: any) => call[0].syncMode === 'polling'
+    );
+    expect(lastPollingCall).toBeDefined();
+  });
+
+  it('sets null interval in push mode', () => {
+    const pushWrapper = ({ children }: { children: React.ReactNode }) => (
+      <SQLiteSyncProvider {...defaultProps} syncMode="push" apiKey="test-key">
+        {children}
+      </SQLiteSyncProvider>
+    );
+    const { result } = renderHook(() => useContext(SQLiteSyncStatusContext), {
+      wrapper: pushWrapper,
+    });
+
+    expect(result.current.currentSyncInterval).toBeNull();
+  });
+
+  it('uses accessToken auth when provided', () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <SQLiteSyncProvider
+        databaseId="db_test_database_id"
+        databaseName="test.db"
+        tablesToBeSynced={defaultProps.tablesToBeSynced}
+        accessToken="my-token"
+        syncMode="polling"
+        adaptivePolling={{}}
+      >
+        {children}
+      </SQLiteSyncProvider>
+    );
+    renderHook(() => useContext(SQLiteDbContext), { wrapper });
+
+    expect(useDatabaseInitialization).toHaveBeenCalledWith(
+      expect.objectContaining({ accessToken: 'my-token' })
+    );
+  });
+
+  it('applies custom adaptivePolling config', () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <SQLiteSyncProvider
+        {...defaultProps}
+        syncMode="polling"
+        adaptivePolling={{ baseInterval: 10000, maxInterval: 120000 }}
+      >
+        {children}
+      </SQLiteSyncProvider>
+    );
+    renderHook(() => useContext(SQLiteDbContext), { wrapper });
+
+    expect(useSyncManager).toHaveBeenCalledWith(
+      expect.objectContaining({
+        adaptiveConfig: expect.objectContaining({
+          baseInterval: 10000,
+          maxInterval: 120000,
+        }),
+      })
+    );
+  });
+});
diff --git a/src/core/__tests__/constants.test.ts b/src/core/__tests__/constants.test.ts
new file mode 100644
index 0000000..38faddf
--- /dev/null
+++ b/src/core/__tests__/constants.test.ts
@@ -0,0 +1,21 @@
+import {
+  FOREGROUND_DEBOUNCE_MS,
+  BACKGROUND_SYNC_TASK_NAME,
+  CLOUDSYNC_BASE_URL,
+} from '../constants';
+
+describe('constants', () => {
+  it('FOREGROUND_DEBOUNCE_MS is 2000', () => {
+    expect(FOREGROUND_DEBOUNCE_MS).toBe(2000);
+  });
+
+  it('BACKGROUND_SYNC_TASK_NAME is a non-empty string', () => {
+    expect(typeof BACKGROUND_SYNC_TASK_NAME).toBe('string');
+    expect(BACKGROUND_SYNC_TASK_NAME.length).toBeGreaterThan(0);
+  });
+
+  it('CLOUDSYNC_BASE_URL is a non-empty string', () => {
+    expect(typeof CLOUDSYNC_BASE_URL).toBe('string');
+    expect(CLOUDSYNC_BASE_URL.length).toBeGreaterThan(0);
+  });
+});
diff --git a/src/core/__tests__/publicExports.test.ts b/src/core/__tests__/publicExports.test.ts
new file mode 100644
index 0000000..dfff91d
--- /dev/null
+++ b/src/core/__tests__/publicExports.test.ts
@@ -0,0 +1,26 @@
+import * as rootExports from '../../index';
+import * as backgroundSyncExports from '../backgroundSync';
+
+describe('public exports', () => {
+  it('exports the documented root runtime APIs', () => {
+    expect(rootExports.SQLiteSyncProvider).toBeDefined();
+    expect(rootExports.registerBackgroundSyncCallback).toBeDefined();
+    expect(rootExports.useSqliteDb).toBeDefined();
+    expect(rootExports.useSyncStatus).toBeDefined();
+    expect(rootExports.useSqliteSync).toBeDefined();
+    expect(rootExports.useOnTableUpdate).toBeDefined();
+    expect(rootExports.useSqliteExecute).toBeDefined();
+    expect(rootExports.useSqliteTransaction).toBeDefined();
+    expect(rootExports.useTriggerSqliteSync).toBeDefined();
+    expect(rootExports.useSqliteSyncQuery).toBeDefined();
+  });
+
+  it('exports the backgroundSync subpath runtime APIs', () => {
+    expect(backgroundSyncExports.registerBackgroundSync).toBeDefined();
+    expect(backgroundSyncExports.unregisterBackgroundSync).toBeDefined();
+    expect(backgroundSyncExports.getPersistedConfig).toBeDefined();
+    expect(backgroundSyncExports.persistConfig).toBeDefined();
+    expect(backgroundSyncExports.clearPersistedConfig).toBeDefined();
+    expect(backgroundSyncExports.executeBackgroundSync).toBeDefined();
+  });
+});
diff --git a/src/core/background/__tests__/backgroundSyncConfig.test.ts b/src/core/background/__tests__/backgroundSyncConfig.test.ts
new file mode 100644
index 0000000..dd2d96f
--- /dev/null
+++ b/src/core/background/__tests__/backgroundSyncConfig.test.ts
@@ -0,0 +1,170 @@
+import {
+  getPersistedConfig,
+  persistConfig,
+  clearPersistedConfig,
+} from '../backgroundSyncConfig';
+import type { BackgroundSyncConfig } from '../backgroundSyncConfig';
+
+jest.mock('../../common/optionalDependencies', () => ({
+  ExpoSecureStore: {
+    getItemAsync: jest.fn(),
+    setItemAsync: jest.fn(),
+    deleteItemAsync: jest.fn(),
+  },
+}));
+
+const { ExpoSecureStore: mockSecureStore } = jest.requireMock(
+  '../../common/optionalDependencies'
+) as {
+  ExpoSecureStore: {
+    getItemAsync: jest.Mock;
+    setItemAsync: jest.Mock;
+    deleteItemAsync: jest.Mock;
+  };
+};
+
+const SAMPLE_CONFIG: BackgroundSyncConfig = {
+  databaseId: 'db_test_database_id',
+  databaseName: 'test.db',
+  tablesToBeSynced: [
+    {
+      name: 'users',
+      createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY)',
+    },
+  ],
+  debug: false,
+};
+
+describe('backgroundSyncConfig', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    jest.spyOn(console, 'log').mockImplementation();
+    jest.spyOn(console, 'warn').mockImplementation();
+    jest.spyOn(console, 'error').mockImplementation();
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  /** HELPER to temporarily set ExpoSecureStore to null */
+  const withNullSecureStore = async (fn: () => Promise<void>) => {
+    const deps = require('../../common/optionalDependencies');
+    const original = deps.ExpoSecureStore;
+    deps.ExpoSecureStore = null;
+    try {
+      await fn();
+    } finally {
+      deps.ExpoSecureStore = original;
+    }
+  };
+
+  describe('getPersistedConfig', () => {
+    it('returns null when ExpoSecureStore is not available', async () => {
+      await withNullSecureStore(async () => {
+        const result = await getPersistedConfig();
+        expect(result).toBeNull();
+      });
+    });
+
+    it('returns null when no value is stored', async () => {
+      mockSecureStore.getItemAsync.mockResolvedValue(null);
+
+      const result = await getPersistedConfig();
+
+      expect(result).toBeNull();
+      expect(mockSecureStore.getItemAsync).toHaveBeenCalledWith(
+        'sqlite_sync_background_config'
+      );
+    });
+
+    it('returns parsed config when stored value exists', async () => {
+      mockSecureStore.getItemAsync.mockResolvedValue(
+        JSON.stringify(SAMPLE_CONFIG)
+      );
+
+      const result = await getPersistedConfig();
+
+      expect(result).toEqual(SAMPLE_CONFIG);
+    });
+
+    it('returns null on JSON parse error', async () => {
+      mockSecureStore.getItemAsync.mockResolvedValue('not-valid-json{{{');
+
+      const result = await getPersistedConfig();
+
+      expect(result).toBeNull();
+    });
+
+    it('returns null for parsed config missing required fields', async () => {
+      mockSecureStore.getItemAsync.mockResolvedValue(
+        JSON.stringify({ databaseId: 'db_only' })
+      );
+
+      const result = await getPersistedConfig();
+
+      expect(result).toBeNull();
+    });
+  });
+
+  describe('persistConfig', () => {
+    it('saves config as JSON string', async () => {
+      mockSecureStore.setItemAsync.mockResolvedValue(undefined);
+
+      await persistConfig(SAMPLE_CONFIG);
+
+      expect(mockSecureStore.setItemAsync).toHaveBeenCalledWith(
+        'sqlite_sync_background_config',
+        JSON.stringify(SAMPLE_CONFIG)
+      );
+    });
+
+    it('warns when ExpoSecureStore is not available', async () => {
+      const debugConfig = { ...SAMPLE_CONFIG, debug: true };
+
+      await withNullSecureStore(async () => {
+        await persistConfig(debugConfig);
+      });
+
+      expect(mockSecureStore.setItemAsync).not.toHaveBeenCalled();
+      expect(console.warn).toHaveBeenCalled();
+    });
+
+    it('handles setItemAsync error gracefully', async () => {
+      mockSecureStore.setItemAsync.mockRejectedValue(new Error('storage full'));
+
+      await persistConfig(SAMPLE_CONFIG);
+
+      expect(console.error).toHaveBeenCalled();
+    });
+  });
+
+  describe('clearPersistedConfig', () => {
+    it('deletes the stored config key', async () => {
+      mockSecureStore.deleteItemAsync.mockResolvedValue(undefined);
+
+      await clearPersistedConfig();
+
+      expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith(
+        'sqlite_sync_background_config'
+      );
+    });
+
+    it('no-ops when ExpoSecureStore is not available', async () => {
+      await withNullSecureStore(async () => {
+        await clearPersistedConfig();
+      });
+
+      expect(mockSecureStore.deleteItemAsync).not.toHaveBeenCalled();
+    });
+
+    it('handles deleteItemAsync error gracefully', async () => {
+      mockSecureStore.deleteItemAsync.mockRejectedValue(
+        new Error('delete failed')
+      );
+
+      // Should not throw
+      await expect(clearPersistedConfig()).resolves.toBeUndefined();
+    });
+  });
+});
diff --git a/src/core/background/__tests__/backgroundSyncRegistry.test.ts b/src/core/background/__tests__/backgroundSyncRegistry.test.ts
new file mode 100644
index 0000000..160f739
--- /dev/null
+++ b/src/core/background/__tests__/backgroundSyncRegistry.test.ts
@@ -0,0 +1,106 @@
+import type { BackgroundSyncConfig } from '../backgroundSyncConfig';
+
+jest.mock('../../common/optionalDependencies', () => ({
+  ExpoNotifications: {
+    registerTaskAsync: jest.fn().mockResolvedValue(undefined),
+    unregisterTaskAsync: jest.fn().mockResolvedValue(undefined),
+  },
+  isBackgroundSyncAvailable: jest.fn().mockReturnValue(true),
+}));
+
+jest.mock('../backgroundSyncConfig', () => ({
+  persistConfig: jest.fn().mockResolvedValue(undefined),
+  clearPersistedConfig: jest.fn().mockResolvedValue(undefined),
+}));
+
+import {
+  registerBackgroundSync,
+  unregisterBackgroundSync,
+} from '../backgroundSyncRegistry';
+import {
+  ExpoNotifications,
+  isBackgroundSyncAvailable,
+} from '../../common/optionalDependencies';
+import { persistConfig, clearPersistedConfig } from '../backgroundSyncConfig';
+import { BACKGROUND_SYNC_TASK_NAME } from '../../constants';
+
+const mockConfig: BackgroundSyncConfig = {
+  databaseId: 'db_test_database_id',
+  databaseName: 'test.db',
+  tablesToBeSynced: [],
+  debug: false,
+};
+
+describe('registerBackgroundSync', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    (isBackgroundSyncAvailable as jest.Mock).mockReturnValue(true);
+  });
+
+  it('persists config', async () => {
+    await registerBackgroundSync(mockConfig);
+
+    expect(persistConfig).toHaveBeenCalledWith(mockConfig);
+  });
+
+  it('registers task with ExpoNotifications', async () => {
+    await registerBackgroundSync(mockConfig);
+
+    expect(ExpoNotifications.registerTaskAsync).toHaveBeenCalledWith(
+      BACKGROUND_SYNC_TASK_NAME
+    );
+  });
+
+  it('warns and returns early when dependencies unavailable', async () => {
+    jest.spyOn(console, 'warn').mockImplementation();
+    (isBackgroundSyncAvailable as jest.Mock).mockReturnValue(false);
+
+    await registerBackgroundSync({ ...mockConfig, debug: true });
+
+    expect(persistConfig).not.toHaveBeenCalled();
+    expect(ExpoNotifications.registerTaskAsync).not.toHaveBeenCalled();
+  });
+});
+
+describe('unregisterBackgroundSync', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('unregisters task', async () => {
+    await unregisterBackgroundSync();
+
+    expect(ExpoNotifications.unregisterTaskAsync).toHaveBeenCalledWith(
+      BACKGROUND_SYNC_TASK_NAME
+    );
+  });
+
+  it('clears persisted config', async () => {
+    await unregisterBackgroundSync();
+
+    expect(clearPersistedConfig).toHaveBeenCalled();
+  });
+
+  it('no-ops without ExpoNotifications', async () => {
+    // Temporarily replace the module export with null
+    const deps = require('../../common/optionalDependencies');
+    const original = deps.ExpoNotifications;
+    deps.ExpoNotifications = null;
+
+    await unregisterBackgroundSync();
+
+    expect(clearPersistedConfig).not.toHaveBeenCalled();
+
+    // Restore
+    deps.ExpoNotifications = original;
+  });
+
+  it('handles errors gracefully', async () => {
+    (ExpoNotifications.unregisterTaskAsync as jest.Mock).mockRejectedValueOnce(
+      new Error('not registered')
+    );
+
+    // Should not throw
+    await expect(unregisterBackgroundSync()).resolves.toBeUndefined();
+  });
+});
diff --git a/src/core/background/__tests__/executeBackgroundSync.test.ts b/src/core/background/__tests__/executeBackgroundSync.test.ts
new file mode 100644
index 0000000..c68d6df
--- /dev/null
+++ b/src/core/background/__tests__/executeBackgroundSync.test.ts
@@ -0,0 +1,216 @@
+import { executeBackgroundSync } from '../executeBackgroundSync';
+import { createDatabase } from '../../database/createDatabase';
+import { initializeSyncExtension } from '../../sync/initializeSyncExtension';
+import { executeSync } from '../../sync/executeSync';
+import { getBackgroundSyncCallback } from '../../pushNotifications/pushNotificationSyncCallbacks';
+
+jest.mock('../../database/createDatabase');
+jest.mock('../../sync/initializeSyncExtension');
+jest.mock('../../sync/executeSync');
+jest.mock('../../pushNotifications/pushNotificationSyncCallbacks');
+
+const mockDb = {
+  execute: jest.fn().mockResolvedValue({ rows: [] }),
+  transaction: jest.fn(),
+  close: jest.fn(),
+  loadExtension: jest.fn(),
+  updateHook: jest.fn(),
+  reactiveExecute: jest.fn(),
+};
+
+const testConfig = {
+  databaseId: 'db_test_database_id',
+  databaseName: 'test.db',
+  tablesToBeSynced: [
+    {
+      name: 'users',
+      createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY)',
+    },
+  ],
+  apiKey: 'test-key',
+  debug: false,
+};
+
+describe('executeBackgroundSync', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    jest.spyOn(console, 'log').mockImplementation();
+    jest.spyOn(console, 'warn').mockImplementation();
+    jest.spyOn(console, 'error').mockImplementation();
+    (createDatabase as jest.Mock).mockResolvedValue(mockDb);
+    (initializeSyncExtension as jest.Mock).mockResolvedValue(undefined);
+    (executeSync as jest.Mock).mockResolvedValue(0);
+    (getBackgroundSyncCallback as jest.Mock).mockReturnValue(null);
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('opens DB with config.databaseName', async () => {
+    await executeBackgroundSync(testConfig);
+
+    expect(createDatabase).toHaveBeenCalledWith('test.db', 'write');
+  });
+
+  it('calls initializeSyncExtension', async () => {
+    await executeBackgroundSync(testConfig);
+
+    expect(initializeSyncExtension).toHaveBeenCalledWith(
+      mockDb,
+      {
+        databaseId: testConfig.databaseId,
+        databaseName: testConfig.databaseName,
+        tablesToBeSynced: testConfig.tablesToBeSynced,
+        apiKey: testConfig.apiKey,
+        accessToken: undefined,
+      },
+      expect.anything()
+    );
+  });
+
+  it('passes accessToken to initializeSyncExtension when provided', async () => {
+    await executeBackgroundSync({
+      ...testConfig,
+      apiKey: undefined,
+      accessToken: 'user-token',
+    });
+
+    expect(initializeSyncExtension).toHaveBeenCalledWith(
+      mockDb,
+      expect.objectContaining({
+        accessToken: 'user-token',
+        apiKey: undefined,
+      }),
+      expect.anything()
+    );
+  });
+
+  it('calls executeSync with native retry options', async () => {
+    await executeBackgroundSync(testConfig);
+
+    expect(executeSync).toHaveBeenCalledWith(mockDb, expect.anything(), {
+      useNativeRetry: true,
+      maxAttempts: 3,
+      attemptDelay: 500,
+    });
+  });
+
+  it('registers updateHook when callback exists', async () => {
+    const mockCallback = jest.fn().mockResolvedValue(undefined);
+    (getBackgroundSyncCallback as jest.Mock).mockReturnValue(mockCallback);
+
+    await executeBackgroundSync(testConfig);
+
+    expect(mockDb.updateHook).toHaveBeenCalledWith(expect.any(Function));
+  });
+
+  it('collects changes from updateHook', async () => {
+    const mockCallback = jest.fn().mockResolvedValue(undefined);
+    (getBackgroundSyncCallback as jest.Mock).mockReturnValue(mockCallback);
+
+    mockDb.updateHook.mockImplementation((handler: unknown) => {
+      if (typeof handler === 'function') {
+        handler({ operation: 'INSERT', table: 'users', rowId: 1 });
+        handler({ operation: 'UPDATE', table: 'users', rowId: 2 });
+      }
+    });
+
+    await executeBackgroundSync(testConfig);
+
+    expect(mockCallback).toHaveBeenCalledWith(
+      expect.objectContaining({
+        changes: [
+          { operation: 'INSERT', table: 'users', rowId: 1 },
+          { operation: 'UPDATE', table: 'users', rowId: 2 },
+        ],
+      })
+    );
+  });
+
+  it('invokes callback with changes and db', async () => {
+    const mockCallback = jest.fn().mockResolvedValue(undefined);
+    (getBackgroundSyncCallback as jest.Mock).mockReturnValue(mockCallback);
+
+    await executeBackgroundSync(testConfig);
+
+    expect(mockCallback).toHaveBeenCalledWith({
+      changes: expect.any(Array),
+      db: mockDb,
+    });
+  });
+
+  it('removes hook before calling callback', async () => {
+    const callOrder: string[] = [];
+    const mockCallback = jest.fn().mockImplementation(() => {
+      callOrder.push('callback');
+      return Promise.resolve();
+    });
+    (getBackgroundSyncCallback as jest.Mock).mockReturnValue(mockCallback);
+
+    mockDb.updateHook.mockImplementation((handler: unknown) => {
+      if (handler === null) {
+        callOrder.push('updateHook(null)');
+      }
+    });
+
+    await executeBackgroundSync(testConfig);
+
+    const firstNullIndex = callOrder.indexOf('updateHook(null)');
+    const callbackIndex = callOrder.indexOf('callback');
+    expect(firstNullIndex).toBeLessThan(callbackIndex);
+  });
+
+  it('handles callback error without throwing', async () => {
+    const mockCallback = jest
+      .fn()
+      .mockRejectedValue(new Error('callback failed'));
+    (getBackgroundSyncCallback as jest.Mock).mockReturnValue(mockCallback);
+
+    await expect(executeBackgroundSync(testConfig)).resolves.toBeUndefined();
+  });
+
+  it('closes DB in finally block', async () => {
+    await executeBackgroundSync(testConfig);
+
+    expect(mockDb.close).toHaveBeenCalled();
+  });
+
+  it('closes DB when sync fails', async () => {
+    (executeSync as jest.Mock).mockRejectedValue(new Error('sync error'));
+
+    await expect(executeBackgroundSync(testConfig)).rejects.toThrow(
+      'sync error'
+    );
+    expect(mockDb.close).toHaveBeenCalled();
+  });
+
+  it('rethrows sync errors', async () => {
+    const syncError = new Error('network failure');
+    (executeSync as jest.Mock).mockRejectedValue(syncError);
+
+    await expect(executeBackgroundSync(testConfig)).rejects.toThrow(
+      'network failure'
+    );
+  });
+
+  it('skips callback when none registered and does not call updateHook', async () => {
+    (getBackgroundSyncCallback as jest.Mock).mockReturnValue(null);
+
+    await executeBackgroundSync(testConfig);
+
+    // updateHook should only be called in finally to clear (null), not to register a handler
+    const hookCalls = mockDb.updateHook.mock.calls;
+    for (const call of hookCalls) {
+      expect(call[0]).toBeNull();
+    }
+  });
+
+  it('handles close error gracefully', async () => {
+    mockDb.close.mockImplementation(() => {
+      throw new Error('close failed');
+    });
+
+    await expect(executeBackgroundSync(testConfig)).resolves.toBeUndefined();
+  });
+});
diff --git a/src/core/background/backgroundSyncConfig.ts b/src/core/background/backgroundSyncConfig.ts
index c4359ac..24ec974 100644
--- a/src/core/background/backgroundSyncConfig.ts
+++ b/src/core/background/backgroundSyncConfig.ts
@@ -6,7 +6,7 @@ import { ExpoSecureStore } from '../common/optionalDependencies';
  * Configuration for background sync
  */
 export interface BackgroundSyncConfig {
-  connectionString: string;
+  databaseId: string;
   databaseName: string;
   tablesToBeSynced: TableConfig[];
   apiKey?: string;
@@ -17,6 +17,23 @@ export interface BackgroundSyncConfig {
 /** STORAGE KEY */
 const CONFIG_STORAGE_KEY = 'sqlite_sync_background_config';
 
+function isValidBackgroundSyncConfig(
+  value: unknown
+): value is BackgroundSyncConfig {
+  if (!value || typeof value !== 'object') {
+    return false;
+  }
+
+  const config = value as Record<string, unknown>;
+  return (
+    typeof config.databaseId === 'string' &&
+    config.databaseId.length > 0 &&
+    typeof config.databaseName === 'string' &&
+    config.databaseName.length > 0 &&
+    Array.isArray(config.tablesToBeSynced)
+  );
+}
+
 /**
  * Get persisted config from SecureStore
  */
@@ -30,7 +47,8 @@ export async function getPersistedConfig(): Promise<BackgroundSyncConfig | null>
     if (!configJson) {
       return null;
     }
-    return JSON.parse(configJson) as BackgroundSyncConfig;
+    const parsed = JSON.parse(configJson);
+    return isValidBackgroundSyncConfig(parsed) ? parsed : null;
   } catch {
     return null;
   }
diff --git a/src/core/background/executeBackgroundSync.ts b/src/core/background/executeBackgroundSync.ts
index 2bcb5b2..6bedbc1 100644
--- a/src/core/background/executeBackgroundSync.ts
+++ b/src/core/background/executeBackgroundSync.ts
@@ -29,7 +29,8 @@ export async function executeBackgroundSync(
     await initializeSyncExtension(
       db,
       {
-        connectionString: config.connectionString,
+        databaseId: config.databaseId,
+        databaseName: config.databaseName,
         tablesToBeSynced: config.tablesToBeSynced,
         apiKey: config.apiKey,
         accessToken: config.accessToken,
diff --git a/src/core/backgroundSync.ts b/src/core/backgroundSync.ts
new file mode 100644
index 0000000..ca6df41
--- /dev/null
+++ b/src/core/backgroundSync.ts
@@ -0,0 +1,11 @@
+export {
+  registerBackgroundSync,
+  unregisterBackgroundSync,
+} from './background/backgroundSyncRegistry';
+export {
+  getPersistedConfig,
+  persistConfig,
+  clearPersistedConfig,
+} from './background/backgroundSyncConfig';
+export { executeBackgroundSync } from './background/executeBackgroundSync';
+export type { BackgroundSyncConfig } from './background/backgroundSyncConfig';
diff --git a/src/core/common/__tests__/logger.test.ts b/src/core/common/__tests__/logger.test.ts
new file mode 100644
index 0000000..a6948f4
--- /dev/null
+++ b/src/core/common/__tests__/logger.test.ts
@@ -0,0 +1,66 @@
+import { createLogger } from '../logger';
+
+describe('createLogger', () => {
+  beforeEach(() => {
+    jest.spyOn(console, 'log').mockImplementation();
+    jest.spyOn(console, 'warn').mockImplementation();
+    jest.spyOn(console, 'error').mockImplementation();
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('info calls console.log when debug=true', () => {
+    createLogger(true).info('test');
+    expect(console.log).toHaveBeenCalled();
+  });
+
+  it('warn calls console.warn when debug=true', () => {
+    createLogger(true).warn('test');
+    expect(console.warn).toHaveBeenCalled();
+  });
+
+  it('info does NOT call console.log when debug=false', () => {
+    createLogger(false).info('test');
+    expect(console.log).not.toHaveBeenCalled();
+  });
+
+  it('warn does NOT call console.warn when debug=false', () => {
+    createLogger(false).warn('test');
+    expect(console.warn).not.toHaveBeenCalled();
+  });
+
+  it('error calls console.error when debug=false', () => {
+    createLogger(false).error('test');
+    expect(console.error).toHaveBeenCalled();
+  });
+
+  it('error calls console.error when debug=true', () => {
+    createLogger(true).error('test');
+    expect(console.error).toHaveBeenCalled();
+  });
+
+  it('includes [SQLiteSync] prefix', () => {
+    createLogger(true).info('test message');
+    expect(console.log).toHaveBeenCalledWith(
+      expect.any(String),
+      '[SQLiteSync]',
+      'test message'
+    );
+  });
+
+  it('includes ISO timestamp', () => {
+    createLogger(true).info('test');
+    const timestamp = (console.log as jest.Mock).mock.calls[0][0];
+    expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+  });
+
+  it('defaults to debug=false when called without arguments', () => {
+    createLogger().info('test');
+    expect(console.log).not.toHaveBeenCalled();
+
+    createLogger().error('test');
+    expect(console.error).toHaveBeenCalled();
+  });
+});
diff --git a/src/core/common/__tests__/optionalDependencies.test.ts b/src/core/common/__tests__/optionalDependencies.test.ts
new file mode 100644
index 0000000..a9f8489
--- /dev/null
+++ b/src/core/common/__tests__/optionalDependencies.test.ts
@@ -0,0 +1,142 @@
+export {};
+
+/** Helper: mock all expo modules as available */
+const mockAllPresent = () => {
+  jest.doMock('expo-notifications', () => ({
+    getPermissionsAsync: jest.fn(),
+    requestPermissionsAsync: jest.fn(),
+    registerTaskAsync: jest.fn(),
+  }));
+  jest.doMock('expo-task-manager', () => ({
+    defineTask: jest.fn(),
+  }));
+  jest.doMock('expo-secure-store', () => ({
+    getItemAsync: jest.fn(),
+    setItemAsync: jest.fn(),
+    deleteItemAsync: jest.fn(),
+  }));
+  jest.doMock('expo-constants', () => ({
+    default: { expoConfig: {} },
+  }));
+  jest.doMock('expo-application', () => ({
+    getIosIdForVendorAsync: jest.fn(),
+    getAndroidId: jest.fn(),
+  }));
+};
+
+describe('optionalDependencies', () => {
+  afterEach(() => {
+    jest.resetModules();
+  });
+
+  it('ExpoNotifications is set when available', () => {
+    jest.isolateModules(() => {
+      mockAllPresent();
+      const deps = require('../optionalDependencies');
+      expect(deps.ExpoNotifications).not.toBeNull();
+    });
+  });
+
+  it('ExpoNotifications is null when not installed', () => {
+    jest.isolateModules(() => {
+      jest.doMock('expo-notifications', () => {
+        throw new Error('Module not found');
+      });
+      jest.doMock('expo-task-manager', () => ({ defineTask: jest.fn() }));
+      jest.doMock('expo-secure-store', () => ({ getItemAsync: jest.fn() }));
+      jest.doMock('expo-constants', () => ({ default: {} }));
+      jest.doMock('expo-application', () => ({ getAndroidId: jest.fn() }));
+      const deps = require('../optionalDependencies');
+      expect(deps.ExpoNotifications).toBeNull();
+    });
+  });
+
+  it('ExpoTaskManager is set when available', () => {
+    jest.isolateModules(() => {
+      mockAllPresent();
+      const deps = require('../optionalDependencies');
+      expect(deps.ExpoTaskManager).not.toBeNull();
+    });
+  });
+
+  it('ExpoTaskManager is null when not installed', () => {
+    jest.isolateModules(() => {
+      jest.doMock('expo-notifications', () => ({ registerTaskAsync: jest.fn() }));
+      jest.doMock('expo-task-manager', () => {
+        throw new Error('Module not found');
+      });
+      jest.doMock('expo-secure-store', () => ({ getItemAsync: jest.fn() }));
+      jest.doMock('expo-constants', () => ({ default: {} }));
+      jest.doMock('expo-application', () => ({ getAndroidId: jest.fn() }));
+      const deps = require('../optionalDependencies');
+      expect(deps.ExpoTaskManager).toBeNull();
+    });
+  });
+
+  it('ExpoSecureStore is set when available', () => {
+    jest.isolateModules(() => {
+      mockAllPresent();
+      const deps = require('../optionalDependencies');
+      expect(deps.ExpoSecureStore).not.toBeNull();
+    });
+  });
+
+  it('ExpoConstants uses .default if present', () => {
+    jest.isolateModules(() => {
+      const mockDefault = { expoConfig: { name: 'test' } };
+      jest.doMock('expo-notifications', () => ({ registerTaskAsync: jest.fn() }));
+      jest.doMock('expo-task-manager', () => ({ defineTask: jest.fn() }));
+      jest.doMock('expo-secure-store', () => ({ getItemAsync: jest.fn() }));
+      jest.doMock('expo-constants', () => ({
+        default: mockDefault,
+        other: 'stuff',
+      }));
+      jest.doMock('expo-application', () => ({ getAndroidId: jest.fn() }));
+      const deps = require('../optionalDependencies');
+      expect(deps.ExpoConstants).toBe(mockDefault);
+    });
+  });
+
+  it('ExpoConstants uses module directly if no default', () => {
+    jest.isolateModules(() => {
+      const mockModule = { expoConfig: { name: 'test' } };
+      jest.doMock('expo-notifications', () => ({ registerTaskAsync: jest.fn() }));
+      jest.doMock('expo-task-manager', () => ({ defineTask: jest.fn() }));
+      jest.doMock('expo-secure-store', () => ({ getItemAsync: jest.fn() }));
+      jest.doMock('expo-constants', () => mockModule);
+      jest.doMock('expo-application', () => ({ getAndroidId: jest.fn() }));
+      const deps = require('../optionalDependencies');
+      expect(deps.ExpoConstants).toBe(mockModule);
+    });
+  });
+
+  it('ExpoApplication is set when available', () => {
+    jest.isolateModules(() => {
+      mockAllPresent();
+      const deps = require('../optionalDependencies');
+      expect(deps.ExpoApplication).not.toBeNull();
+    });
+  });
+
+  it('isBackgroundSyncAvailable returns true when all 3 present', () => {
+    jest.isolateModules(() => {
+      mockAllPresent();
+      const deps = require('../optionalDependencies');
+      expect(deps.isBackgroundSyncAvailable()).toBe(true);
+    });
+  });
+
+  it('isBackgroundSyncAvailable returns false when any missing', () => {
+    jest.isolateModules(() => {
+      jest.doMock('expo-notifications', () => {
+        throw new Error('not found');
+      });
+      jest.doMock('expo-task-manager', () => ({ defineTask: jest.fn() }));
+      jest.doMock('expo-secure-store', () => ({ getItemAsync: jest.fn() }));
+      jest.doMock('expo-constants', () => ({ default: {} }));
+      jest.doMock('expo-application', () => ({ getAndroidId: jest.fn() }));
+      const deps = require('../optionalDependencies');
+      expect(deps.isBackgroundSyncAvailable()).toBe(false);
+    });
+  });
+});
diff --git a/src/core/common/__tests__/useInternalLogger.test.ts b/src/core/common/__tests__/useInternalLogger.test.ts
new file mode 100644
index 0000000..aa82e1b
--- /dev/null
+++ b/src/core/common/__tests__/useInternalLogger.test.ts
@@ -0,0 +1,25 @@
+import { renderHook } from '@testing-library/react-native';
+import { useInternalLogger } from '../useInternalLogger';
+import { createTestWrapper } from '../../../testUtils';
+import { createLogger } from '../logger';
+
+describe('useInternalLogger', () => {
+  it('returns logger from context', () => {
+    const logger = createLogger(true);
+    const wrapper = createTestWrapper({ logger });
+
+    const { result } = renderHook(() => useInternalLogger(), { wrapper });
+
+    expect(result.current).toBe(logger);
+  });
+
+  it('logger has info, warn, error methods', () => {
+    const wrapper = createTestWrapper();
+
+    const { result } = renderHook(() => useInternalLogger(), { wrapper });
+
+    expect(typeof result.current.info).toBe('function');
+    expect(typeof result.current.warn).toBe('function');
+    expect(typeof result.current.error).toBe('function');
+  });
+});
diff --git a/src/core/constants.ts b/src/core/constants.ts
index c5faf7e..cea56b9 100644
--- a/src/core/constants.ts
+++ b/src/core/constants.ts
@@ -9,3 +9,8 @@ export const FOREGROUND_DEBOUNCE_MS = 2000;
  * Exported for use in registration functions.
  */
 export const BACKGROUND_SYNC_TASK_NAME = 'SQLITE_SYNC_BACKGROUND_TASK';
+
+/**
+ * CloudSync staging server base URL
+ */
+export const CLOUDSYNC_BASE_URL = 'https://cloudsync.sqlite.ai';
diff --git a/src/core/database/__tests__/createDatabase.test.ts b/src/core/database/__tests__/createDatabase.test.ts
new file mode 100644
index 0000000..35510c0
--- /dev/null
+++ b/src/core/database/__tests__/createDatabase.test.ts
@@ -0,0 +1,67 @@
+import { createDatabase } from '../createDatabase';
+import { open } from '@op-engineering/op-sqlite';
+
+jest.mock('@op-engineering/op-sqlite');
+
+describe('createDatabase', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('opens database with given name', async () => {
+    await createDatabase('app.db', 'write');
+    expect(open).toHaveBeenCalledWith({ name: 'app.db' });
+  });
+
+  it('sets WAL journal mode', async () => {
+    const db = await createDatabase('app.db', 'write');
+    expect(db.execute).toHaveBeenCalledWith('PRAGMA journal_mode = WAL');
+  });
+
+  it('sets synchronous NORMAL in write mode', async () => {
+    const db = await createDatabase('app.db', 'write');
+    expect(db.execute).toHaveBeenCalledWith('PRAGMA synchronous = NORMAL');
+  });
+
+  it('sets locking_mode NORMAL in write mode', async () => {
+    const db = await createDatabase('app.db', 'write');
+    expect(db.execute).toHaveBeenCalledWith('PRAGMA locking_mode = NORMAL');
+  });
+
+  it('sets query_only in read mode', async () => {
+    const db = await createDatabase('app.db', 'read');
+    expect(db.execute).toHaveBeenCalledWith('PRAGMA query_only = true');
+  });
+
+  it('does NOT set synchronous in read mode', async () => {
+    const db = await createDatabase('app.db', 'read');
+    const calls = (db.execute as jest.Mock).mock.calls.map((c: any[]) => c[0]);
+    expect(calls).not.toContain('PRAGMA synchronous = NORMAL');
+  });
+
+  it('returns the DB instance', async () => {
+    const db = await createDatabase('app.db', 'write');
+    expect(db).toBeDefined();
+    expect(db.execute).toBeDefined();
+    expect(db.close).toBeDefined();
+  });
+
+  it('propagates error if open() throws', async () => {
+    (open as jest.Mock).mockImplementationOnce(() => {
+      throw new Error('open failed');
+    });
+    await expect(createDatabase('app.db', 'write')).rejects.toThrow('open failed');
+  });
+
+  it('propagates error if PRAGMA fails', async () => {
+    (open as jest.Mock).mockReturnValueOnce({
+      execute: jest.fn().mockRejectedValue(new Error('PRAGMA failed')),
+      close: jest.fn(),
+      loadExtension: jest.fn(),
+      updateHook: jest.fn(),
+      transaction: jest.fn(),
+      reactiveExecute: jest.fn(),
+    });
+    await expect(createDatabase('app.db', 'write')).rejects.toThrow('PRAGMA failed');
+  });
+});
diff --git a/src/core/database/__tests__/useDatabaseInitialization.test.ts b/src/core/database/__tests__/useDatabaseInitialization.test.ts
new file mode 100644
index 0000000..2dd09f7
--- /dev/null
+++ b/src/core/database/__tests__/useDatabaseInitialization.test.ts
@@ -0,0 +1,306 @@
+jest.mock('../createDatabase');
+jest.mock('../../sync/initializeSyncExtension');
+
+import { renderHook, act } from '@testing-library/react-native';
+import { useDatabaseInitialization } from '../useDatabaseInitialization';
+import { createDatabase } from '../createDatabase';
+import { initializeSyncExtension } from '../../sync/initializeSyncExtension';
+import { createLogger } from '../../common/logger';
+
+const logger = createLogger(false);
+
+const mockDb = {
+  execute: jest.fn().mockResolvedValue({ rows: [] }),
+  transaction: jest.fn(),
+  close: jest.fn(),
+  loadExtension: jest.fn(),
+  updateHook: jest.fn(),
+  reactiveExecute: jest.fn(),
+};
+
+describe('useDatabaseInitialization', () => {
+  beforeEach(() => {
+    jest.spyOn(console, 'log').mockImplementation();
+    jest.spyOn(console, 'warn').mockImplementation();
+    jest.spyOn(console, 'error').mockImplementation();
+    jest.clearAllMocks();
+    (createDatabase as jest.Mock).mockResolvedValue({ ...mockDb });
+    (initializeSyncExtension as jest.Mock).mockResolvedValue(undefined);
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  const defaultParams = {
+    databaseId: 'db_test_database_id',
+    databaseName: 'test.db',
+    tablesToBeSynced: [
+      {
+        name: 'users',
+        createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)',
+      },
+    ],
+    logger,
+  };
+
+  it('initializes database and sync successfully', async () => {
+    const { result } = renderHook(() =>
+      useDatabaseInitialization(defaultParams)
+    );
+
+    await act(async () => {});
+
+    expect(createDatabase).toHaveBeenCalledWith('test.db', 'write');
+    expect(createDatabase).toHaveBeenCalledWith('test.db', 'read');
+    expect(result.current.writeDb).not.toBeNull();
+    expect(result.current.readDb).not.toBeNull();
+    expect(result.current.isSyncReady).toBe(true);
+    expect(result.current.initError).toBeNull();
+    expect(result.current.syncError).toBeNull();
+  });
+
+  it('creates tables from config', async () => {
+    const db = {
+      ...mockDb,
+      execute: jest.fn().mockResolvedValue({ rows: [] }),
+    };
+    (createDatabase as jest.Mock).mockResolvedValue(db);
+
+    renderHook(() => useDatabaseInitialization(defaultParams));
+
+    await act(async () => {});
+
+    expect(db.execute).toHaveBeenCalledWith(
+      'CREATE TABLE IF NOT EXISTS users (id TEXT)'
+    );
+  });
+
+  it('sets initError on database creation failure', async () => {
+    (createDatabase as jest.Mock).mockRejectedValue(
+      new Error('db open failed')
+    );
+
+    const { result } = renderHook(() =>
+      useDatabaseInitialization(defaultParams)
+    );
+
+    await act(async () => {});
+
+    expect(result.current.initError?.message).toContain('db open failed');
+    expect(result.current.writeDb).toBeNull();
+    expect(result.current.isSyncReady).toBe(false);
+  });
+
+  it('sets syncError on sync init failure (db still works)', async () => {
+    (initializeSyncExtension as jest.Mock).mockRejectedValue(
+      new Error('sync init failed')
+    );
+
+    const { result } = renderHook(() =>
+      useDatabaseInitialization(defaultParams)
+    );
+
+    await act(async () => {});
+
+    expect(result.current.writeDb).not.toBeNull();
+    expect(result.current.readDb).not.toBeNull();
+    expect(result.current.isSyncReady).toBe(false);
+    expect(result.current.syncError?.message).toBe('sync init failed');
+    expect(result.current.initError).toBeNull();
+  });
+
+  it('calls onDatabaseReady callback', async () => {
+    const onDatabaseReady = jest.fn().mockResolvedValue(undefined);
+
+    renderHook(() =>
+      useDatabaseInitialization({ ...defaultParams, onDatabaseReady })
+    );
+
+    await act(async () => {});
+
+    expect(onDatabaseReady).toHaveBeenCalledWith(expect.any(Object));
+  });
+
+  it('runs onDatabaseReady before sync initialization', async () => {
+    const events: string[] = [];
+    const onDatabaseReady = jest.fn().mockImplementation(async () => {
+      events.push('onDatabaseReady');
+    });
+    (initializeSyncExtension as jest.Mock).mockImplementation(async () => {
+      events.push('initializeSyncExtension');
+    });
+
+    renderHook(() =>
+      useDatabaseInitialization({ ...defaultParams, onDatabaseReady })
+    );
+
+    await act(async () => {});
+
+    expect(events).toEqual(['onDatabaseReady', 'initializeSyncExtension']);
+  });
+
+  it('sets initError when onDatabaseReady fails', async () => {
+    const onDatabaseReady = jest
+      .fn()
+      .mockRejectedValue(new Error('migration fail'));
+
+    const { result } = renderHook(() =>
+      useDatabaseInitialization({ ...defaultParams, onDatabaseReady })
+    );
+
+    await act(async () => {});
+
+    expect(result.current.initError?.message).toContain('migration fail');
+  });
+
+  it('closes databases on unmount', async () => {
+    const writeDb = { ...mockDb, close: jest.fn() };
+    const readDb = { ...mockDb, close: jest.fn() };
+    (createDatabase as jest.Mock)
+      .mockResolvedValueOnce(writeDb)
+      .mockResolvedValueOnce(readDb);
+
+    const { unmount } = renderHook(() =>
+      useDatabaseInitialization(defaultParams)
+    );
+
+    await act(async () => {});
+
+    unmount();
+
+    expect(writeDb.close).toHaveBeenCalled();
+    expect(readDb.close).toHaveBeenCalled();
+  });
+
+  it('sets initError when databaseName is empty', async () => {
+    const { result } = renderHook(() =>
+      useDatabaseInitialization({ ...defaultParams, databaseName: '' })
+    );
+
+    await act(async () => {});
+
+    expect(result.current.initError?.message).toContain(
+      'Database name is required'
+    );
+    expect(result.current.writeDb).toBeNull();
+    expect(result.current.isSyncReady).toBe(false);
+  });
+
+  it('warns when tablesToBeSynced is empty', async () => {
+    const { result } = renderHook(() =>
+      useDatabaseInitialization({ ...defaultParams, tablesToBeSynced: [] })
+    );
+
+    await act(async () => {});
+
+    expect(result.current.writeDb).not.toBeNull();
+    expect(result.current.readDb).not.toBeNull();
+  });
+
+  it('handles write db close error on unmount', async () => {
+    const writeDb = {
+      ...mockDb,
+      close: jest.fn().mockImplementation(() => {
+        throw new Error('close fail');
+      }),
+    };
+    const readDb = { ...mockDb, close: jest.fn() };
+    (createDatabase as jest.Mock)
+      .mockResolvedValueOnce(writeDb)
+      .mockResolvedValueOnce(readDb);
+
+    const { unmount } = renderHook(() =>
+      useDatabaseInitialization(defaultParams)
+    );
+
+    await act(async () => {});
+
+    unmount();
+    // No crash — error is caught internally
+  });
+
+  it('handles read db close error on unmount', async () => {
+    const writeDb = { ...mockDb, close: jest.fn() };
+    const readDb = {
+      ...mockDb,
+      close: jest.fn().mockImplementation(() => {
+        throw new Error('close fail');
+      }),
+    };
+    (createDatabase as jest.Mock)
+      .mockResolvedValueOnce(writeDb)
+      .mockResolvedValueOnce(readDb);
+
+    const { unmount } = renderHook(() =>
+      useDatabaseInitialization(defaultParams)
+    );
+
+    await act(async () => {});
+
+    unmount();
+    // No crash — error is caught internally
+  });
+
+  it('sets initError when table creation fails', async () => {
+    const db = {
+      ...mockDb,
+      execute: jest.fn().mockRejectedValue(new Error('SQL error')),
+    };
+    (createDatabase as jest.Mock).mockResolvedValue(db);
+
+    const { result } = renderHook(() =>
+      useDatabaseInitialization(defaultParams)
+    );
+
+    await act(async () => {});
+
+    expect(result.current.initError?.message).toContain(
+      'Failed to create table users'
+    );
+  });
+
+  it('cleans up write db if read db creation fails', async () => {
+    const writeDb = { ...mockDb, close: jest.fn() };
+    (createDatabase as jest.Mock)
+      .mockResolvedValueOnce(writeDb)
+      .mockRejectedValueOnce(new Error('read open failed'));
+
+    const { result } = renderHook(() =>
+      useDatabaseInitialization(defaultParams)
+    );
+
+    await act(async () => {});
+
+    expect(result.current.initError?.message).toContain('read open failed');
+    expect(writeDb.close).toHaveBeenCalled();
+  });
+
+  it('reinitializes and closes old databases when auth changes', async () => {
+    const writeDb1 = { ...mockDb, close: jest.fn() };
+    const readDb1 = { ...mockDb, close: jest.fn() };
+    const writeDb2 = { ...mockDb, close: jest.fn() };
+    const readDb2 = { ...mockDb, close: jest.fn() };
+
+    (createDatabase as jest.Mock)
+      .mockResolvedValueOnce(writeDb1)
+      .mockResolvedValueOnce(readDb1)
+      .mockResolvedValueOnce(writeDb2)
+      .mockResolvedValueOnce(readDb2);
+
+    const { rerender } = renderHook(
+      ({ accessToken }: { accessToken?: string }) =>
+        useDatabaseInitialization({ ...defaultParams, accessToken }),
+      { initialProps: { accessToken: 'token-a' } }
+    );
+
+    await act(async () => {});
+
+    rerender({ accessToken: 'token-b' });
+
+    await act(async () => {});
+
+    expect(writeDb1.close).toHaveBeenCalled();
+    expect(readDb1.close).toHaveBeenCalled();
+  });
+});
diff --git a/src/core/database/useDatabaseInitialization.ts b/src/core/database/useDatabaseInitialization.ts
index 7a4d3c8..76faeaf 100644
--- a/src/core/database/useDatabaseInitialization.ts
+++ b/src/core/database/useDatabaseInitialization.ts
@@ -10,9 +10,9 @@ import { initializeSyncExtension } from '../sync/initializeSyncExtension';
  */
 export interface DatabaseInitializationParams {
   /**
-   * SQLite Cloud connection string
+   * CloudSync database ID used by runtime sync APIs
    */
-  connectionString: string;
+  databaseId: string;
 
   /**
    * Local database file name
@@ -95,7 +95,7 @@ export interface DatabaseInitializationResult {
  * @example
  * ```typescript
  * const { writeDb, readDb, isSyncReady, initError } = useDatabaseInitialization({
- *   connectionString: 'sqlitecloud://...',
+ *   databaseId: 'db_xxxxxxxxxxxxxxxxxxxxxxxx',
  *   databaseName: 'app.db',
  *   tablesToBeSynced: [{ name: 'users', createTableSql: '...' }],
  *   apiKey: 'your-api-key',
@@ -107,7 +107,7 @@ export function useDatabaseInitialization(
   params: DatabaseInitializationParams
 ): DatabaseInitializationResult {
   const {
-    connectionString,
+    databaseId,
     databaseName,
     tablesToBeSynced,
     apiKey,
@@ -136,13 +136,19 @@ export function useDatabaseInitialization(
   const serializedConfig = useMemo(
     () =>
       JSON.stringify({
-        connectionString,
+        databaseId,
         databaseName,
         tables: tablesToBeSynced,
         apiKey,
         accessToken,
       }),
-    [connectionString, databaseName, tablesToBeSynced, apiKey, accessToken]
+    [
+      databaseId,
+      databaseName,
+      tablesToBeSynced,
+      apiKey,
+      accessToken,
+    ]
   );
 
   /** INITIALIZATION EFFECT */
@@ -220,7 +226,13 @@ export function useDatabaseInitialization(
 
           await initializeSyncExtension(
             localWriteDb,
-            { connectionString, tablesToBeSynced, apiKey, accessToken },
+            {
+              databaseId,
+              databaseName,
+              tablesToBeSynced,
+              apiKey,
+              accessToken,
+            },
             logger
           );
 
@@ -246,6 +258,32 @@ export function useDatabaseInitialization(
       } catch (err) {
         /** FATAL ERROR - database can not be used */
         logger.error('❌ Database initialization failed:', err);
+        if (writeDbRef.current) {
+          try {
+            writeDbRef.current.close();
+            logger.info('Write database closed after initialization failure');
+          } catch (closeErr) {
+            logger.error(
+              '❌ Error closing write database after initialization failure:',
+              closeErr
+            );
+          } finally {
+            writeDbRef.current = null;
+          }
+        }
+        if (readDbRef.current) {
+          try {
+            readDbRef.current.close();
+            logger.info('Read database closed after initialization failure');
+          } catch (closeErr) {
+            logger.error(
+              '❌ Error closing read database after initialization failure:',
+              closeErr
+            );
+          } finally {
+            readDbRef.current = null;
+          }
+        }
         if (isMounted) {
           setInitError(
             err instanceof Error
diff --git a/src/core/lifecycle/__tests__/useAppLifecycle.test.ts b/src/core/lifecycle/__tests__/useAppLifecycle.test.ts
new file mode 100644
index 0000000..b3a9a3d
--- /dev/null
+++ b/src/core/lifecycle/__tests__/useAppLifecycle.test.ts
@@ -0,0 +1,267 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { createLogger } from '../../common/logger';
+import { FOREGROUND_DEBOUNCE_MS } from '../../constants';
+
+const mockRemove = jest.fn();
+let appStateHandler: ((state: string) => void) | null = null;
+
+jest.mock('react-native', () => ({
+  AppState: {
+    addEventListener: jest.fn((_event: string, handler: any) => {
+      appStateHandler = handler;
+      return { remove: mockRemove };
+    }),
+  },
+}));
+
+import { AppState } from 'react-native';
+import { useAppLifecycle } from '../useAppLifecycle';
+
+const createDefaultParams = (overrides?: Partial<any>) => ({
+  isSyncReady: true,
+  performSyncRef: { current: jest.fn().mockResolvedValue(undefined) },
+  setConsecutiveEmptySyncs: jest.fn(),
+  currentIntervalRef: { current: 5000 },
+  setCurrentInterval: jest.fn(),
+  adaptiveConfig: {
+    baseInterval: 5000,
+    maxInterval: 60000,
+    emptyThreshold: 5,
+    idleBackoffMultiplier: 1.5,
+    errorBackoffMultiplier: 2.0,
+  },
+  syncMode: 'polling' as const,
+  logger: createLogger(false),
+  ...overrides,
+});
+
+describe('useAppLifecycle', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    appStateHandler = null;
+    jest.spyOn(console, 'log').mockImplementation(() => {});
+    jest.spyOn(console, 'warn').mockImplementation(() => {});
+    jest.spyOn(console, 'error').mockImplementation(() => {});
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('returns active state initially', () => {
+    const params = createDefaultParams();
+    const { result } = renderHook(() => useAppLifecycle(params));
+
+    expect(result.current.appState).toBe('active');
+    expect(result.current.isInBackground).toBe(false);
+  });
+
+  it('registers AppState listener when sync ready', () => {
+    const params = createDefaultParams({ isSyncReady: true });
+    renderHook(() => useAppLifecycle(params));
+
+    expect(AppState.addEventListener).toHaveBeenCalledWith(
+      'change',
+      expect.any(Function)
+    );
+  });
+
+  it('does not register listener when not sync ready', () => {
+    const params = createDefaultParams({ isSyncReady: false });
+    renderHook(() => useAppLifecycle(params));
+
+    expect(AppState.addEventListener).not.toHaveBeenCalled();
+  });
+
+  it('triggers performSync on foreground transition', () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      performSyncRef: { current: performSync },
+    });
+
+    renderHook(() => useAppLifecycle(params));
+
+    act(() => {
+      appStateHandler?.('background');
+    });
+
+    act(() => {
+      appStateHandler?.('active');
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(1);
+  });
+
+  it('debounces rapid foreground transitions', () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      performSyncRef: { current: performSync },
+    });
+
+    renderHook(() => useAppLifecycle(params));
+
+    // First background → active transition
+    act(() => {
+      appStateHandler?.('background');
+    });
+    act(() => {
+      appStateHandler?.('active');
+    });
+
+    // Second rapid background → active transition (within debounce window)
+    act(() => {
+      appStateHandler?.('background');
+    });
+    act(() => {
+      appStateHandler?.('active');
+    });
+
+    // performSync should only be called once due to debouncing
+    expect(performSync).toHaveBeenCalledTimes(1);
+  });
+
+  it('resets interval to base on foreground (polling mode)', () => {
+    const setConsecutiveEmptySyncs = jest.fn();
+    const setCurrentInterval = jest.fn();
+    const currentIntervalRef = { current: 30000 };
+    const adaptiveConfig = {
+      baseInterval: 5000,
+      maxInterval: 60000,
+      emptyThreshold: 5,
+      idleBackoffMultiplier: 1.5,
+      errorBackoffMultiplier: 2.0,
+    };
+    const params = createDefaultParams({
+      setConsecutiveEmptySyncs,
+      setCurrentInterval,
+      currentIntervalRef,
+      adaptiveConfig,
+      syncMode: 'polling',
+    });
+
+    renderHook(() => useAppLifecycle(params));
+
+    act(() => {
+      appStateHandler?.('background');
+    });
+    act(() => {
+      appStateHandler?.('active');
+    });
+
+    expect(setConsecutiveEmptySyncs).toHaveBeenCalledWith(0);
+    expect(currentIntervalRef.current).toBe(adaptiveConfig.baseInterval);
+    expect(setCurrentInterval).toHaveBeenCalledWith(adaptiveConfig.baseInterval);
+  });
+
+  it('does not reset interval in push mode', () => {
+    const setConsecutiveEmptySyncs = jest.fn();
+    const setCurrentInterval = jest.fn();
+    const params = createDefaultParams({
+      setConsecutiveEmptySyncs,
+      setCurrentInterval,
+      syncMode: 'push',
+    });
+
+    renderHook(() => useAppLifecycle(params));
+
+    act(() => {
+      appStateHandler?.('background');
+    });
+    act(() => {
+      appStateHandler?.('active');
+    });
+
+    expect(setConsecutiveEmptySyncs).not.toHaveBeenCalled();
+    expect(setCurrentInterval).not.toHaveBeenCalled();
+  });
+
+  it('removes listener on unmount', () => {
+    const params = createDefaultParams();
+    const { unmount } = renderHook(() => useAppLifecycle(params));
+
+    unmount();
+
+    expect(mockRemove).toHaveBeenCalledTimes(1);
+  });
+
+  it('updates appState and isInBackground on background transition', () => {
+    const params = createDefaultParams();
+    const { result } = renderHook(() => useAppLifecycle(params));
+
+    act(() => {
+      appStateHandler?.('background');
+    });
+
+    expect(result.current.appState).toBe('background');
+    expect(result.current.isInBackground).toBe(true);
+  });
+
+  it('does not trigger sync on active to active transition', () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      performSyncRef: { current: performSync },
+    });
+
+    renderHook(() => useAppLifecycle(params));
+
+    act(() => {
+      appStateHandler?.('active');
+    });
+
+    expect(performSync).not.toHaveBeenCalled();
+  });
+
+  it('triggers sync on inactive to active transition', () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      performSyncRef: { current: performSync },
+    });
+
+    renderHook(() => useAppLifecycle(params));
+
+    act(() => {
+      appStateHandler?.('inactive');
+    });
+    act(() => {
+      appStateHandler?.('active');
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(1);
+  });
+
+  it('debounce allows foreground sync after FOREGROUND_DEBOUNCE_MS has elapsed', () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      performSyncRef: { current: performSync },
+    });
+
+    jest.useFakeTimers();
+
+    renderHook(() => useAppLifecycle(params));
+
+    // First foreground transition
+    act(() => {
+      appStateHandler?.('background');
+    });
+    act(() => {
+      appStateHandler?.('active');
+    });
+
+    // Advance time past debounce window
+    act(() => {
+      jest.advanceTimersByTime(FOREGROUND_DEBOUNCE_MS + 1);
+    });
+
+    // Second foreground transition after debounce
+    act(() => {
+      appStateHandler?.('background');
+    });
+    act(() => {
+      appStateHandler?.('active');
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(2);
+
+    jest.useRealTimers();
+  });
+});
diff --git a/src/core/lifecycle/__tests__/useNetworkListener.test.ts b/src/core/lifecycle/__tests__/useNetworkListener.test.ts
new file mode 100644
index 0000000..fb3bbe6
--- /dev/null
+++ b/src/core/lifecycle/__tests__/useNetworkListener.test.ts
@@ -0,0 +1,246 @@
+import NetInfo from '@react-native-community/netinfo';
+import { renderHook, act } from '@testing-library/react-native';
+import { createLogger } from '../../common/logger';
+import { useNetworkListener } from '../useNetworkListener';
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+const createDefaultParams = (overrides?: Partial<any>) => ({
+  isSyncReady: true,
+  performSyncRef: { current: jest.fn().mockResolvedValue(undefined) },
+  appState: 'active',
+  logger: createLogger(false),
+  ...overrides,
+});
+
+// ─── Setup ───────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+  jest.spyOn(console, 'log').mockImplementation(() => {});
+  jest.spyOn(console, 'warn').mockImplementation(() => {});
+  jest.spyOn(console, 'error').mockImplementation(() => {});
+});
+
+afterEach(() => {
+  jest.restoreAllMocks();
+  (NetInfo as any).__clearListeners();
+});
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('useNetworkListener', () => {
+  it('returns network available initially', () => {
+    const { result } = renderHook(() =>
+      useNetworkListener(createDefaultParams())
+    );
+
+    expect(result.current.isNetworkAvailable).toBe(true);
+  });
+
+  it('registers NetInfo listener when sync ready', () => {
+    renderHook(() => useNetworkListener(createDefaultParams()));
+
+    expect(NetInfo.addEventListener).toHaveBeenCalled();
+  });
+
+  it('does not register when not sync ready', () => {
+    (NetInfo.addEventListener as jest.Mock).mockClear();
+
+    renderHook(() =>
+      useNetworkListener(createDefaultParams({ isSyncReady: false }))
+    );
+
+    expect(NetInfo.addEventListener).not.toHaveBeenCalled();
+  });
+
+  it('triggers sync on reconnection when app active', () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      performSyncRef: { current: performSync },
+      appState: 'active',
+    });
+
+    renderHook(() => useNetworkListener(params));
+
+    // Go offline first so wasOffline becomes true
+    act(() => {
+      (NetInfo as any).__simulateChange({
+        isConnected: false,
+        isInternetReachable: false,
+      });
+    });
+
+    // Come back online — should trigger sync
+    act(() => {
+      (NetInfo as any).__simulateChange({
+        isConnected: true,
+        isInternetReachable: true,
+      });
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(1);
+  });
+
+  it('does not trigger sync on reconnection when app in background', () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      performSyncRef: { current: performSync },
+      appState: 'background',
+    });
+
+    renderHook(() => useNetworkListener(params));
+
+    // Go offline
+    act(() => {
+      (NetInfo as any).__simulateChange({
+        isConnected: false,
+        isInternetReachable: false,
+      });
+    });
+
+    // Come back online while in background
+    act(() => {
+      (NetInfo as any).__simulateChange({
+        isConnected: true,
+        isInternetReachable: true,
+      });
+    });
+
+    expect(performSync).not.toHaveBeenCalled();
+  });
+
+  it('does not trigger sync when going from online to online', () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      performSyncRef: { current: performSync },
+      appState: 'active',
+    });
+
+    renderHook(() => useNetworkListener(params));
+
+    // Simulate online → online (wasOffline was false the whole time)
+    act(() => {
+      (NetInfo as any).__simulateChange({
+        isConnected: true,
+        isInternetReachable: true,
+      });
+    });
+
+    expect(performSync).not.toHaveBeenCalled();
+  });
+
+  it('updates isNetworkAvailable to false when going offline', () => {
+    const { result } = renderHook(() =>
+      useNetworkListener(createDefaultParams())
+    );
+
+    act(() => {
+      (NetInfo as any).__simulateChange({
+        isConnected: false,
+        isInternetReachable: false,
+      });
+    });
+
+    expect(result.current.isNetworkAvailable).toBe(false);
+  });
+
+  it('treats null isInternetReachable as online (true)', () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      performSyncRef: { current: performSync },
+      appState: 'active',
+    });
+
+    const { result } = renderHook(() => useNetworkListener(params));
+
+    // Go offline first
+    act(() => {
+      (NetInfo as any).__simulateChange({
+        isConnected: false,
+        isInternetReachable: false,
+      });
+    });
+
+    expect(result.current.isNetworkAvailable).toBe(false);
+
+    // Come back with isInternetReachable = null (should be treated as true)
+    act(() => {
+      (NetInfo as any).__simulateChange({
+        isConnected: true,
+        isInternetReachable: null,
+      });
+    });
+
+    expect(result.current.isNetworkAvailable).toBe(true);
+    expect(performSync).toHaveBeenCalledTimes(1);
+  });
+
+  it('treats null isConnected as offline (false)', () => {
+    const { result } = renderHook(() =>
+      useNetworkListener(createDefaultParams())
+    );
+
+    act(() => {
+      (NetInfo as any).__simulateChange({
+        isConnected: null,
+        isInternetReachable: true,
+      });
+    });
+
+    expect(result.current.isNetworkAvailable).toBe(false);
+  });
+
+  it('does not retrigger sync for repeated online events without going offline again', () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      performSyncRef: { current: performSync },
+      appState: 'active',
+    });
+
+    renderHook(() => useNetworkListener(params));
+
+    act(() => {
+      (NetInfo as any).__simulateChange({
+        isConnected: false,
+        isInternetReachable: false,
+      });
+    });
+    act(() => {
+      (NetInfo as any).__simulateChange({
+        isConnected: true,
+        isInternetReachable: true,
+      });
+    });
+    act(() => {
+      (NetInfo as any).__simulateChange({
+        isConnected: true,
+        isInternetReachable: true,
+      });
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(1);
+  });
+
+  it('unsubscribes on cleanup', () => {
+    // Capture the unsubscribe mock returned by addEventListener
+    let capturedUnsubscribe: jest.Mock | undefined;
+    (NetInfo.addEventListener as jest.Mock).mockImplementationOnce(
+      (_callback: any) => {
+        capturedUnsubscribe = jest.fn(() => {
+          (NetInfo as any).__clearListeners();
+        });
+        // Still register so __simulateChange works if needed
+        (NetInfo as any).__simulateChange; // no-op reference to avoid lint
+        return capturedUnsubscribe;
+      }
+    );
+
+    const { unmount } = renderHook(() =>
+      useNetworkListener(createDefaultParams())
+    );
+
+    unmount();
+
+    expect(capturedUnsubscribe).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/src/core/polling/__tests__/calculateAdaptiveSyncInterval.test.ts b/src/core/polling/__tests__/calculateAdaptiveSyncInterval.test.ts
new file mode 100644
index 0000000..db87579
--- /dev/null
+++ b/src/core/polling/__tests__/calculateAdaptiveSyncInterval.test.ts
@@ -0,0 +1,98 @@
+import { calculateAdaptiveSyncInterval } from '../calculateAdaptiveSyncInterval';
+
+const defaultConfig = {
+  baseInterval: 5000,
+  maxInterval: 300000,
+  emptyThreshold: 5,
+  idleBackoffMultiplier: 1.5,
+  errorBackoffMultiplier: 2.0,
+};
+
+describe('calculateAdaptiveSyncInterval', () => {
+  it('returns baseInterval when no errors, no idle', () => {
+    const result = calculateAdaptiveSyncInterval(
+      { lastSyncChanges: 5, consecutiveEmptySyncs: 0, consecutiveSyncErrors: 0 },
+      defaultConfig
+    );
+    expect(result).toBe(5000);
+  });
+
+  it('returns baseInterval when below emptyThreshold', () => {
+    const result = calculateAdaptiveSyncInterval(
+      { lastSyncChanges: 0, consecutiveEmptySyncs: 4, consecutiveSyncErrors: 0 },
+      defaultConfig
+    );
+    expect(result).toBe(5000);
+  });
+
+  it('applies idle backoff at exactly emptyThreshold', () => {
+    const result = calculateAdaptiveSyncInterval(
+      { lastSyncChanges: 0, consecutiveEmptySyncs: 5, consecutiveSyncErrors: 0 },
+      defaultConfig
+    );
+    expect(result).toBe(7500);
+  });
+
+  it('increases idle backoff with consecutive empty syncs', () => {
+    const result = calculateAdaptiveSyncInterval(
+      { lastSyncChanges: 0, consecutiveEmptySyncs: 7, consecutiveSyncErrors: 0 },
+      defaultConfig
+    );
+    expect(result).toBe(5000 * Math.pow(1.5, 3));
+  });
+
+  it('caps idle backoff at maxInterval', () => {
+    const result = calculateAdaptiveSyncInterval(
+      { lastSyncChanges: 0, consecutiveEmptySyncs: 100, consecutiveSyncErrors: 0 },
+      defaultConfig
+    );
+    expect(result).toBe(300000);
+  });
+
+  it('applies error backoff exponentially', () => {
+    const result = calculateAdaptiveSyncInterval(
+      { lastSyncChanges: 0, consecutiveEmptySyncs: 0, consecutiveSyncErrors: 3 },
+      defaultConfig
+    );
+    expect(result).toBe(40000);
+  });
+
+  it('caps error backoff at maxInterval', () => {
+    const result = calculateAdaptiveSyncInterval(
+      { lastSyncChanges: 0, consecutiveEmptySyncs: 0, consecutiveSyncErrors: 100 },
+      defaultConfig
+    );
+    expect(result).toBe(300000);
+  });
+
+  it('gives error priority over idle backoff', () => {
+    const result = calculateAdaptiveSyncInterval(
+      { lastSyncChanges: 0, consecutiveEmptySyncs: 10, consecutiveSyncErrors: 2 },
+      defaultConfig
+    );
+    expect(result).toBe(5000 * Math.pow(2.0, 2));
+  });
+
+  it('handles single error', () => {
+    const result = calculateAdaptiveSyncInterval(
+      { lastSyncChanges: 0, consecutiveEmptySyncs: 0, consecutiveSyncErrors: 1 },
+      defaultConfig
+    );
+    expect(result).toBe(10000);
+  });
+
+  it('works with custom config values', () => {
+    const config = {
+      baseInterval: 1000,
+      maxInterval: 10000,
+      emptyThreshold: 2,
+      idleBackoffMultiplier: 2,
+      errorBackoffMultiplier: 3,
+    };
+    const result = calculateAdaptiveSyncInterval(
+      { lastSyncChanges: 0, consecutiveEmptySyncs: 3, consecutiveSyncErrors: 0 },
+      config
+    );
+    expect(result).toBe(1000 * Math.pow(2, 2));
+  });
+});
diff --git a/src/core/polling/__tests__/useAdaptivePollingSync.test.ts b/src/core/polling/__tests__/useAdaptivePollingSync.test.ts
new file mode 100644
index 0000000..deae817
--- /dev/null
+++ b/src/core/polling/__tests__/useAdaptivePollingSync.test.ts
@@ -0,0 +1,252 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { useAdaptivePollingSync } from '../useAdaptivePollingSync';
+import type { AdaptivePollingParams } from '../useAdaptivePollingSync';
+
+jest.useFakeTimers();
+
+const createDefaultParams = (overrides?: Partial<AdaptivePollingParams>): AdaptivePollingParams => ({
+  isSyncReady: true,
+  appState: 'active',
+  performSyncRef: { current: jest.fn().mockResolvedValue(undefined) },
+  currentIntervalRef: { current: 5000 },
+  syncMode: 'polling',
+  ...overrides,
+});
+
+describe('useAdaptivePollingSync', () => {
+  beforeEach(() => {
+    jest.spyOn(console, 'log').mockImplementation(() => {});
+    jest.spyOn(console, 'warn').mockImplementation(() => {});
+    jest.spyOn(console, 'error').mockImplementation(() => {});
+  });
+
+  afterEach(() => {
+    jest.clearAllTimers();
+    jest.clearAllMocks();
+  });
+
+  it('does not poll when not sync ready', async () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      isSyncReady: false,
+      performSyncRef: { current: performSync },
+    });
+
+    renderHook(() => useAdaptivePollingSync(params));
+
+    await act(async () => {
+      jest.advanceTimersByTime(10000);
+    });
+
+    expect(performSync).not.toHaveBeenCalled();
+  });
+
+  it('does not poll when syncMode is push', async () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      syncMode: 'push',
+      performSyncRef: { current: performSync },
+    });
+
+    renderHook(() => useAdaptivePollingSync(params));
+
+    await act(async () => {
+      jest.advanceTimersByTime(10000);
+    });
+
+    expect(performSync).not.toHaveBeenCalled();
+  });
+
+  it('does not poll when interval is null', async () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      currentIntervalRef: { current: null },
+      performSyncRef: { current: performSync },
+    });
+
+    renderHook(() => useAdaptivePollingSync(params));
+
+    await act(async () => {
+      jest.advanceTimersByTime(10000);
+    });
+
+    expect(performSync).not.toHaveBeenCalled();
+  });
+
+  it('polls at current interval', async () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      currentIntervalRef: { current: 5000 },
+      performSyncRef: { current: performSync },
+    });
+
+    renderHook(() => useAdaptivePollingSync(params));
+
+    await act(async () => {
+      jest.advanceTimersByTime(5000);
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(1);
+  });
+
+  it('reschedules after sync completes', async () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      currentIntervalRef: { current: 5000 },
+      performSyncRef: { current: performSync },
+    });
+
+    renderHook(() => useAdaptivePollingSync(params));
+
+    // First poll fires
+    await act(async () => {
+      jest.advanceTimersByTime(5000);
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(1);
+
+    // Second poll fires after sync completes and next interval elapses
+    await act(async () => {
+      jest.advanceTimersByTime(5000);
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(2);
+  });
+
+  it('pauses when app goes to background', async () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      currentIntervalRef: { current: 5000 },
+      performSyncRef: { current: performSync },
+    });
+
+    const { rerender } = renderHook(
+      (props: AdaptivePollingParams) => useAdaptivePollingSync(props),
+      { initialProps: params }
+    );
+
+    // Let first poll fire while active
+    await act(async () => {
+      jest.advanceTimersByTime(5000);
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(1);
+
+    // App goes to background
+    rerender({ ...params, appState: 'background' });
+
+    // Advance timers — no more calls should happen
+    await act(async () => {
+      jest.advanceTimersByTime(15000);
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(1);
+  });
+
+  it('resumes when app returns to foreground', async () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      currentIntervalRef: { current: 5000 },
+      performSyncRef: { current: performSync },
+    });
+
+    const { rerender } = renderHook(
+      (props: AdaptivePollingParams) => useAdaptivePollingSync(props),
+      { initialProps: params }
+    );
+
+    // Go to background immediately (before any poll)
+    rerender({ ...params, appState: 'background' });
+
+    await act(async () => {
+      jest.advanceTimersByTime(5000);
+    });
+
+    expect(performSync).not.toHaveBeenCalled();
+
+    // Return to foreground
+    rerender({ ...params, appState: 'active' });
+
+    // Polling should resume
+    await act(async () => {
+      jest.advanceTimersByTime(5000);
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(1);
+  });
+
+  it('stops scheduling when interval becomes null mid-loop', async () => {
+    const intervalRef = { current: 5000 as number | null };
+    // Make performSync set interval to null when called
+    const performSync = jest.fn().mockImplementation(async () => {
+      intervalRef.current = null;
+    });
+    const params = createDefaultParams({
+      currentIntervalRef: intervalRef,
+      performSyncRef: { current: performSync },
+    });
+
+    renderHook(() => useAdaptivePollingSync(params));
+
+    // First poll fires — performSync sets interval to null
+    await act(async () => {
+      jest.advanceTimersByTime(5000);
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(1);
+
+    // Advance — no more polls should happen since interval is null
+    await act(async () => {
+      jest.advanceTimersByTime(15000);
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(1);
+  });
+
+  it('uses updated interval value for the next scheduled sync', async () => {
+    const intervalRef = { current: 5000 as number | null };
+    const performSync = jest.fn().mockImplementation(async () => {
+      intervalRef.current = 1000;
+    });
+    const params = createDefaultParams({
+      currentIntervalRef: intervalRef,
+      performSyncRef: { current: performSync },
+    });
+
+    renderHook(() => useAdaptivePollingSync(params));
+
+    await act(async () => {
+      jest.advanceTimersByTime(5000);
+    });
+
+    expect(performSync).toHaveBeenCalledTimes(1);
+
+    await act(async () => {
+      jest.advanceTimersByTime(999);
+    });
+    expect(performSync).toHaveBeenCalledTimes(1);
+
+    await act(async () => {
+      jest.advanceTimersByTime(1);
+    });
+    expect(performSync).toHaveBeenCalledTimes(2);
+  });
+
+  it('cleans up timer on unmount', async () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    const params = createDefaultParams({
+      currentIntervalRef: { current: 5000 },
+      performSyncRef: { current: performSync },
+    });
+
+    const { unmount } = renderHook(() => useAdaptivePollingSync(params));
+
+    unmount();
+
+    await act(async () => {
+      jest.advanceTimersByTime(10000);
+    });
+
+    expect(performSync).not.toHaveBeenCalled();
+  });
+});
diff --git a/src/core/pushNotifications/__tests__/isSqliteCloudNotification.test.ts b/src/core/pushNotifications/__tests__/isSqliteCloudNotification.test.ts
new file mode 100644
index 0000000..b98aa1e
--- /dev/null
+++ b/src/core/pushNotifications/__tests__/isSqliteCloudNotification.test.ts
@@ -0,0 +1,58 @@
+import {
+  isForegroundSqliteCloudNotification,
+  isSqliteCloudNotification,
+} from '../isSqliteCloudNotification';
+
+describe('isSqliteCloudNotification', () => {
+  it('detects foreground apply notifications', () => {
+    expect(
+      isForegroundSqliteCloudNotification({
+        request: { content: { data: { cloudSyncEvent: 'apply' } } },
+      })
+    ).toBe(true);
+  });
+
+  it('detects foreground check notifications', () => {
+    expect(
+      isForegroundSqliteCloudNotification({
+        request: {
+          content: {
+            data: { cloudSyncEvent: 'check', artifactURI: 's3://artifact' },
+          },
+        },
+      })
+    ).toBe(true);
+  });
+
+  it('detects iOS background notifications', () => {
+    expect(
+      isSqliteCloudNotification({
+        data: { body: { cloudSyncEvent: 'apply' } },
+      })
+    ).toBe(true);
+  });
+
+  it('detects Android background notifications from body JSON', () => {
+    expect(
+      isSqliteCloudNotification({
+        data: { body: '{"cloudSyncEvent":"check","artifactURI":"s3://..."}' },
+      })
+    ).toBe(true);
+  });
+
+  it('detects Android background notifications from dataString JSON', () => {
+    expect(
+      isSqliteCloudNotification({
+        data: { body: '{}', dataString: '{"cloudSyncEvent":"apply"}' },
+      })
+    ).toBe(true);
+  });
+
+  it('rejects notifications without cloudSyncEvent', () => {
+    expect(
+      isSqliteCloudNotification({
+        data: { body: '{}', dataString: '{}' },
+      })
+    ).toBe(false);
+  });
+});
diff --git a/src/core/pushNotifications/__tests__/pushNotificationSyncCallbacks.test.ts b/src/core/pushNotifications/__tests__/pushNotificationSyncCallbacks.test.ts
new file mode 100644
index 0000000..31c6f74
--- /dev/null
+++ b/src/core/pushNotifications/__tests__/pushNotificationSyncCallbacks.test.ts
@@ -0,0 +1,39 @@
+import {
+  registerBackgroundSyncCallback,
+  getBackgroundSyncCallback,
+  setForegroundSyncCallback,
+  getForegroundSyncCallback,
+} from '../pushNotificationSyncCallbacks';
+
+describe('pushNotificationSyncCallbacks', () => {
+  beforeEach(() => {
+    setForegroundSyncCallback(null);
+  });
+
+  it('getBackgroundSyncCallback returns null initially', () => {
+    const result = getBackgroundSyncCallback();
+    expect(result === null || typeof result === 'function').toBe(true);
+  });
+
+  it('register then get background callback returns same function', () => {
+    const callback = jest.fn();
+    registerBackgroundSyncCallback(callback);
+    expect(getBackgroundSyncCallback()).toBe(callback);
+  });
+
+  it('getForegroundSyncCallback returns null initially', () => {
+    expect(getForegroundSyncCallback()).toBeNull();
+  });
+
+  it('set then get foreground callback returns same function', () => {
+    const callback = jest.fn();
+    setForegroundSyncCallback(callback);
+    expect(getForegroundSyncCallback()).toBe(callback);
+  });
+
+  it('set null clears foreground callback', () => {
+    setForegroundSyncCallback(jest.fn());
+    setForegroundSyncCallback(null);
+    expect(getForegroundSyncCallback()).toBeNull();
+  });
+});
diff --git a/src/core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts b/src/core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts
new file mode 100644
index 0000000..304d989
--- /dev/null
+++ b/src/core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts
@@ -0,0 +1,165 @@
+jest.mock('react-native', () => ({
+  AppState: { currentState: 'active' },
+}));
+jest.mock('../../common/optionalDependencies', () => ({
+  ExpoTaskManager: { defineTask: jest.fn() },
+}));
+jest.mock('../../common/logger', () => ({
+  createLogger: jest.fn(() => ({
+    info: jest.fn(),
+    warn: jest.fn(),
+    error: jest.fn(),
+  })),
+}));
+jest.mock('../../background/backgroundSyncConfig');
+jest.mock('../../background/executeBackgroundSync');
+jest.mock('../pushNotificationSyncCallbacks');
+jest.mock('../isSqliteCloudNotification');
+
+import { AppState } from 'react-native';
+import { ExpoTaskManager } from '../../common/optionalDependencies';
+import { getPersistedConfig } from '../../background/backgroundSyncConfig';
+import { executeBackgroundSync } from '../../background/executeBackgroundSync';
+import { getForegroundSyncCallback } from '../pushNotificationSyncCallbacks';
+import { isSqliteCloudNotification } from '../isSqliteCloudNotification';
+import { BACKGROUND_SYNC_TASK_NAME } from '../../constants';
+
+const mockDefineTask = (ExpoTaskManager as any).defineTask as jest.Mock;
+
+/** Import the module to trigger the top-level side effect */
+require('../pushNotificationSyncTask');
+
+/** Capture the handler ONCE before any mocks are cleared */
+const handler = mockDefineTask.mock.calls[0]![1] as (args: {
+  data: any;
+  error: any;
+}) => Promise<void>;
+
+describe('pushNotificationSyncTask', () => {
+  beforeEach(() => {
+    // Clear all mocks EXCEPT the initial defineTask call we already captured
+    (getPersistedConfig as jest.Mock).mockReset();
+    (executeBackgroundSync as jest.Mock).mockReset();
+    (getForegroundSyncCallback as jest.Mock).mockReset();
+    (isSqliteCloudNotification as jest.Mock).mockReset();
+    jest.spyOn(console, 'log').mockImplementation();
+    jest.spyOn(console, 'warn').mockImplementation();
+    jest.spyOn(console, 'error').mockImplementation();
+    (AppState as any).currentState = 'active';
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('defines task when ExpoTaskManager is available', () => {
+    expect(mockDefineTask).toHaveBeenCalledWith(
+      BACKGROUND_SYNC_TASK_NAME,
+      expect.any(Function)
+    );
+  });
+
+  it('defineTask is only called once (not on re-import)', () => {
+    // The module-level if(ExpoTaskManager) runs once on first import.
+    // We verify it was called exactly once (from the initial require above).
+    expect(mockDefineTask).toHaveBeenCalledTimes(1);
+  });
+
+  it('calls executeBackgroundSync for valid SQLite Cloud notification', async () => {
+    const fakeConfig = {
+      debug: false,
+      databaseId: 'db_test_database_id',
+      databaseName: 'test.db',
+      tablesToBeSynced: [],
+    };
+    (getPersistedConfig as jest.Mock).mockResolvedValue(fakeConfig);
+    (isSqliteCloudNotification as jest.Mock).mockReturnValue(true);
+    (getForegroundSyncCallback as jest.Mock).mockReturnValue(null);
+    (AppState as any).currentState = 'background';
+
+    await handler({
+      data: { body: { artifactURI: 'https://sqlite.ai' } },
+      error: null,
+    });
+
+    expect(executeBackgroundSync).toHaveBeenCalledWith(fakeConfig);
+  });
+
+  it('skips non-SQLite Cloud notification', async () => {
+    (getPersistedConfig as jest.Mock).mockResolvedValue({ debug: false });
+    (isSqliteCloudNotification as jest.Mock).mockReturnValue(false);
+
+    await handler({
+      data: { body: { artifactURI: 'https://other.com' } },
+      error: null,
+    });
+
+    expect(executeBackgroundSync).not.toHaveBeenCalled();
+  });
+
+  it('uses foreground callback when app is active', async () => {
+    const foregroundCallback = jest.fn().mockResolvedValue(undefined);
+    (getPersistedConfig as jest.Mock).mockResolvedValue({ debug: false });
+    (isSqliteCloudNotification as jest.Mock).mockReturnValue(true);
+    (getForegroundSyncCallback as jest.Mock).mockReturnValue(
+      foregroundCallback
+    );
+    (AppState as any).currentState = 'active';
+
+    await handler({
+      data: { body: { artifactURI: 'https://sqlite.ai' } },
+      error: null,
+    });
+
+    expect(foregroundCallback).toHaveBeenCalled();
+    expect(executeBackgroundSync).not.toHaveBeenCalled();
+  });
+
+  it('handles foreground sync error gracefully', async () => {
+    const foregroundCallback = jest
+      .fn()
+      .mockRejectedValue(new Error('sync failed'));
+    (getPersistedConfig as jest.Mock).mockResolvedValue({ debug: false });
+    (isSqliteCloudNotification as jest.Mock).mockReturnValue(true);
+    (getForegroundSyncCallback as jest.Mock).mockReturnValue(
+      foregroundCallback
+    );
+    (AppState as any).currentState = 'active';
+
+    await expect(handler({ data: {}, error: null })).resolves.toBeUndefined();
+  });
+
+  it('skips background sync without config', async () => {
+    (getPersistedConfig as jest.Mock).mockResolvedValue(null);
+    (isSqliteCloudNotification as jest.Mock).mockReturnValue(true);
+    (getForegroundSyncCallback as jest.Mock).mockReturnValue(null);
+    (AppState as any).currentState = 'background';
+
+    await handler({ data: {}, error: null });
+
+    expect(executeBackgroundSync).not.toHaveBeenCalled();
+  });
+
+  it('skips background sync when persisted config is invalid', async () => {
+    (getPersistedConfig as jest.Mock).mockResolvedValue(null);
+    (isSqliteCloudNotification as jest.Mock).mockReturnValue(true);
+    (getForegroundSyncCallback as jest.Mock).mockReturnValue(null);
+    (AppState as any).currentState = 'background';
+
+    await handler({
+      data: { body: { artifactURI: 'https://sqlite.ai' } },
+      error: null,
+    });
+
+    expect(executeBackgroundSync).not.toHaveBeenCalled();
+  });
+
+  it('handles task error by logging and returning', async () => {
+    (getPersistedConfig as jest.Mock).mockResolvedValue({ debug: false });
+
+    await handler({ data: null, error: new Error('task error') });
+
+    expect(isSqliteCloudNotification).not.toHaveBeenCalled();
+    expect(executeBackgroundSync).not.toHaveBeenCalled();
+  });
+});
diff --git a/src/core/pushNotifications/__tests__/registerPushToken.test.ts b/src/core/pushNotifications/__tests__/registerPushToken.test.ts
new file mode 100644
index 0000000..e2f7b60
--- /dev/null
+++ b/src/core/pushNotifications/__tests__/registerPushToken.test.ts
@@ -0,0 +1,185 @@
+import { registerPushToken } from '../registerPushToken';
+import { createLogger } from '../../common/logger';
+import {
+  ExpoSecureStore,
+  ExpoApplication,
+} from '../../common/optionalDependencies';
+
+jest.mock('../../common/optionalDependencies', () => ({
+  ExpoSecureStore: {
+    getItemAsync: jest.fn().mockResolvedValue(null),
+    setItemAsync: jest.fn().mockResolvedValue(undefined),
+  },
+  ExpoApplication: {
+    getIosIdForVendorAsync: jest.fn().mockResolvedValue('mock-ios-vendor-id'),
+    getAndroidId: jest.fn().mockReturnValue('mock-android-id'),
+  },
+}));
+
+jest.mock('react-native', () => ({
+  Platform: { OS: 'ios' },
+}));
+
+const mockFetch = jest.fn().mockResolvedValue({
+  ok: true,
+  text: jest.fn().mockResolvedValue(''),
+});
+global.fetch = mockFetch;
+
+const logger = createLogger(false);
+
+const baseParams = {
+  expoToken: 'ExponentPushToken[abc123]',
+  databaseId: 'db_test_database_id',
+  siteId: 'site-1',
+  platform: 'ios',
+  apiKey: 'my-api-key',
+  logger,
+};
+
+describe('registerPushToken', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    mockFetch.mockResolvedValue({
+      ok: true,
+      text: jest.fn().mockResolvedValue(''),
+    });
+
+    const { Platform } = require('react-native');
+    Platform.OS = 'ios';
+  });
+
+  it('skips registration if token is already registered', async () => {
+    (ExpoSecureStore!.getItemAsync as jest.Mock).mockResolvedValueOnce(
+      'ExponentPushToken[abc123]'
+    );
+
+    await registerPushToken(baseParams);
+
+    expect(mockFetch).not.toHaveBeenCalled();
+  });
+
+  it('sends request to the correct URL', async () => {
+    await registerPushToken(baseParams);
+
+    expect(mockFetch).toHaveBeenCalledWith(
+      'https://cloudsync.sqlite.ai/v2/cloudsync/databases/db_test_database_id/notifications/tokens',
+      expect.objectContaining({
+        method: 'PUT',
+      })
+    );
+  });
+
+  it('uses accessToken in Authorization header when provided', async () => {
+    await registerPushToken({
+      ...baseParams,
+      accessToken: 'my-access-token',
+    });
+
+    const callArgs = mockFetch.mock.calls[0][1];
+    expect(callArgs.headers.Authorization).toBe('Bearer my-access-token');
+  });
+
+  it('uses apiKey in Authorization header when no accessToken', async () => {
+    await registerPushToken({
+      ...baseParams,
+      apiKey: 'my-api-key',
+    });
+
+    const callArgs = mockFetch.mock.calls[0][1];
+    expect(callArgs.headers.Authorization).toBe('Bearer my-api-key');
+  });
+
+  it('sends correct body fields', async () => {
+    await registerPushToken({
+      ...baseParams,
+      siteId: 'site-42',
+    });
+
+    const callArgs = mockFetch.mock.calls[0][1];
+    const body = JSON.parse(callArgs.body);
+
+    expect(body).toEqual({
+      expoToken: 'ExponentPushToken[abc123]',
+      deviceId: 'mock-ios-vendor-id',
+      siteId: 'site-42',
+      platform: 'ios',
+    });
+  });
+
+  it('uses iOS device ID on iOS', async () => {
+    await registerPushToken(baseParams);
+
+    expect(ExpoApplication!.getIosIdForVendorAsync).toHaveBeenCalled();
+    expect(ExpoApplication!.getAndroidId).not.toHaveBeenCalled();
+  });
+
+  it('uses Android device ID on Android', async () => {
+    const { Platform } = require('react-native');
+    Platform.OS = 'android';
+
+    await registerPushToken({ ...baseParams, platform: 'android' });
+
+    expect(ExpoApplication!.getAndroidId).toHaveBeenCalled();
+  });
+
+  it('throws on non-ok response', async () => {
+    mockFetch.mockResolvedValueOnce({
+      ok: false,
+      status: 500,
+      text: jest.fn().mockResolvedValue('Internal Server Error'),
+    });
+
+    await expect(registerPushToken(baseParams)).rejects.toThrow(
+      'Failed to register push token: 500 Internal Server Error'
+    );
+  });
+
+  it('persists token after successful registration', async () => {
+    await registerPushToken(baseParams);
+
+    expect(ExpoSecureStore!.setItemAsync).toHaveBeenCalledWith(
+      'sqlite_sync_push_token_registered',
+      'ExponentPushToken[abc123]'
+    );
+  });
+
+  it('handles SecureStore read errors gracefully', async () => {
+    (ExpoSecureStore!.getItemAsync as jest.Mock).mockRejectedValueOnce(
+      new Error('SecureStore read failed')
+    );
+
+    await expect(registerPushToken(baseParams)).resolves.toBeUndefined();
+    expect(mockFetch).toHaveBeenCalled();
+  });
+
+  it('handles SecureStore write errors gracefully', async () => {
+    (ExpoSecureStore!.setItemAsync as jest.Mock).mockRejectedValueOnce(
+      new Error('SecureStore write failed')
+    );
+
+    await expect(registerPushToken(baseParams)).resolves.toBeUndefined();
+  });
+
+  it('throws when ExpoApplication is null', async () => {
+    const deps = require('../../common/optionalDependencies');
+    const originalExpoApplication = deps.ExpoApplication;
+    deps.ExpoApplication = null;
+
+    try {
+      await expect(registerPushToken(baseParams)).rejects.toThrow(
+        'expo-application is required'
+      );
+    } finally {
+      deps.ExpoApplication = originalExpoApplication;
+    }
+  });
+
+  it('throws when neither apiKey nor accessToken is provided', async () => {
+    await expect(
+      registerPushToken({ ...baseParams, apiKey: undefined, accessToken: undefined })
+    ).rejects.toThrow(
+      'Push token registration requires either apiKey or accessToken'
+    );
+  });
+});
diff --git a/src/core/pushNotifications/__tests__/usePushNotificationSync.test.ts b/src/core/pushNotifications/__tests__/usePushNotificationSync.test.ts
new file mode 100644
index 0000000..019b3c3
--- /dev/null
+++ b/src/core/pushNotifications/__tests__/usePushNotificationSync.test.ts
@@ -0,0 +1,462 @@
+jest.mock('react-native', () => ({
+  Platform: { OS: 'ios' },
+}));
+jest.mock('../../common/optionalDependencies', () => ({
+  ExpoConstants: {
+    expoConfig: { extra: { eas: { projectId: 'test-project-id' } } },
+  },
+  ExpoNotifications: {
+    getPermissionsAsync: jest.fn(),
+    requestPermissionsAsync: jest.fn(),
+    getDevicePushTokenAsync: jest.fn(),
+    getExpoPushTokenAsync: jest.fn(),
+    addNotificationReceivedListener: jest.fn(),
+  },
+  isBackgroundSyncAvailable: jest.fn(),
+}));
+jest.mock('../../background/backgroundSyncRegistry');
+jest.mock('../pushNotificationSyncCallbacks');
+jest.mock('../registerPushToken');
+jest.mock('../isSqliteCloudNotification');
+
+import { renderHook, act } from '@testing-library/react-native';
+import { usePushNotificationSync } from '../usePushNotificationSync';
+import {
+  ExpoNotifications,
+  isBackgroundSyncAvailable,
+} from '../../common/optionalDependencies';
+import {
+  registerBackgroundSync,
+  unregisterBackgroundSync,
+} from '../../background/backgroundSyncRegistry';
+import { setForegroundSyncCallback } from '../pushNotificationSyncCallbacks';
+import { registerPushToken } from '../registerPushToken';
+import { isForegroundSqliteCloudNotification } from '../isSqliteCloudNotification';
+import { createLogger } from '../../common/logger';
+
+const mockExpoNotifications = ExpoNotifications as any;
+
+describe('usePushNotificationSync', () => {
+  const logger = createLogger(false);
+
+  const createDefaultParams = (overrides?: Partial<any>) => ({
+    isSyncReady: true,
+    performSyncRef: { current: jest.fn().mockResolvedValue(undefined) },
+    writeDbRef: {
+      current: {
+        execute: jest
+          .fn()
+          .mockResolvedValue({ rows: [{ 'cloudsync_siteid()': 'site-123' }] }),
+      },
+    } as any,
+    syncMode: 'push' as const,
+    notificationListening: 'foreground' as const,
+    logger,
+    databaseId: 'db_test_database_id',
+    databaseName: 'test.db',
+    tablesToBeSynced: [
+      {
+        name: 'users',
+        createTableSql:
+          'CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY)',
+      },
+    ],
+    ...overrides,
+  });
+
+  beforeEach(() => {
+    jest.spyOn(console, 'log').mockImplementation();
+    jest.spyOn(console, 'warn').mockImplementation();
+    jest.spyOn(console, 'error').mockImplementation();
+    jest.clearAllMocks();
+
+    mockExpoNotifications.getPermissionsAsync.mockResolvedValue({
+      status: 'granted',
+    });
+    mockExpoNotifications.requestPermissionsAsync.mockResolvedValue({
+      status: 'granted',
+    });
+    mockExpoNotifications.getDevicePushTokenAsync.mockResolvedValue({
+      data: 'device-token',
+    });
+    mockExpoNotifications.getExpoPushTokenAsync.mockResolvedValue({
+      data: 'ExponentPushToken[xxx]',
+    });
+    mockExpoNotifications.addNotificationReceivedListener.mockReturnValue({
+      remove: jest.fn(),
+    });
+    (isBackgroundSyncAvailable as jest.Mock).mockReturnValue(false);
+    (registerPushToken as jest.Mock).mockResolvedValue(undefined);
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('does nothing when syncMode is polling', async () => {
+    renderHook(() =>
+      usePushNotificationSync(createDefaultParams({ syncMode: 'polling' }))
+    );
+
+    await act(async () => {});
+
+    expect(mockExpoNotifications.getPermissionsAsync).not.toHaveBeenCalled();
+    expect(
+      mockExpoNotifications.addNotificationReceivedListener
+    ).not.toHaveBeenCalled();
+  });
+
+  it('does nothing when not sync ready', async () => {
+    renderHook(() =>
+      usePushNotificationSync(createDefaultParams({ isSyncReady: false }))
+    );
+
+    await act(async () => {});
+
+    expect(mockExpoNotifications.getPermissionsAsync).not.toHaveBeenCalled();
+  });
+
+  it('does nothing when expo-notifications is unavailable', async () => {
+    const deps = require('../../common/optionalDependencies');
+    const originalExpoNotifications = deps.ExpoNotifications;
+    deps.ExpoNotifications = null;
+
+    try {
+      renderHook(() => usePushNotificationSync(createDefaultParams()));
+
+      await act(async () => {});
+
+      expect(registerPushToken).not.toHaveBeenCalled();
+    } finally {
+      deps.ExpoNotifications = originalExpoNotifications;
+    }
+  });
+
+  it('requests permissions when push mode', async () => {
+    renderHook(() => usePushNotificationSync(createDefaultParams()));
+
+    await act(async () => {});
+
+    expect(mockExpoNotifications.getPermissionsAsync).toHaveBeenCalled();
+  });
+
+  it('registers push token on permission granted', async () => {
+    renderHook(() => usePushNotificationSync(createDefaultParams()));
+
+    await act(async () => {});
+
+    expect(registerPushToken).toHaveBeenCalledWith(
+      expect.objectContaining({
+        expoToken: 'ExponentPushToken[xxx]',
+        databaseId: 'db_test_database_id',
+      })
+    );
+  });
+
+  it('calls onPermissionsDenied when permissions denied', async () => {
+    mockExpoNotifications.getPermissionsAsync.mockResolvedValue({
+      status: 'denied',
+    });
+    mockExpoNotifications.requestPermissionsAsync.mockResolvedValue({
+      status: 'denied',
+    });
+
+    const onPermissionsDenied = jest.fn();
+    renderHook(() =>
+      usePushNotificationSync(createDefaultParams({ onPermissionsDenied }))
+    );
+
+    await act(async () => {});
+
+    expect(onPermissionsDenied).toHaveBeenCalled();
+  });
+
+  it('shows custom permission prompt and requests permissions after allow', async () => {
+    mockExpoNotifications.getPermissionsAsync.mockResolvedValue({
+      status: 'undetermined',
+    });
+    const renderPushPermissionPrompt = jest.fn(({ allow }: any) => {
+      allow();
+      return 'permission-ui';
+    });
+
+    const { result } = renderHook(() =>
+      usePushNotificationSync(
+        createDefaultParams({ renderPushPermissionPrompt })
+      )
+    );
+
+    await act(async () => {});
+
+    expect(renderPushPermissionPrompt).toHaveBeenCalledWith(
+      expect.objectContaining({
+        allow: expect.any(Function),
+        deny: expect.any(Function),
+      })
+    );
+    expect(result.current.permissionPromptNode).toBeNull();
+    expect(mockExpoNotifications.requestPermissionsAsync).toHaveBeenCalled();
+  });
+
+  it('shows custom permission prompt and falls back when denied from custom UI', async () => {
+    mockExpoNotifications.getPermissionsAsync.mockResolvedValue({
+      status: 'undetermined',
+    });
+    const onPermissionsDenied = jest.fn();
+    const renderPushPermissionPrompt = jest.fn(({ deny }: any) => {
+      deny();
+      return 'permission-ui';
+    });
+
+    renderHook(() =>
+      usePushNotificationSync(
+        createDefaultParams({
+          renderPushPermissionPrompt,
+          onPermissionsDenied,
+        })
+      )
+    );
+
+    await act(async () => {});
+
+    expect(onPermissionsDenied).toHaveBeenCalled();
+    expect(mockExpoNotifications.requestPermissionsAsync).not.toHaveBeenCalled();
+  });
+
+  it('adds foreground listener in foreground mode', async () => {
+    renderHook(() => usePushNotificationSync(createDefaultParams()));
+
+    await act(async () => {});
+
+    expect(
+      mockExpoNotifications.addNotificationReceivedListener
+    ).toHaveBeenCalledWith(expect.any(Function));
+  });
+
+  it('triggers sync on SQLite Cloud notification', async () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    let notificationHandler: any;
+    mockExpoNotifications.addNotificationReceivedListener.mockImplementation(
+      (handler: any) => {
+        notificationHandler = handler;
+        return { remove: jest.fn() };
+      }
+    );
+    (isForegroundSqliteCloudNotification as jest.Mock).mockReturnValue(true);
+
+    renderHook(() =>
+      usePushNotificationSync(
+        createDefaultParams({
+          performSyncRef: { current: performSync },
+        })
+      )
+    );
+
+    await act(async () => {});
+
+    // Simulate notification
+    await act(async () => {
+      notificationHandler({
+        request: {
+          content: { data: { artifactURI: 'https://sqlite.ai' } },
+        },
+      });
+    });
+
+    expect(performSync).toHaveBeenCalled();
+  });
+
+  it('does not trigger sync for non-SQLite Cloud notification', async () => {
+    const performSync = jest.fn().mockResolvedValue(undefined);
+    let notificationHandler: any;
+    mockExpoNotifications.addNotificationReceivedListener.mockImplementation(
+      (handler: any) => {
+        notificationHandler = handler;
+        return { remove: jest.fn() };
+      }
+    );
+    (isForegroundSqliteCloudNotification as jest.Mock).mockReturnValue(false);
+
+    renderHook(() =>
+      usePushNotificationSync(
+        createDefaultParams({
+          performSyncRef: { current: performSync },
+        })
+      )
+    );
+
+    await act(async () => {});
+
+    await act(async () => {
+      notificationHandler({ request: { content: { data: {} } } });
+    });
+
+    expect(performSync).not.toHaveBeenCalled();
+  });
+
+  it('registers background sync in always mode when available', async () => {
+    (isBackgroundSyncAvailable as jest.Mock).mockReturnValue(true);
+
+    renderHook(() =>
+      usePushNotificationSync(
+        createDefaultParams({ notificationListening: 'always' })
+      )
+    );
+
+    await act(async () => {});
+
+    expect(registerBackgroundSync).toHaveBeenCalledWith(
+      expect.objectContaining({
+        databaseName: 'test.db',
+      })
+    );
+    expect(setForegroundSyncCallback).toHaveBeenCalledWith(
+      expect.any(Function)
+    );
+  });
+
+  it('falls back to foreground listener when background not available', async () => {
+    (isBackgroundSyncAvailable as jest.Mock).mockReturnValue(false);
+
+    renderHook(() =>
+      usePushNotificationSync(
+        createDefaultParams({ notificationListening: 'always' })
+      )
+    );
+
+    await act(async () => {});
+
+    expect(registerBackgroundSync).not.toHaveBeenCalled();
+    expect(
+      mockExpoNotifications.addNotificationReceivedListener
+    ).toHaveBeenCalled();
+  });
+
+  it('does not register background sync in always mode when deps are missing', async () => {
+    (isBackgroundSyncAvailable as jest.Mock).mockReturnValue(false);
+
+    renderHook(() =>
+      usePushNotificationSync(
+        createDefaultParams({ notificationListening: 'always' })
+      )
+    );
+
+    await act(async () => {});
+
+    expect(registerBackgroundSync).not.toHaveBeenCalled();
+    expect(setForegroundSyncCallback).not.toHaveBeenCalledWith(
+      expect.any(Function)
+    );
+  });
+
+  it('unregisters background sync when switching from push to polling', async () => {
+    const { rerender } = renderHook(
+      ({ syncMode }: { syncMode: 'push' | 'polling' }) =>
+        usePushNotificationSync(createDefaultParams({ syncMode })),
+      { initialProps: { syncMode: 'push' as const } }
+    );
+
+    await act(async () => {});
+
+    rerender({ syncMode: 'polling' });
+
+    expect(unregisterBackgroundSync).toHaveBeenCalled();
+  });
+
+  it('skips token registration when siteId retrieval fails', async () => {
+    const writeDbRef = {
+      current: {
+        execute: jest.fn().mockRejectedValue(new Error('cloudsync_siteid fail')),
+      },
+    };
+
+    renderHook(() =>
+      usePushNotificationSync(createDefaultParams({ writeDbRef }))
+    );
+
+    await act(async () => {});
+
+    expect(registerPushToken).not.toHaveBeenCalled();
+  });
+
+  it('skips token registration when siteId is empty', async () => {
+    const writeDbRef = {
+      current: {
+        execute: jest.fn().mockResolvedValue({ rows: [] }),
+      },
+    };
+
+    renderHook(() =>
+      usePushNotificationSync(createDefaultParams({ writeDbRef }))
+    );
+
+    await act(async () => {});
+
+    expect(registerPushToken).not.toHaveBeenCalled();
+  });
+
+  it('falls back to polling when registerPushToken fails', async () => {
+    (registerPushToken as jest.Mock).mockRejectedValue(new Error('token fail'));
+    const onPermissionsDenied = jest.fn();
+
+    renderHook(() =>
+      usePushNotificationSync(createDefaultParams({ onPermissionsDenied }))
+    );
+
+    await act(async () => {});
+
+    expect(registerPushToken).toHaveBeenCalled();
+    expect(onPermissionsDenied).toHaveBeenCalled();
+  });
+
+  it('falls back to polling when databaseId is missing', async () => {
+    const onPermissionsDenied = jest.fn();
+
+    renderHook(() =>
+      usePushNotificationSync(
+        createDefaultParams({
+          databaseId: '',
+          onPermissionsDenied,
+        })
+      )
+    );
+
+    await act(async () => {});
+
+    expect(registerPushToken).not.toHaveBeenCalled();
+    expect(onPermissionsDenied).toHaveBeenCalled();
+  });
+
+  it('removes listeners on unmount', async () => {
+    const removeMock = jest.fn();
+    mockExpoNotifications.addNotificationReceivedListener.mockReturnValue({
+      remove: removeMock,
+    });
+
+    const { unmount } = renderHook(() =>
+      usePushNotificationSync(createDefaultParams())
+    );
+
+    await act(async () => {});
+
+    unmount();
+
+    expect(removeMock).toHaveBeenCalled();
+    expect(setForegroundSyncCallback).toHaveBeenCalledWith(null);
+  });
+
+  it('clears foreground callback on unmount in always mode', async () => {
+    (isBackgroundSyncAvailable as jest.Mock).mockReturnValue(true);
+
+    const { unmount } = renderHook(() =>
+      usePushNotificationSync(
+        createDefaultParams({ notificationListening: 'always' })
+      )
+    );
+
+    await act(async () => {});
+    unmount();
+
+    expect(setForegroundSyncCallback).toHaveBeenCalledWith(null);
+  });
+});
diff --git a/src/core/pushNotifications/isSqliteCloudNotification.ts b/src/core/pushNotifications/isSqliteCloudNotification.ts
index bc9f999..c0f525e 100644
--- a/src/core/pushNotifications/isSqliteCloudNotification.ts
+++ b/src/core/pushNotifications/isSqliteCloudNotification.ts
@@ -1,40 +1,48 @@
-/** SQLITE CLOUD ARTIFACT URI */
-const ARTIFACT_URI = 'https://sqlite.ai';
+const CLOUDSYNC_EVENTS = new Set(['apply', 'check']);
+
+const hasCloudSyncEvent = (data: any): boolean =>
+  CLOUDSYNC_EVENTS.has(data?.cloudSyncEvent);
 
 /**
  * Check if a foreground Expo Notification is from SQLite Cloud.
  * Foreground notifications use the Expo Notification object structure:
- * `notification.request.content.data.artifactURI`
+ * `notification.request.content.data.cloudSyncEvent`
  */
 export const isForegroundSqliteCloudNotification = (
   notification: any
 ): boolean => {
-  const artifactURI = notification?.request?.content?.data?.artifactURI;
-  return artifactURI === ARTIFACT_URI;
+  return hasCloudSyncEvent(notification?.request?.content?.data);
 };
 
 /**
  * Check if background/terminated task data is from SQLite Cloud.
  * Handles platform differences:
- * - iOS: `data.body` is an object with `artifactURI`
- * - Android: `data.body` or `data.dataString` is a JSON string containing `artifactURI`
+ * - iOS: `data.body` is an object with `cloudSyncEvent`
+ * - Android: `data.body` or `data.dataString` is a JSON string containing `cloudSyncEvent`
  * Also checks the foreground structure as a fallback.
  */
 export const isSqliteCloudNotification = (notification: any): boolean => {
   const body = notification?.data?.body;
 
   /** CHECK IOS BACKGROUND FORMAT */
-  if (body && typeof body === 'object' && body.artifactURI === ARTIFACT_URI) {
+  if (body && typeof body === 'object' && hasCloudSyncEvent(body)) {
     return true;
   }
 
   /** CHECK ANDROID BACKGROUND FORMAT */
-  const bodyString =
-    typeof body === 'string' ? body : notification?.data?.dataString;
-  if (typeof bodyString === 'string') {
+  const androidPayloads = [
+    typeof body === 'string' ? body : null,
+    notification?.data?.dataString,
+  ];
+
+  for (const payload of androidPayloads) {
+    if (typeof payload !== 'string') {
+      continue;
+    }
+
     try {
-      const parsed = JSON.parse(bodyString);
-      if (parsed?.artifactURI === ARTIFACT_URI) {
+      const parsed = JSON.parse(payload);
+      if (hasCloudSyncEvent(parsed)) {
         return true;
       }
     } catch {
diff --git a/src/core/pushNotifications/registerPushToken.ts b/src/core/pushNotifications/registerPushToken.ts
index 3a3e072..6b7e402 100644
--- a/src/core/pushNotifications/registerPushToken.ts
+++ b/src/core/pushNotifications/registerPushToken.ts
@@ -3,9 +3,9 @@ import {
   ExpoApplication,
 } from '../common/optionalDependencies';
 import type { Logger } from '../common/logger';
+import { CLOUDSYNC_BASE_URL } from '../constants';
 
 const TOKEN_REGISTERED_KEY = 'sqlite_sync_push_token_registered';
-const CLOUDSYNC_BASE_URL = 'https://cloudsync-staging.fly.dev/v2';
 
 async function getDeviceId(): Promise<string> {
   if (!ExpoApplication) {
@@ -24,10 +24,9 @@ async function getDeviceId(): Promise<string> {
 
 interface RegisterPushTokenParams {
   expoToken: string;
-  databaseName: string;
-  siteId?: string;
+  databaseId: string;
+  siteId: string;
   platform: string;
-  connectionString: string;
   apiKey?: string;
   accessToken?: string;
   logger: Logger;
@@ -42,10 +41,9 @@ export async function registerPushToken(
 ): Promise<void> {
   const {
     expoToken,
-    databaseName,
+    databaseId,
     siteId,
     platform,
-    connectionString,
     apiKey,
     accessToken,
     logger,
@@ -74,7 +72,11 @@ export async function registerPushToken(
   if (accessToken) {
     headers.Authorization = `Bearer ${accessToken}`;
   } else if (apiKey) {
-    headers.Authorization = `Bearer ${connectionString}?apikey=${apiKey}`;
+    headers.Authorization = `Bearer ${apiKey}`;
+  } else {
+    throw new Error(
+      'Push token registration requires either apiKey or accessToken'
+    );
   }
 
   /** PREPARE REQUEST BODY */
@@ -83,13 +85,14 @@ export async function registerPushToken(
   const body = {
     expoToken,
     deviceId,
-    database: databaseName,
-    siteId: siteId ?? '',
+    siteId,
     platform,
   };
 
   /** SEND REGISTRATION REQUEST */
-  const url = `${CLOUDSYNC_BASE_URL}/cloudsync/notifications/tokens`;
+  const url = `${CLOUDSYNC_BASE_URL}/v2/cloudsync/databases/${encodeURIComponent(
+    databaseId
+  )}/notifications/tokens`;
   logger.info(
     '📱 Registering push token with backend...',
     url,
@@ -97,7 +100,7 @@ export async function registerPushToken(
   );
 
   const response = await fetch(url, {
-    method: 'POST',
+    method: 'PUT',
     headers,
     body: JSON.stringify(body),
   });
diff --git a/src/core/pushNotifications/usePushNotificationSync.ts b/src/core/pushNotifications/usePushNotificationSync.ts
index 8c79f02..a6718bd 100644
--- a/src/core/pushNotifications/usePushNotificationSync.ts
+++ b/src/core/pushNotifications/usePushNotificationSync.ts
@@ -69,9 +69,9 @@ export interface PushNotificationSyncParams {
 
   // Background sync configuration (needed for background/terminated modes)
   /**
-   * SQLite Cloud connection string
+   * Database ID used by CloudSync v2 runtime APIs
    */
-  connectionString: string;
+  databaseId: string;
 
   /**
    * Local database file name
@@ -120,7 +120,7 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): {
     logger,
     onPermissionsDenied,
     renderPushPermissionPrompt,
-    connectionString,
+    databaseId,
     databaseName,
     tablesToBeSynced,
     apiKey,
@@ -131,7 +131,7 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): {
   /** SERIALIZED CONFIG */
   // Detect actual changes (avoids re-runs from unstable references like tablesToBeSynced)
   const serializedBackgroundConfig = JSON.stringify({
-    connectionString,
+    databaseId,
     databaseName,
     tablesToBeSynced,
     apiKey,
@@ -212,6 +212,14 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): {
 
     hasRequestedPermissionsRef.current = true;
 
+    if (!databaseId.trim()) {
+      logger.warn(
+        '⚠️ Push mode requires databaseId for notification token registration. Falling back to polling mode.'
+      );
+      onPermissionsDeniedRef.current?.();
+      return;
+    }
+
     const requestPermissions = async () => {
       try {
         /** CHECK EXISTING PERMISSIONS */
@@ -277,34 +285,43 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): {
           // Get site ID for token registration
           let siteId: string | undefined;
           try {
-            const firstTable = tablesToBeSynced[0];
-            if (firstTable && writeDbRef.current) {
-              const initResult = await writeDbRef.current.execute(
-                'SELECT cloudsync_init(?);',
-                [firstTable.name]
+            if (writeDbRef.current) {
+              const siteIdResult = await writeDbRef.current.execute(
+                'SELECT cloudsync_siteid();'
               );
-              const firstRow = initResult.rows?.[0];
-              siteId = firstRow
-                ? String(Object.values(firstRow)[0])
-                : undefined;
+              const firstRow = siteIdResult.rows?.[0];
+              siteId = firstRow ? String(Object.values(firstRow)[0]) : undefined;
             }
           } catch {
-            logger.warn('⚠️ Could not retrieve siteId');
+            logger.warn(
+              '⚠️ Could not retrieve siteId - skipping token registration (will retry on next app open)'
+            );
+            return;
+          }
+
+          if (!siteId) {
+            logger.warn(
+              '⚠️ No siteId available - skipping token registration (will retry on next app open)'
+            );
+            return;
           }
 
           try {
             await registerPushToken({
               expoToken: token.data,
-              databaseName,
+              databaseId,
               siteId,
               platform: Platform.OS,
-              connectionString,
               apiKey,
               accessToken,
               logger,
             });
           } catch (registerError) {
-            logger.warn('⚠️ Failed to register push token:', registerError);
+            logger.warn(
+              '⚠️ Failed to register push token - falling back to polling mode:',
+              registerError
+            );
+            onPermissionsDeniedRef.current?.();
           }
         }
       } catch (error) {
@@ -315,7 +332,7 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): {
 
     requestPermissions();
     // eslint-disable-next-line react-hooks/exhaustive-deps -- runs once per mount, guarded by hasRequestedPermissionsRef
-  }, [isSyncReady, syncMode]);
+  }, [databaseId, isSyncReady, syncMode]);
 
   /** NOTIFICATION LISTENERS EFFECT */
   useEffect(() => {
@@ -363,7 +380,7 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): {
         );
 
         registerBackgroundSync({
-          connectionString,
+          databaseId,
           databaseName,
           tablesToBeSynced,
           apiKey,
diff --git a/src/core/sync/__tests__/executeSync.test.ts b/src/core/sync/__tests__/executeSync.test.ts
new file mode 100644
index 0000000..4274e44
--- /dev/null
+++ b/src/core/sync/__tests__/executeSync.test.ts
@@ -0,0 +1,228 @@
+import { createMockDB } from '../../../__mocks__/@op-engineering/op-sqlite';
+import { createLogger } from '../../common/logger';
+import { executeSync } from '../executeSync';
+
+const logger = createLogger(false);
+
+beforeEach(() => {
+  jest.useFakeTimers();
+});
+
+afterEach(() => {
+  jest.useRealTimers();
+});
+
+const syncResult = (rowsReceived: number) => ({
+  rows: [
+    {
+      'cloudsync_network_sync()': JSON.stringify({
+        send: {
+          status: 'synced',
+          localVersion: 5,
+          serverVersion: 5,
+        },
+        receive: { rows: rowsReceived, tables: ['users'] },
+      }),
+    },
+  ],
+});
+
+const noChangesResult = () => syncResult(0);
+
+describe('JS retry', () => {
+  it('returns 0 when no changes', async () => {
+    const db = createMockDB();
+    db.execute.mockResolvedValue(noChangesResult());
+
+    const promise = executeSync(db as any, logger, { maxAttempts: 1 });
+    // Advance past any delays
+    await jest.runAllTimersAsync();
+    const changes = await promise;
+
+    expect(changes).toBe(0);
+  });
+
+  it('returns count from JSON result', async () => {
+    const db = createMockDB();
+    db.execute.mockResolvedValue(syncResult(3));
+
+    const promise = executeSync(db as any, logger, { maxAttempts: 1 });
+    await jest.runAllTimersAsync();
+    const changes = await promise;
+
+    expect(changes).toBe(3);
+  });
+
+  it('stops retrying on changes found', async () => {
+    const db = createMockDB();
+    db.execute
+      .mockResolvedValueOnce(noChangesResult())
+      .mockResolvedValueOnce(syncResult(5));
+
+    const promise = executeSync(db as any, logger, { maxAttempts: 4, attemptDelay: 1000 });
+    await jest.runAllTimersAsync();
+    const changes = await promise;
+
+    expect(changes).toBe(5);
+    expect(db.execute).toHaveBeenCalledTimes(2);
+  });
+
+  it('retries up to maxAttempts', async () => {
+    const db = createMockDB();
+    db.execute.mockResolvedValue(noChangesResult());
+
+    const promise = executeSync(db as any, logger, { maxAttempts: 4, attemptDelay: 1000 });
+    await jest.runAllTimersAsync();
+    const changes = await promise;
+
+    expect(changes).toBe(0);
+    expect(db.execute).toHaveBeenCalledTimes(4);
+  });
+
+  it('custom maxAttempts honored', async () => {
+    const db = createMockDB();
+    db.execute.mockResolvedValue(noChangesResult());
+
+    const promise = executeSync(db as any, logger, { maxAttempts: 2, attemptDelay: 1000 });
+    await jest.runAllTimersAsync();
+    const changes = await promise;
+
+    expect(changes).toBe(0);
+    expect(db.execute).toHaveBeenCalledTimes(2);
+  });
+
+  it('uses transaction when useTransaction=true', async () => {
+    const db = createMockDB();
+    const txExecute = jest.fn().mockResolvedValue(syncResult(2));
+    db.transaction.mockImplementation(async (fn: any) => {
+      const tx = { execute: txExecute };
+      await fn(tx);
+      return tx;
+    });
+
+    const promise = executeSync(db as any, logger, {
+      useTransaction: true,
+      maxAttempts: 1,
+    });
+    await jest.runAllTimersAsync();
+    const changes = await promise;
+
+    expect(changes).toBe(2);
+    expect(db.transaction).toHaveBeenCalledTimes(1);
+    expect(txExecute).toHaveBeenCalledWith('SELECT cloudsync_network_sync();');
+    expect(db.execute).not.toHaveBeenCalled();
+  });
+
+  it('no transaction when useTransaction=false (default)', async () => {
+    const db = createMockDB();
+    db.execute.mockResolvedValue(syncResult(1));
+
+    const promise = executeSync(db as any, logger, { maxAttempts: 1 });
+    await jest.runAllTimersAsync();
+    const changes = await promise;
+
+    expect(changes).toBe(1);
+    expect(db.execute).toHaveBeenCalledTimes(1);
+    expect(db.transaction).not.toHaveBeenCalled();
+  });
+
+  it('handles malformed JSON gracefully (returns 0)', async () => {
+    const db = createMockDB();
+    db.execute.mockResolvedValue({
+      rows: [{ 'cloudsync_network_sync()': 'not-valid-json' }],
+    });
+
+    const promise = executeSync(db as any, logger, { maxAttempts: 1 });
+    await jest.runAllTimersAsync();
+    const changes = await promise;
+
+    expect(changes).toBe(0);
+  });
+
+  it('handles missing rows (returns 0)', async () => {
+    const db = createMockDB();
+    db.execute.mockResolvedValue({ rows: [] });
+
+    const promise = executeSync(db as any, logger, { maxAttempts: 1 });
+    await jest.runAllTimersAsync();
+    const changes = await promise;
+
+    expect(changes).toBe(0);
+  });
+
+  it('handles non-string values (returns 0)', async () => {
+    const db = createMockDB();
+    db.execute.mockResolvedValue({
+      rows: [{ 'cloudsync_network_sync()': 42 }],
+    });
+
+    const promise = executeSync(db as any, logger, { maxAttempts: 1 });
+    await jest.runAllTimersAsync();
+    const changes = await promise;
+
+    expect(changes).toBe(0);
+  });
+
+  it('delays between attempts', async () => {
+    const db = createMockDB();
+    db.execute.mockResolvedValue(noChangesResult());
+
+    const promise = executeSync(db as any, logger, { maxAttempts: 3, attemptDelay: 2000 });
+
+    // First attempt runs immediately
+    await jest.advanceTimersByTimeAsync(0);
+    expect(db.execute).toHaveBeenCalledTimes(1);
+
+    // After 2000ms delay, second attempt
+    await jest.advanceTimersByTimeAsync(2000);
+    expect(db.execute).toHaveBeenCalledTimes(2);
+
+    // After another 2000ms delay, third attempt
+    await jest.advanceTimersByTimeAsync(2000);
+    expect(db.execute).toHaveBeenCalledTimes(3);
+
+    await promise;
+  });
+});
+
+describe('Native retry', () => {
+  it('passes params to cloudsync_network_sync', async () => {
+    const db = createMockDB();
+    db.execute.mockResolvedValue(noChangesResult());
+
+    const promise = executeSync(db as any, logger, {
+      useNativeRetry: true,
+      maxAttempts: 5,
+      attemptDelay: 2000,
+    });
+    await jest.runAllTimersAsync();
+    await promise;
+
+    expect(db.execute).toHaveBeenCalledWith(
+      'SELECT cloudsync_network_sync(?, ?);',
+      [2000, 5]
+    );
+  });
+
+  it('returns changes from result', async () => {
+    const db = createMockDB();
+    db.execute.mockResolvedValue(syncResult(7));
+
+    const promise = executeSync(db as any, logger, { useNativeRetry: true });
+    await jest.runAllTimersAsync();
+    const changes = await promise;
+
+    expect(changes).toBe(7);
+  });
+
+  it('returns 0 for empty result', async () => {
+    const db = createMockDB();
+    db.execute.mockResolvedValue({ rows: [] });
+
+    const promise = executeSync(db as any, logger, { useNativeRetry: true });
+    await jest.runAllTimersAsync();
+    const changes = await promise;
+
+    expect(changes).toBe(0);
+  });
+});
diff --git a/src/core/sync/__tests__/initializeSyncExtension.test.ts b/src/core/sync/__tests__/initializeSyncExtension.test.ts
new file mode 100644
index 0000000..1ba49a1
--- /dev/null
+++ b/src/core/sync/__tests__/initializeSyncExtension.test.ts
@@ -0,0 +1,330 @@
+jest.mock('react-native', () => ({ Platform: { OS: 'ios' } }));
+jest.mock('@op-engineering/op-sqlite');
+
+import { Platform } from 'react-native';
+import { getDylibPath } from '@op-engineering/op-sqlite';
+import { createMockDB } from '../../../__mocks__/@op-engineering/op-sqlite';
+import { createLogger } from '../../common/logger';
+import { CLOUDSYNC_BASE_URL } from '../../constants';
+import {
+  initializeSyncExtension,
+  type SyncInitConfig,
+} from '../initializeSyncExtension';
+
+const logger = createLogger(false);
+
+function makeConfig(overrides: Partial<SyncInitConfig> = {}): SyncInitConfig {
+  return {
+    databaseId: 'db_test_database_id',
+    databaseName: 'test.db',
+    tablesToBeSynced: [
+      {
+        name: 'users',
+        createTableSql:
+          'CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY)',
+      },
+    ],
+    apiKey: 'test-api-key',
+    ...overrides,
+  };
+}
+
+function makeMockDB(
+  versionResult: any = { rows: [{ 'cloudsync_version()': '1.0.0' }] }
+) {
+  const db = createMockDB();
+  db.execute.mockImplementation(async (sql: string) => {
+    if (sql.includes('cloudsync_version')) return versionResult;
+    if (sql.includes('cloudsync_init'))
+      return { rows: [{ 'cloudsync_init(?)': 'site-id-123' }] };
+    return { rows: [] };
+  });
+  return db;
+}
+
+describe('initializeSyncExtension', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    (Platform as any).OS = 'ios';
+  });
+
+  it('throws if databaseId is missing', async () => {
+    const db = makeMockDB();
+    const config = makeConfig({ databaseId: '' });
+
+    await expect(
+      initializeSyncExtension(db as any, config, logger)
+    ).rejects.toThrow('Sync configuration incomplete');
+  });
+
+  it('throws if databaseName is missing', async () => {
+    const db = makeMockDB();
+    const config = makeConfig({ databaseName: '' });
+
+    await expect(
+      initializeSyncExtension(db as any, config, logger)
+    ).rejects.toThrow('Sync configuration incomplete');
+  });
+
+  it('throws if neither apiKey nor accessToken is provided', async () => {
+    const db = makeMockDB();
+    const config = makeConfig({ apiKey: undefined, accessToken: undefined });
+
+    await expect(
+      initializeSyncExtension(db as any, config, logger)
+    ).rejects.toThrow('Sync configuration incomplete');
+  });
+
+  it('uses getDylibPath for iOS extension path', async () => {
+    (Platform as any).OS = 'ios';
+    const db = makeMockDB();
+    const config = makeConfig();
+
+    await initializeSyncExtension(db as any, config, logger);
+
+    expect(getDylibPath).toHaveBeenCalledWith(
+      'ai.sqlite.cloudsync',
+      'CloudSync'
+    );
+    expect(db.loadExtension).toHaveBeenCalledWith('/mock/path/CloudSync');
+  });
+
+  it('uses "cloudsync" for Android extension path', async () => {
+    (Platform as any).OS = 'android';
+    const db = makeMockDB();
+    const config = makeConfig();
+
+    await initializeSyncExtension(db as any, config, logger);
+
+    expect(db.loadExtension).toHaveBeenCalledWith('cloudsync');
+  });
+
+  it('verifies extension via cloudsync_version()', async () => {
+    const db = makeMockDB();
+    const config = makeConfig();
+
+    await initializeSyncExtension(db as any, config, logger);
+
+    expect(db.execute).toHaveBeenCalledWith('SELECT cloudsync_version();');
+  });
+
+  it('throws if cloudsync_version returns empty result', async () => {
+    const db = makeMockDB({ rows: [{}] });
+    const config = makeConfig();
+
+    await expect(
+      initializeSyncExtension(db as any, config, logger)
+    ).rejects.toThrow('CloudSync extension not loaded properly');
+  });
+
+  it('calls cloudsync_init for each table', async () => {
+    const db = makeMockDB();
+    const config = makeConfig({
+      tablesToBeSynced: [
+        {
+          name: 'users',
+          createTableSql:
+            'CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY)',
+        },
+        {
+          name: 'posts',
+          createTableSql:
+            'CREATE TABLE IF NOT EXISTS posts (id TEXT PRIMARY KEY)',
+        },
+        {
+          name: 'comments',
+          createTableSql:
+            'CREATE TABLE IF NOT EXISTS comments (id TEXT PRIMARY KEY)',
+        },
+      ],
+    });
+
+    await initializeSyncExtension(db as any, config, logger);
+
+    expect(db.execute).toHaveBeenCalledWith('SELECT cloudsync_init(?);', [
+      'users',
+    ]);
+    expect(db.execute).toHaveBeenCalledWith('SELECT cloudsync_init(?);', [
+      'posts',
+    ]);
+    expect(db.execute).toHaveBeenCalledWith('SELECT cloudsync_init(?);', [
+      'comments',
+    ]);
+  });
+
+  it('calls cloudsync_network_init_custom with the library base URL', async () => {
+    const db = makeMockDB();
+    const config = makeConfig();
+
+    await initializeSyncExtension(db as any, config, logger);
+
+    expect(db.execute).toHaveBeenCalledWith(
+      'SELECT cloudsync_network_init_custom(?, ?);',
+      [CLOUDSYNC_BASE_URL, 'db_test_database_id']
+    );
+  });
+
+  it('sets API key when apiKey is provided', async () => {
+    const db = makeMockDB();
+    const config = makeConfig({ apiKey: 'my-api-key', accessToken: undefined });
+
+    await initializeSyncExtension(db as any, config, logger);
+
+    expect(db.execute).toHaveBeenCalledWith(
+      'SELECT cloudsync_network_set_apikey(?);',
+      ['my-api-key']
+    );
+  });
+
+  it('sets access token when accessToken is provided', async () => {
+    const db = makeMockDB();
+    const config = makeConfig({ apiKey: undefined, accessToken: 'my-token' });
+
+    await initializeSyncExtension(db as any, config, logger);
+
+    expect(db.execute).toHaveBeenCalledWith(
+      'SELECT cloudsync_network_set_token(?);',
+      ['my-token']
+    );
+    expect(db.execute).not.toHaveBeenCalledWith(
+      'SELECT cloudsync_network_set_apikey(?);',
+      expect.anything()
+    );
+  });
+
+  it('throws if cloudsync_version returns no rows', async () => {
+    const db = makeMockDB({ rows: [] });
+    const config = makeConfig();
+
+    await expect(
+      initializeSyncExtension(db as any, config, logger)
+    ).rejects.toThrow('CloudSync extension not loaded properly');
+  });
+
+  it('logs initialization for each table', async () => {
+    const logSpy = jest.spyOn(console, 'log').mockImplementation();
+    const debugLogger = createLogger(true);
+    const db = makeMockDB();
+    const config = makeConfig();
+
+    await initializeSyncExtension(db as any, config, debugLogger);
+
+    expect(logSpy).toHaveBeenCalledWith(
+      expect.any(String),
+      '[SQLiteSync]',
+      expect.stringContaining('CloudSync initialized for table: users')
+    );
+    logSpy.mockRestore();
+  });
+
+  it('does not depend on a return value from cloudsync_init', async () => {
+    const logSpy = jest.spyOn(console, 'log').mockImplementation();
+    const debugLogger = createLogger(true);
+    const db = createMockDB();
+    db.execute.mockImplementation(async (sql: string) => {
+      if (sql.includes('cloudsync_version'))
+        return { rows: [{ 'cloudsync_version()': '1.0.0' }] };
+      if (sql.includes('cloudsync_init')) return { rows: [{}] };
+      return { rows: [] };
+    });
+    const config = makeConfig();
+
+    await initializeSyncExtension(db as any, config, debugLogger);
+
+    expect(logSpy).toHaveBeenCalledWith(
+      expect.any(String),
+      '[SQLiteSync]',
+      expect.stringContaining('CloudSync initialized for table: users')
+    );
+    logSpy.mockRestore();
+  });
+
+  it('prefers apiKey over accessToken when both are provided', async () => {
+    const db = makeMockDB();
+    const config = makeConfig({
+      apiKey: 'my-api-key',
+      accessToken: 'my-token',
+    });
+
+    await initializeSyncExtension(db as any, config, logger);
+
+    expect(db.execute).toHaveBeenCalledWith(
+      'SELECT cloudsync_network_set_apikey(?);',
+      ['my-api-key']
+    );
+    expect(db.execute).not.toHaveBeenCalledWith(
+      'SELECT cloudsync_network_set_token(?);',
+      expect.anything()
+    );
+  });
+
+  it('throws when loadExtension fails', async () => {
+    const db = makeMockDB();
+    db.loadExtension.mockImplementation(() => {
+      throw new Error('load fail');
+    });
+
+    await expect(
+      initializeSyncExtension(db as any, makeConfig(), logger)
+    ).rejects.toThrow('load fail');
+  });
+
+  it('throws when network init fails', async () => {
+    const db = makeMockDB();
+    db.execute.mockImplementation(async (sql: string) => {
+      if (sql.includes('cloudsync_version')) {
+        return { rows: [{ 'cloudsync_version()': '1.0.0' }] };
+      }
+      if (sql.includes('cloudsync_network_init_custom')) {
+        throw new Error('network init failed');
+      }
+      return { rows: [{}] };
+    });
+
+    await expect(
+      initializeSyncExtension(db as any, makeConfig(), logger)
+    ).rejects.toThrow('network init failed');
+  });
+
+  it('throws when setting apiKey fails', async () => {
+    const db = makeMockDB();
+    db.execute.mockImplementation(async (sql: string) => {
+      if (sql.includes('cloudsync_version')) {
+        return { rows: [{ 'cloudsync_version()': '1.0.0' }] };
+      }
+      if (sql.includes('cloudsync_network_set_apikey')) {
+        throw new Error('apikey failed');
+      }
+      return { rows: [{}] };
+    });
+
+    await expect(
+      initializeSyncExtension(
+        db as any,
+        makeConfig({ apiKey: 'my-api-key', accessToken: undefined }),
+        logger
+      )
+    ).rejects.toThrow('apikey failed');
+  });
+
+  it('throws when setting accessToken fails', async () => {
+    const db = makeMockDB();
+    db.execute.mockImplementation(async (sql: string) => {
+      if (sql.includes('cloudsync_version')) {
+        return { rows: [{ 'cloudsync_version()': '1.0.0' }] };
+      }
+      if (sql.includes('cloudsync_network_set_token')) {
+        throw new Error('token failed');
+      }
+      return { rows: [{}] };
+    });
+
+    await expect(
+      initializeSyncExtension(
+        db as any,
+        makeConfig({ apiKey: undefined, accessToken: 'my-token' }),
+        logger
+      )
+    ).rejects.toThrow('token failed');
+  });
+});
diff --git a/src/core/sync/__tests__/useInitialSync.test.ts b/src/core/sync/__tests__/useInitialSync.test.ts
new file mode 100644
index 0000000..bc8ec22
--- /dev/null
+++ b/src/core/sync/__tests__/useInitialSync.test.ts
@@ -0,0 +1,70 @@
+import { renderHook } from '@testing-library/react-native';
+import { useInitialSync } from '../useInitialSync';
+import { createLogger } from '../../common/logger';
+
+jest.useFakeTimers();
+
+describe('useInitialSync', () => {
+  const logger = createLogger(false);
+
+  afterEach(() => {
+    jest.clearAllTimers();
+    jest.clearAllMocks();
+  });
+
+  it('triggers sync after 1500ms when ready', () => {
+    const performSync = jest.fn();
+    const performSyncRef = { current: performSync };
+
+    renderHook(() =>
+      useInitialSync({ isSyncReady: true, performSyncRef, logger })
+    );
+
+    expect(performSync).not.toHaveBeenCalled();
+    jest.advanceTimersByTime(1500);
+    expect(performSync).toHaveBeenCalledTimes(1);
+  });
+
+  it('does not trigger when not ready', () => {
+    const performSync = jest.fn();
+    const performSyncRef = { current: performSync };
+
+    renderHook(() =>
+      useInitialSync({ isSyncReady: false, performSyncRef, logger })
+    );
+
+    jest.advanceTimersByTime(5000);
+    expect(performSync).not.toHaveBeenCalled();
+  });
+
+  it('only triggers once', () => {
+    const performSync = jest.fn();
+    const performSyncRef = { current: performSync };
+
+    const { rerender } = renderHook(
+      (props: { ready: boolean }) =>
+        useInitialSync({ isSyncReady: props.ready, performSyncRef, logger }),
+      { initialProps: { ready: true } }
+    );
+
+    jest.advanceTimersByTime(1500);
+    expect(performSync).toHaveBeenCalledTimes(1);
+
+    rerender({ ready: true });
+    jest.advanceTimersByTime(1500);
+    expect(performSync).toHaveBeenCalledTimes(1);
+  });
+
+  it('clears timeout on unmount', () => {
+    const performSync = jest.fn();
+    const performSyncRef = { current: performSync };
+
+    const { unmount } = renderHook(() =>
+      useInitialSync({ isSyncReady: true, performSyncRef, logger })
+    );
+
+    unmount();
+    jest.advanceTimersByTime(2000);
+    expect(performSync).not.toHaveBeenCalled();
+  });
+});
diff --git a/src/core/sync/__tests__/useSyncManager.test.ts b/src/core/sync/__tests__/useSyncManager.test.ts
new file mode 100644
index 0000000..c78d850
--- /dev/null
+++ b/src/core/sync/__tests__/useSyncManager.test.ts
@@ -0,0 +1,312 @@
+jest.mock('react-native', () => ({
+  Platform: { OS: 'ios' },
+}));
+jest.mock('../executeSync');
+jest.mock('../../polling/calculateAdaptiveSyncInterval');
+jest.mock('@react-native-community/netinfo');
+
+import { renderHook, act } from '@testing-library/react-native';
+import { Platform } from 'react-native';
+import NetInfo from '@react-native-community/netinfo';
+import { useSyncManager } from '../useSyncManager';
+import { executeSync } from '../executeSync';
+import { calculateAdaptiveSyncInterval } from '../../polling/calculateAdaptiveSyncInterval';
+import { createLogger } from '../../common/logger';
+
+describe('useSyncManager', () => {
+  const logger = createLogger(false);
+
+  const createDefaultParams = (overrides?: Partial<any>) => ({
+    writeDbRef: { current: { execute: jest.fn(), transaction: jest.fn() } } as any,
+    isSyncReady: true,
+    logger,
+    adaptiveConfig: {
+      baseInterval: 5000,
+      maxInterval: 60000,
+      emptyThreshold: 5,
+      idleBackoffMultiplier: 1.5,
+      errorBackoffMultiplier: 2.0,
+    },
+    currentIntervalRef: { current: 5000 },
+    setCurrentInterval: jest.fn(),
+    syncMode: 'polling' as const,
+    ...overrides,
+  });
+
+  beforeEach(() => {
+    jest.spyOn(console, 'log').mockImplementation();
+    jest.spyOn(console, 'warn').mockImplementation();
+    jest.spyOn(console, 'error').mockImplementation();
+    jest.clearAllMocks();
+    (executeSync as jest.Mock).mockResolvedValue(0);
+    (calculateAdaptiveSyncInterval as jest.Mock).mockReturnValue(5000);
+    (Platform as any).OS = 'ios';
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('returns initial state', () => {
+    const { result } = renderHook(() =>
+      useSyncManager(createDefaultParams())
+    );
+
+    expect(result.current.isSyncing).toBe(false);
+    expect(result.current.lastSyncTime).toBeNull();
+    expect(result.current.lastSyncChanges).toBe(0);
+    expect(result.current.consecutiveEmptySyncs).toBe(0);
+    expect(result.current.consecutiveSyncErrors).toBe(0);
+    expect(result.current.syncError).toBeNull();
+    expect(typeof result.current.performSync).toBe('function');
+  });
+
+  it('skips sync when writeDb is null', async () => {
+    const { result } = renderHook(() =>
+      useSyncManager(createDefaultParams({ writeDbRef: { current: null } }))
+    );
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(executeSync).not.toHaveBeenCalled();
+  });
+
+  it('skips sync when not sync ready', async () => {
+    const { result } = renderHook(() =>
+      useSyncManager(createDefaultParams({ isSyncReady: false }))
+    );
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(executeSync).not.toHaveBeenCalled();
+  });
+
+  it('executes sync successfully with changes', async () => {
+    (executeSync as jest.Mock).mockResolvedValue(5);
+    (calculateAdaptiveSyncInterval as jest.Mock).mockReturnValue(5000);
+
+    const params = createDefaultParams();
+    const { result } = renderHook(() => useSyncManager(params));
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(executeSync).toHaveBeenCalledWith(
+      params.writeDbRef.current,
+      logger,
+      { useTransaction: true }
+    );
+    expect(result.current.lastSyncChanges).toBe(5);
+    expect(result.current.lastSyncTime).not.toBeNull();
+    expect(result.current.syncError).toBeNull();
+  });
+
+  it('increments consecutiveEmptySyncs on zero changes', async () => {
+    (executeSync as jest.Mock).mockResolvedValue(0);
+    const { result } = renderHook(() => useSyncManager(createDefaultParams()));
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(result.current.consecutiveEmptySyncs).toBe(1);
+  });
+
+  it('resets consecutiveEmptySyncs on changes', async () => {
+    (executeSync as jest.Mock).mockResolvedValueOnce(0).mockResolvedValueOnce(3);
+    const { result } = renderHook(() => useSyncManager(createDefaultParams()));
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+    expect(result.current.consecutiveEmptySyncs).toBe(1);
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+    expect(result.current.consecutiveEmptySyncs).toBe(0);
+  });
+
+  it('recalculates interval in polling mode', async () => {
+    (executeSync as jest.Mock).mockResolvedValue(0);
+    (calculateAdaptiveSyncInterval as jest.Mock).mockReturnValue(7500);
+
+    const params = createDefaultParams();
+    const { result } = renderHook(() => useSyncManager(params));
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(calculateAdaptiveSyncInterval).toHaveBeenCalled();
+    expect(params.currentIntervalRef.current).toBe(7500);
+    expect(params.setCurrentInterval).toHaveBeenCalledWith(7500);
+  });
+
+  it('does not recalculate interval in push mode', async () => {
+    (executeSync as jest.Mock).mockResolvedValue(0);
+    const params = createDefaultParams({ syncMode: 'push' });
+    const { result } = renderHook(() => useSyncManager(params));
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(calculateAdaptiveSyncInterval).not.toHaveBeenCalled();
+    expect(params.setCurrentInterval).not.toHaveBeenCalled();
+  });
+
+  it('handles sync error', async () => {
+    (executeSync as jest.Mock).mockRejectedValue(new Error('sync fail'));
+    const { result } = renderHook(() => useSyncManager(createDefaultParams()));
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(result.current.syncError?.message).toBe('sync fail');
+    expect(result.current.consecutiveSyncErrors).toBe(1);
+  });
+
+  it('resets sync error counters after a successful sync following a failure', async () => {
+    (executeSync as jest.Mock)
+      .mockRejectedValueOnce(new Error('sync fail'))
+      .mockResolvedValueOnce(2);
+
+    const { result } = renderHook(() => useSyncManager(createDefaultParams()));
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+    expect(result.current.consecutiveSyncErrors).toBe(1);
+    expect(result.current.syncError?.message).toBe('sync fail');
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(result.current.consecutiveSyncErrors).toBe(0);
+    expect(result.current.syncError).toBeNull();
+    expect(result.current.lastSyncChanges).toBe(2);
+  });
+
+  it('recalculates interval with error backoff in polling mode', async () => {
+    (executeSync as jest.Mock).mockRejectedValue(new Error('fail'));
+    (calculateAdaptiveSyncInterval as jest.Mock).mockReturnValue(10000);
+
+    const params = createDefaultParams();
+    const { result } = renderHook(() => useSyncManager(params));
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(calculateAdaptiveSyncInterval).toHaveBeenCalledWith(
+      expect.objectContaining({ consecutiveSyncErrors: 1 }),
+      params.adaptiveConfig
+    );
+    expect(params.currentIntervalRef.current).toBe(10000);
+  });
+
+  it('prevents concurrent syncs', async () => {
+    let resolveSync: () => void;
+    (executeSync as jest.Mock).mockImplementation(
+      () => new Promise<number>((resolve) => { resolveSync = () => resolve(0); })
+    );
+
+    const { result } = renderHook(() => useSyncManager(createDefaultParams()));
+
+    // Start first sync
+    act(() => {
+      result.current.performSync();
+    });
+
+    // Try second sync while first is in progress
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(executeSync).toHaveBeenCalledTimes(1);
+
+    // Complete first sync
+    await act(async () => {
+      resolveSync!();
+    });
+  });
+
+  it('allows sync on Android when isInternetReachable is null', async () => {
+    (Platform as any).OS = 'android';
+    (NetInfo.fetch as jest.Mock).mockResolvedValue({
+      isConnected: true,
+      isInternetReachable: null,
+    });
+    (executeSync as jest.Mock).mockResolvedValue(0);
+
+    const { result } = renderHook(() => useSyncManager(createDefaultParams()));
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(NetInfo.fetch).toHaveBeenCalled();
+    expect(executeSync).toHaveBeenCalled();
+  });
+
+  it('checks network on Android before syncing', async () => {
+    (Platform as any).OS = 'android';
+    (NetInfo.fetch as jest.Mock).mockResolvedValue({
+      isConnected: false,
+      isInternetReachable: false,
+    });
+
+    const { result } = renderHook(() => useSyncManager(createDefaultParams()));
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(NetInfo.fetch).toHaveBeenCalled();
+    expect(executeSync).not.toHaveBeenCalled();
+  });
+
+  it('skips network check on iOS', async () => {
+    (Platform as any).OS = 'ios';
+    (executeSync as jest.Mock).mockResolvedValue(0);
+
+    const { result } = renderHook(() => useSyncManager(createDefaultParams()));
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(NetInfo.fetch).not.toHaveBeenCalled();
+    expect(executeSync).toHaveBeenCalled();
+  });
+
+  it('does not recalculate interval on error in push mode', async () => {
+    (executeSync as jest.Mock).mockRejectedValue(new Error('fail'));
+    const params = createDefaultParams({ syncMode: 'push' });
+    const { result } = renderHook(() => useSyncManager(params));
+
+    await act(async () => {
+      await result.current.performSync();
+    });
+
+    expect(calculateAdaptiveSyncInterval).not.toHaveBeenCalled();
+    expect(params.setCurrentInterval).not.toHaveBeenCalled();
+    expect(result.current.syncError?.message).toBe('fail');
+  });
+
+  it('keeps performSyncRef updated', () => {
+    const { result } = renderHook(() => useSyncManager(createDefaultParams()));
+
+    expect(result.current.performSyncRef.current).toBe(
+      result.current.performSync
+    );
+  });
+});
diff --git a/src/core/sync/executeSync.ts b/src/core/sync/executeSync.ts
index 22aeb99..25736df 100644
--- a/src/core/sync/executeSync.ts
+++ b/src/core/sync/executeSync.ts
@@ -2,12 +2,29 @@ import type { DB, QueryResult } from '@op-engineering/op-sqlite';
 import type { Logger } from '../common/logger';
 
 /**
- * Extracts the number of changes from a CloudSync query result
+ * Extracts the number of received rows from a CloudSync query result.
+ *
+ * The result row contains a JSON string:
+ * {"send":{...},"receive":{"rows":N,"tables":["table1"]}}
+ *
+ * We only use receive.rows since polling is for downloading remote changes.
  */
 const extractChangesFromResult = (result: QueryResult | undefined): number => {
   const firstRow = result?.rows?.[0];
-  const value = firstRow ? Object.values(firstRow)[0] : 0;
-  return typeof value === 'number' ? value : 0;
+  if (!firstRow) return 0;
+
+  const raw = Object.values(firstRow)[0];
+
+  if (typeof raw === 'string') {
+    try {
+      const parsed = JSON.parse(raw);
+      return typeof parsed?.receive?.rows === 'number' ? parsed.receive.rows : 0;
+    } catch {
+      return 0;
+    }
+  }
+
+  return 0;
 };
 
 /**
@@ -63,12 +80,12 @@ export async function executeSync(
     );
 
     const result = await db.execute('SELECT cloudsync_network_sync(?, ?);', [
-      maxAttempts,
       attemptDelay,
+      maxAttempts,
     ]);
 
     changes = extractChangesFromResult(result);
-    logger.info(`🔄 Sync result: ${changes} changes`);
+    logger.info(`🔄 Sync result: ${changes} changes downloaded`);
   } else {
     /** JS RETRY MODE */
     // Retry/delay in JS thread - better for foreground (doesn't block write connection)
@@ -87,7 +104,9 @@ export async function executeSync(
       }
 
       changes = extractChangesFromResult(result);
-      logger.info(`🔄 Sync attempt ${attempt + 1} result: ${changes} changes`);
+      logger.info(
+        `🔄 Sync attempt ${attempt + 1} result: ${changes} changes downloaded`
+      );
 
       if (changes > 0) {
         break;
@@ -95,16 +114,16 @@ export async function executeSync(
 
       // Wait before next attempt (except on last attempt)
       if (attempt < maxAttempts - 1) {
-        await new Promise((resolve) => setTimeout(resolve, attemptDelay));
+        await new Promise<void>((resolve) => setTimeout(resolve, attemptDelay));
       }
     }
   }
 
   /** LOG RESULT */
   if (changes > 0) {
-    logger.info(`✅ Sync completed: ${changes} changes synced`);
+    logger.info(`✅ Sync completed: ${changes} changes downloaded`);
   } else {
-    logger.info('✅ Sync completed: no changes');
+    logger.info('✅ Sync completed: no changes downloaded');
   }
 
   return changes;
diff --git a/src/core/sync/initializeSyncExtension.ts b/src/core/sync/initializeSyncExtension.ts
index 4c131cc..e136543 100644
--- a/src/core/sync/initializeSyncExtension.ts
+++ b/src/core/sync/initializeSyncExtension.ts
@@ -2,12 +2,14 @@ import { Platform } from 'react-native';
 import { getDylibPath, type DB } from '@op-engineering/op-sqlite';
 import type { TableConfig } from '../../types/TableConfig';
 import type { Logger } from '../common/logger';
+import { CLOUDSYNC_BASE_URL } from '../constants';
 
 /**
  * Configuration for sync initialization
  */
 export interface SyncInitConfig {
-  connectionString: string;
+  databaseId: string;
+  databaseName: string;
   tablesToBeSynced: TableConfig[];
   apiKey?: string;
   accessToken?: string;
@@ -25,10 +27,14 @@ export async function initializeSyncExtension(
   config: SyncInitConfig,
   logger: Logger
 ): Promise<void> {
-  const { connectionString, tablesToBeSynced, apiKey, accessToken } = config;
+  const databaseId = config.databaseId.trim();
+  const databaseName = config.databaseName.trim();
+  const apiKey = config.apiKey?.trim();
+  const accessToken = config.accessToken?.trim();
+  const { tablesToBeSynced } = config;
 
   /** VALIDATE CONFIG */
-  if (!connectionString || (!apiKey && !accessToken)) {
+  if (!databaseId || !databaseName || (!apiKey && !accessToken)) {
     throw new Error('Sync configuration incomplete');
   }
 
@@ -54,21 +60,16 @@ export async function initializeSyncExtension(
 
   /** INITIALIZE TABLES */
   for (const table of tablesToBeSynced) {
-    const initResult = await db.execute('SELECT cloudsync_init(?);', [
-      table.name,
-    ]);
-    const firstRow = initResult.rows?.[0];
-    const result = firstRow ? Object.values(firstRow)[0] : undefined;
+    await db.execute('SELECT cloudsync_init(?);', [table.name]);
 
-    logger.info(
-      `✅ CloudSync initialized for table: ${table.name}${
-        result ? ` (site_id: ${result})` : ''
-      }`
-    );
+    logger.info(`✅ CloudSync initialized for table: ${table.name}`);
   }
 
   /** INITIALIZE NETWORK */
-  await db.execute('SELECT cloudsync_network_init(?);', [connectionString]);
+  await db.execute('SELECT cloudsync_network_init_custom(?, ?);', [
+    CLOUDSYNC_BASE_URL,
+    databaseId,
+  ]);
   logger.info('✅ Network initialized');
 
   /** SET AUTHENTICATION */
diff --git a/src/hooks/context/__tests__/useSqliteDb.test.ts b/src/hooks/context/__tests__/useSqliteDb.test.ts
new file mode 100644
index 0000000..304bb70
--- /dev/null
+++ b/src/hooks/context/__tests__/useSqliteDb.test.ts
@@ -0,0 +1,27 @@
+import { renderHook } from '@testing-library/react-native';
+import { useSqliteDb } from '../useSqliteDb';
+import { createTestWrapper, createMockDB } from '../../../testUtils';
+
+describe('useSqliteDb', () => {
+  it('returns writeDb, readDb, initError from context', () => {
+    const mockDb = createMockDB();
+    const wrapper = createTestWrapper({
+      db: { writeDb: mockDb as any, readDb: mockDb as any, initError: null },
+    });
+
+    const { result } = renderHook(() => useSqliteDb(), { wrapper });
+
+    expect(result.current.writeDb).toBe(mockDb);
+    expect(result.current.readDb).toBe(mockDb);
+    expect(result.current.initError).toBeNull();
+  });
+
+  it('returns null values by default', () => {
+    const wrapper = createTestWrapper();
+    const { result } = renderHook(() => useSqliteDb(), { wrapper });
+
+    expect(result.current.writeDb).toBeNull();
+    expect(result.current.readDb).toBeNull();
+    expect(result.current.initError).toBeNull();
+  });
+});
diff --git a/src/hooks/context/__tests__/useSqliteSync.test.ts b/src/hooks/context/__tests__/useSqliteSync.test.ts
new file mode 100644
index 0000000..00f4915
--- /dev/null
+++ b/src/hooks/context/__tests__/useSqliteSync.test.ts
@@ -0,0 +1,31 @@
+import { renderHook } from '@testing-library/react-native';
+import { useSqliteSync } from '../useSqliteSync';
+import { createTestWrapper, createMockDB } from '../../../testUtils';
+
+describe('useSqliteSync', () => {
+  it('returns merged contexts', () => {
+    const mockDb = createMockDB();
+    const triggerSync = jest.fn().mockResolvedValue(undefined);
+    const wrapper = createTestWrapper({
+      db: { writeDb: mockDb as any },
+      status: { isSyncing: true },
+      actions: { triggerSync },
+    });
+
+    const { result } = renderHook(() => useSqliteSync(), { wrapper });
+
+    expect(result.current.writeDb).toBe(mockDb);
+    expect(result.current.isSyncing).toBe(true);
+    expect(result.current.triggerSync).toBe(triggerSync);
+  });
+
+  it('triggerSync is callable', async () => {
+    const triggerSync = jest.fn().mockResolvedValue(undefined);
+    const wrapper = createTestWrapper({ actions: { triggerSync } });
+
+    const { result } = renderHook(() => useSqliteSync(), { wrapper });
+
+    await result.current.triggerSync();
+    expect(triggerSync).toHaveBeenCalled();
+  });
+});
diff --git a/src/hooks/context/__tests__/useSyncStatus.test.ts b/src/hooks/context/__tests__/useSyncStatus.test.ts
new file mode 100644
index 0000000..c587f13
--- /dev/null
+++ b/src/hooks/context/__tests__/useSyncStatus.test.ts
@@ -0,0 +1,31 @@
+import { renderHook } from '@testing-library/react-native';
+import { useSyncStatus } from '../useSyncStatus';
+import { createTestWrapper } from '../../../testUtils';
+
+describe('useSyncStatus', () => {
+  it('returns all status fields from context', () => {
+    const wrapper = createTestWrapper({
+      status: {
+        isSyncing: true,
+        lastSyncTime: 12345,
+        syncError: new Error('test'),
+      },
+    });
+
+    const { result } = renderHook(() => useSyncStatus(), { wrapper });
+
+    expect(result.current.isSyncing).toBe(true);
+    expect(result.current.lastSyncTime).toBe(12345);
+    expect(result.current.syncError).toBeInstanceOf(Error);
+  });
+
+  it('returns default values', () => {
+    const wrapper = createTestWrapper();
+    const { result } = renderHook(() => useSyncStatus(), { wrapper });
+
+    expect(result.current.isSyncing).toBe(false);
+    expect(result.current.lastSyncTime).toBeNull();
+    expect(result.current.syncError).toBeNull();
+    expect(result.current.syncMode).toBe('polling');
+  });
+});
diff --git a/src/hooks/sqlite/__tests__/useOnTableUpdate.test.ts b/src/hooks/sqlite/__tests__/useOnTableUpdate.test.ts
new file mode 100644
index 0000000..4a4f138
--- /dev/null
+++ b/src/hooks/sqlite/__tests__/useOnTableUpdate.test.ts
@@ -0,0 +1,206 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { useOnTableUpdate } from '../useOnTableUpdate';
+import { createTestWrapper, createMockDB } from '../../../testUtils';
+
+describe('useOnTableUpdate', () => {
+  beforeEach(() => {
+    jest.spyOn(console, 'log').mockImplementation();
+    jest.spyOn(console, 'warn').mockImplementation();
+    jest.spyOn(console, 'error').mockImplementation();
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('registers updateHook on writeDb', () => {
+    const mockDb = createMockDB();
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+
+    renderHook(
+      () => useOnTableUpdate({ tables: ['users'], onUpdate: jest.fn() }),
+      { wrapper }
+    );
+
+    expect(mockDb.updateHook).toHaveBeenCalledWith(expect.any(Function));
+  });
+
+  it('removes updateHook on unmount', () => {
+    const mockDb = createMockDB();
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+
+    const { unmount } = renderHook(
+      () => useOnTableUpdate({ tables: ['users'], onUpdate: jest.fn() }),
+      { wrapper }
+    );
+
+    unmount();
+    expect(mockDb.updateHook).toHaveBeenCalledWith(null);
+  });
+
+  it('uses updated table config after rerender without re-registering the hook', async () => {
+    const mockDb = createMockDB();
+    let hookHandler: any;
+    (mockDb.updateHook as jest.Mock).mockImplementation((fn: any) => {
+      if (typeof fn === 'function') hookHandler = fn;
+    });
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const onUpdate = jest.fn();
+
+    const { rerender } = renderHook(
+      ({ tables }: { tables: string[] }) =>
+        useOnTableUpdate({ tables, onUpdate }),
+      { wrapper, initialProps: { tables: ['users'] } }
+    );
+
+    rerender({ tables: ['orders'] });
+
+    await act(async () => {
+      await hookHandler({ operation: 'INSERT', table: 'users', rowId: 1 });
+      await hookHandler({ operation: 'INSERT', table: 'orders', rowId: 2 });
+    });
+
+    expect(mockDb.updateHook).toHaveBeenCalledTimes(1);
+    expect(onUpdate).toHaveBeenCalledTimes(1);
+    expect(onUpdate).toHaveBeenCalledWith(
+      expect.objectContaining({ table: 'orders', rowId: 2 })
+    );
+  });
+
+  it('calls callback for watched table', async () => {
+    const mockDb = createMockDB();
+    let hookHandler: any;
+    (mockDb.updateHook as jest.Mock).mockImplementation((fn: any) => {
+      if (typeof fn === 'function') hookHandler = fn;
+    });
+    (mockDb.execute as jest.Mock).mockResolvedValue({
+      rows: [{ id: '1', name: 'Alice' }],
+    });
+
+    const onUpdate = jest.fn();
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+
+    renderHook(
+      () => useOnTableUpdate({ tables: ['users'], onUpdate }),
+      { wrapper }
+    );
+
+    await act(async () => {
+      await hookHandler({ operation: 'INSERT', table: 'users', rowId: 1 });
+    });
+
+    expect(onUpdate).toHaveBeenCalledWith({
+      table: 'users',
+      operation: 'INSERT',
+      rowId: 1,
+      row: { id: '1', name: 'Alice' },
+    });
+  });
+
+  it('ignores unwatched table', async () => {
+    const mockDb = createMockDB();
+    let hookHandler: any;
+    (mockDb.updateHook as jest.Mock).mockImplementation((fn: any) => {
+      if (typeof fn === 'function') hookHandler = fn;
+    });
+
+    const onUpdate = jest.fn();
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+
+    renderHook(
+      () => useOnTableUpdate({ tables: ['users'], onUpdate }),
+      { wrapper }
+    );
+
+    await act(async () => {
+      await hookHandler({ operation: 'INSERT', table: 'orders', rowId: 1 });
+    });
+
+    expect(onUpdate).not.toHaveBeenCalled();
+  });
+
+  it('provides null row for DELETE', async () => {
+    const mockDb = createMockDB();
+    let hookHandler: any;
+    (mockDb.updateHook as jest.Mock).mockImplementation((fn: any) => {
+      if (typeof fn === 'function') hookHandler = fn;
+    });
+
+    const onUpdate = jest.fn();
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+
+    renderHook(
+      () => useOnTableUpdate({ tables: ['users'], onUpdate }),
+      { wrapper }
+    );
+
+    await act(async () => {
+      await hookHandler({ operation: 'DELETE', table: 'users', rowId: 1 });
+    });
+
+    expect(onUpdate).toHaveBeenCalledWith(
+      expect.objectContaining({ row: null, operation: 'DELETE' })
+    );
+  });
+
+  it('handles fetch error gracefully', async () => {
+    const mockDb = createMockDB();
+    let hookHandler: any;
+    (mockDb.updateHook as jest.Mock).mockImplementation((fn: any) => {
+      if (typeof fn === 'function') hookHandler = fn;
+    });
+    (mockDb.execute as jest.Mock).mockRejectedValue(new Error('fetch fail'));
+
+    const onUpdate = jest.fn();
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+
+    renderHook(
+      () => useOnTableUpdate({ tables: ['users'], onUpdate }),
+      { wrapper }
+    );
+
+    await act(async () => {
+      await hookHandler({ operation: 'INSERT', table: 'users', rowId: 1 });
+    });
+
+    expect(onUpdate).toHaveBeenCalledWith(
+      expect.objectContaining({ row: null })
+    );
+  });
+
+  it('provides null row when query returns empty rows', async () => {
+    const mockDb = createMockDB();
+    let hookHandler: any;
+    (mockDb.updateHook as jest.Mock).mockImplementation((fn: any) => {
+      if (typeof fn === 'function') hookHandler = fn;
+    });
+    (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+
+    const onUpdate = jest.fn();
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+
+    renderHook(
+      () => useOnTableUpdate({ tables: ['users'], onUpdate }),
+      { wrapper }
+    );
+
+    await act(async () => {
+      await hookHandler({ operation: 'UPDATE', table: 'users', rowId: 99 });
+    });
+
+    expect(onUpdate).toHaveBeenCalledWith(
+      expect.objectContaining({ row: null, operation: 'UPDATE' })
+    );
+  });
+
+  it('no-ops when writeDb is null', () => {
+    const wrapper = createTestWrapper();
+
+    renderHook(
+      () => useOnTableUpdate({ tables: ['users'], onUpdate: jest.fn() }),
+      { wrapper }
+    );
+
+    // No crash, no updateHook call
+  });
+});
diff --git a/src/hooks/sqlite/__tests__/useSqliteExecute.test.ts b/src/hooks/sqlite/__tests__/useSqliteExecute.test.ts
new file mode 100644
index 0000000..5d087c2
--- /dev/null
+++ b/src/hooks/sqlite/__tests__/useSqliteExecute.test.ts
@@ -0,0 +1,207 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { useSqliteExecute } from '../useSqliteExecute';
+import { createTestWrapper, createMockDB } from '../../../testUtils';
+
+describe('useSqliteExecute', () => {
+  beforeEach(() => {
+    jest.spyOn(console, 'log').mockImplementation();
+    jest.spyOn(console, 'warn').mockImplementation();
+    jest.spyOn(console, 'error').mockImplementation();
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('returns undefined when no db', async () => {
+    const wrapper = createTestWrapper();
+    const { result } = renderHook(() => useSqliteExecute(), { wrapper });
+
+    let res: any;
+    await act(async () => {
+      res = await result.current.execute('SELECT 1');
+    });
+    expect(res).toBeUndefined();
+  });
+
+  it('executes on writeDb by default', async () => {
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [{ id: 1 }] });
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteExecute(), { wrapper });
+
+    let res: any;
+    await act(async () => {
+      res = await result.current.execute('SELECT 1');
+    });
+    expect(mockDb.execute).toHaveBeenCalledWith('SELECT 1', []);
+    expect(res).toEqual({ rows: [{ id: 1 }] });
+  });
+
+  it('executes on readDb when readOnly', async () => {
+    const writeDb = createMockDB();
+    const readDb = createMockDB();
+    (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+    const wrapper = createTestWrapper({
+      db: { writeDb: writeDb as any, readDb: readDb as any },
+    });
+    const { result } = renderHook(() => useSqliteExecute(), { wrapper });
+
+    await act(async () => {
+      await result.current.execute('SELECT 1', [], { readOnly: true });
+    });
+    expect(readDb.execute).toHaveBeenCalled();
+    expect(writeDb.execute).not.toHaveBeenCalled();
+  });
+
+  it('returns undefined for readOnly query when readDb is missing', async () => {
+    const writeDb = createMockDB();
+    const wrapper = createTestWrapper({
+      db: { writeDb: writeDb as any, readDb: null },
+    });
+    const { result } = renderHook(() => useSqliteExecute(), { wrapper });
+
+    let res: any;
+    await act(async () => {
+      res = await result.current.execute('SELECT 1', [], { readOnly: true });
+    });
+
+    expect(res).toBeUndefined();
+    expect(writeDb.execute).not.toHaveBeenCalled();
+  });
+
+  it('sets error on failure and throws', async () => {
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock).mockRejectedValue(new Error('exec fail'));
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteExecute(), { wrapper });
+
+    await act(async () => {
+      await expect(result.current.execute('BAD SQL')).rejects.toThrow('exec fail');
+    });
+    expect(result.current.error?.message).toBe('exec fail');
+  });
+
+  it('clears error on next successful execute', async () => {
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock)
+      .mockRejectedValueOnce(new Error('fail'))
+      .mockResolvedValue({ rows: [] });
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteExecute(), { wrapper });
+
+    await act(async () => {
+      try {
+        await result.current.execute('BAD');
+      } catch {}
+    });
+    expect(result.current.error).not.toBeNull();
+
+    await act(async () => {
+      await result.current.execute('GOOD');
+    });
+    expect(result.current.error).toBeNull();
+  });
+
+  it('auto-syncs after write', async () => {
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteExecute(), { wrapper });
+
+    await act(async () => {
+      await result.current.execute('INSERT INTO t VALUES (1)');
+    });
+    expect(mockDb.execute).toHaveBeenCalledWith(
+      'SELECT cloudsync_network_send_changes();'
+    );
+  });
+
+  it('skips auto-sync on readOnly', async () => {
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+    const wrapper = createTestWrapper({
+      db: { writeDb: mockDb as any, readDb: mockDb as any },
+    });
+    const { result } = renderHook(() => useSqliteExecute(), { wrapper });
+
+    await act(async () => {
+      await result.current.execute('SELECT 1', [], { readOnly: true });
+    });
+    const calls = (mockDb.execute as jest.Mock).mock.calls.map((c: any) => c[0]);
+    expect(calls).not.toContain('SELECT cloudsync_network_send_changes();');
+  });
+
+  it('skips auto-sync when autoSync=false', async () => {
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteExecute(), { wrapper });
+
+    await act(async () => {
+      await result.current.execute('INSERT INTO t VALUES (1)', [], {
+        autoSync: false,
+      });
+    });
+    const calls = (mockDb.execute as jest.Mock).mock.calls.map((c: any) => c[0]);
+    expect(calls).not.toContain('SELECT cloudsync_network_send_changes();');
+  });
+
+  it('auto-sync failure is non-fatal', async () => {
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock)
+      .mockResolvedValueOnce({ rows: [] }) // main query
+      .mockRejectedValueOnce(new Error('sync fail')); // auto-sync
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteExecute(), { wrapper });
+
+    let res: any;
+    await act(async () => {
+      res = await result.current.execute('INSERT INTO t VALUES (1)');
+    });
+    expect(res).toEqual({ rows: [] });
+  });
+
+  it('wraps non-Error thrown value', async () => {
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock).mockRejectedValue('raw string error');
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteExecute(), { wrapper });
+
+    await act(async () => {
+      await expect(result.current.execute('BAD')).rejects.toThrow(
+        'Execution failed'
+      );
+    });
+    expect(result.current.error?.message).toBe('Execution failed');
+  });
+
+  it('sets isExecuting while execute is in flight', async () => {
+    let resolveExecute: ((value: any) => void) | undefined;
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock).mockImplementation(
+      () =>
+        new Promise((resolve) => {
+          resolveExecute = resolve;
+        })
+    );
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteExecute(), { wrapper });
+
+    let pendingPromise: Promise<any> | undefined;
+    act(() => {
+      pendingPromise = result.current.execute('SELECT 1', [], {
+        autoSync: false,
+      });
+    });
+
+    expect(result.current.isExecuting).toBe(true);
+
+    await act(async () => {
+      resolveExecute?.({ rows: [] });
+      await pendingPromise;
+    });
+
+    expect(result.current.isExecuting).toBe(false);
+  });
+});
diff --git a/src/hooks/sqlite/__tests__/useSqliteTransaction.test.ts b/src/hooks/sqlite/__tests__/useSqliteTransaction.test.ts
new file mode 100644
index 0000000..02e406b
--- /dev/null
+++ b/src/hooks/sqlite/__tests__/useSqliteTransaction.test.ts
@@ -0,0 +1,175 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { useSqliteTransaction } from '../useSqliteTransaction';
+import { createTestWrapper, createMockDB } from '../../../testUtils';
+
+describe('useSqliteTransaction', () => {
+  beforeEach(() => {
+    jest.spyOn(console, 'log').mockImplementation();
+    jest.spyOn(console, 'warn').mockImplementation();
+    jest.spyOn(console, 'error').mockImplementation();
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('returns undefined when no writeDb', async () => {
+    const wrapper = createTestWrapper();
+    const { result } = renderHook(() => useSqliteTransaction(), { wrapper });
+
+    let res: any;
+    await act(async () => {
+      res = await result.current.executeTransaction(async () => {});
+    });
+    expect(res).toBeUndefined();
+  });
+
+  it('calls writeDb.transaction', async () => {
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteTransaction(), { wrapper });
+
+    const fn = jest.fn();
+    await act(async () => {
+      await result.current.executeTransaction(fn);
+    });
+    expect(mockDb.transaction).toHaveBeenCalled();
+  });
+
+  it('sets error on failure and throws', async () => {
+    const mockDb = createMockDB();
+    (mockDb.transaction as jest.Mock).mockRejectedValue(new Error('tx fail'));
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteTransaction(), { wrapper });
+
+    await act(async () => {
+      await expect(
+        result.current.executeTransaction(async () => {})
+      ).rejects.toThrow('tx fail');
+    });
+    expect(result.current.error?.message).toBe('tx fail');
+  });
+
+  it('clears error on next success', async () => {
+    const mockDb = createMockDB();
+    (mockDb.transaction as jest.Mock)
+      .mockRejectedValueOnce(new Error('fail'))
+      .mockResolvedValue(undefined);
+    (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteTransaction(), { wrapper });
+
+    await act(async () => {
+      try {
+        await result.current.executeTransaction(async () => {});
+      } catch {}
+    });
+    expect(result.current.error).not.toBeNull();
+
+    await act(async () => {
+      await result.current.executeTransaction(async () => {});
+    });
+    expect(result.current.error).toBeNull();
+  });
+
+  it('auto-syncs after commit', async () => {
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteTransaction(), { wrapper });
+
+    await act(async () => {
+      await result.current.executeTransaction(async () => {});
+    });
+    expect(mockDb.execute).toHaveBeenCalledWith(
+      'SELECT cloudsync_network_send_changes();'
+    );
+  });
+
+  it('does not auto-sync when transaction fails', async () => {
+    const mockDb = createMockDB();
+    (mockDb.transaction as jest.Mock).mockRejectedValue(new Error('tx fail'));
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteTransaction(), { wrapper });
+
+    await act(async () => {
+      await expect(
+        result.current.executeTransaction(async () => {})
+      ).rejects.toThrow('tx fail');
+    });
+
+    expect(mockDb.execute).not.toHaveBeenCalledWith(
+      'SELECT cloudsync_network_send_changes();'
+    );
+  });
+
+  it('skips auto-sync when autoSync=false', async () => {
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteTransaction(), { wrapper });
+
+    await act(async () => {
+      await result.current.executeTransaction(async () => {}, {
+        autoSync: false,
+      });
+    });
+    expect(mockDb.execute).not.toHaveBeenCalled();
+  });
+
+  it('auto-sync failure is non-fatal', async () => {
+    const mockDb = createMockDB();
+    (mockDb.execute as jest.Mock).mockRejectedValue(new Error('sync fail'));
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteTransaction(), { wrapper });
+
+    await act(async () => {
+      await result.current.executeTransaction(async () => {});
+    });
+    // Should not throw — sync failure is caught internally
+    expect(result.current.error).toBeNull();
+  });
+
+  it('wraps non-Error thrown value', async () => {
+    const mockDb = createMockDB();
+    (mockDb.transaction as jest.Mock).mockRejectedValue('raw string error');
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteTransaction(), { wrapper });
+
+    await act(async () => {
+      await expect(
+        result.current.executeTransaction(async () => {})
+      ).rejects.toThrow('Transaction failed');
+    });
+    expect(result.current.error?.message).toBe('Transaction failed');
+  });
+
+  it('sets isExecuting while transaction is in flight', async () => {
+    let resolveTransaction: (() => void) | undefined;
+    const mockDb = createMockDB();
+    (mockDb.transaction as jest.Mock).mockImplementation(
+      () =>
+        new Promise<void>((resolve) => {
+          resolveTransaction = resolve;
+        })
+    );
+    (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+    const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } });
+    const { result } = renderHook(() => useSqliteTransaction(), { wrapper });
+
+    let pendingPromise: Promise<void> | undefined;
+    act(() => {
+      pendingPromise = result.current.executeTransaction(async () => {});
+    });
+
+    expect(result.current.isExecuting).toBe(true);
+
+    await act(async () => {
+      resolveTransaction?.();
+      await pendingPromise;
+    });
+
+    expect(result.current.isExecuting).toBe(false);
+  });
+});
diff --git a/src/hooks/sync/__tests__/useSqliteSyncQuery.test.ts b/src/hooks/sync/__tests__/useSqliteSyncQuery.test.ts
new file mode 100644
index 0000000..777dd19
--- /dev/null
+++ b/src/hooks/sync/__tests__/useSqliteSyncQuery.test.ts
@@ -0,0 +1,404 @@
+jest.useFakeTimers();
+
+import { renderHook, act } from '@testing-library/react-native';
+import { useSqliteSyncQuery } from '../useSqliteSyncQuery';
+import { createTestWrapper, createMockDB } from '../../../testUtils';
+
+describe('useSqliteSyncQuery', () => {
+  beforeEach(() => {
+    jest.spyOn(console, 'log').mockImplementation();
+    jest.spyOn(console, 'warn').mockImplementation();
+    jest.spyOn(console, 'error').mockImplementation();
+  });
+
+  afterEach(() => {
+    jest.clearAllTimers();
+    jest.restoreAllMocks();
+  });
+
+  const defaultConfig = {
+    query: 'SELECT * FROM users',
+    arguments: [],
+    fireOn: [{ table: 'users' }],
+  };
+
+  it('returns loading state initially', () => {
+    const readDb = createMockDB();
+    (readDb.execute as jest.Mock).mockReturnValue(new Promise(() => {})); // never resolves
+    const wrapper = createTestWrapper({
+      db: { readDb: readDb as any, writeDb: createMockDB() as any },
+    });
+
+    const { result } = renderHook(
+      () => useSqliteSyncQuery(defaultConfig),
+      { wrapper }
+    );
+
+    expect(result.current.isLoading).toBe(true);
+    expect(result.current.data).toEqual([]);
+    expect(result.current.error).toBeNull();
+  });
+
+  it('fetches data on mount using readDb', async () => {
+    const readDb = createMockDB();
+    const writeDb = createMockDB();
+    (readDb.execute as jest.Mock).mockResolvedValue({
+      rows: [{ id: 1, name: 'Alice' }],
+    });
+
+    const wrapper = createTestWrapper({
+      db: { readDb: readDb as any, writeDb: writeDb as any },
+    });
+
+    const { result } = renderHook(
+      () => useSqliteSyncQuery(defaultConfig),
+      { wrapper }
+    );
+
+    await act(async () => {});
+
+    expect(readDb.execute).toHaveBeenCalledWith('SELECT * FROM users', []);
+    expect(result.current.data).toEqual([{ id: 1, name: 'Alice' }]);
+    expect(result.current.isLoading).toBe(false);
+  });
+
+  it('sets error on read failure', async () => {
+    const readDb = createMockDB();
+    (readDb.execute as jest.Mock).mockRejectedValue(new Error('read fail'));
+
+    const wrapper = createTestWrapper({
+      db: { readDb: readDb as any, writeDb: createMockDB() as any },
+    });
+
+    const { result } = renderHook(
+      () => useSqliteSyncQuery(defaultConfig),
+      { wrapper }
+    );
+
+    await act(async () => {});
+
+    expect(result.current.error?.message).toBe('read fail');
+    expect(result.current.isLoading).toBe(false);
+  });
+
+  it('sets up reactive subscription after debounce', async () => {
+    const readDb = createMockDB();
+    const writeDb = createMockDB();
+    (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+
+    const wrapper = createTestWrapper({
+      db: { readDb: readDb as any, writeDb: writeDb as any },
+    });
+
+    renderHook(
+      () => useSqliteSyncQuery(defaultConfig),
+      { wrapper }
+    );
+
+    await act(async () => {});
+
+    // Before debounce, no reactive subscription
+    expect(writeDb.reactiveExecute).not.toHaveBeenCalled();
+
+    // After debounce (1000ms)
+    await act(async () => {
+      jest.advanceTimersByTime(1000);
+    });
+
+    expect(writeDb.reactiveExecute).toHaveBeenCalledWith(
+      expect.objectContaining({
+        query: 'SELECT * FROM users',
+        arguments: [],
+        fireOn: [{ table: 'users' }],
+        callback: expect.any(Function),
+      })
+    );
+  });
+
+  it('updates data when reactive callback fires', async () => {
+    const readDb = createMockDB();
+    const writeDb = createMockDB();
+    (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+
+    let reactiveCallback: any;
+    (writeDb.reactiveExecute as jest.Mock).mockImplementation((config: any) => {
+      reactiveCallback = config.callback;
+      return jest.fn();
+    });
+
+    const wrapper = createTestWrapper({
+      db: { readDb: readDb as any, writeDb: writeDb as any },
+    });
+
+    const { result } = renderHook(
+      () => useSqliteSyncQuery(defaultConfig),
+      { wrapper }
+    );
+
+    await act(async () => {});
+
+    // Trigger debounce to set up subscription
+    await act(async () => {
+      jest.advanceTimersByTime(1000);
+    });
+
+    // Simulate reactive update
+    await act(async () => {
+      reactiveCallback({ rows: [{ id: 2, name: 'Bob' }] });
+    });
+
+    expect(result.current.data).toEqual([{ id: 2, name: 'Bob' }]);
+  });
+
+  it('unsubscribes reactive query on unmount', async () => {
+    const readDb = createMockDB();
+    const writeDb = createMockDB();
+    (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+    const unsubscribe = jest.fn();
+    (writeDb.reactiveExecute as jest.Mock).mockReturnValue(unsubscribe);
+
+    const wrapper = createTestWrapper({
+      db: { readDb: readDb as any, writeDb: writeDb as any },
+    });
+
+    const { unmount } = renderHook(
+      () => useSqliteSyncQuery(defaultConfig),
+      { wrapper }
+    );
+
+    await act(async () => {});
+
+    // Trigger debounce
+    await act(async () => {
+      jest.advanceTimersByTime(1000);
+    });
+
+    unmount();
+
+    // unsubscribe is called via setTimeout(fn, 0), advance to flush
+    await act(async () => {
+      jest.advanceTimersByTime(0);
+    });
+
+    expect(unsubscribe).toHaveBeenCalled();
+  });
+
+  it('no-ops when readDb is null', async () => {
+    const wrapper = createTestWrapper();
+
+    const { result } = renderHook(
+      () => useSqliteSyncQuery(defaultConfig),
+      { wrapper }
+    );
+
+    await act(async () => {});
+
+    expect(result.current.isLoading).toBe(true);
+    expect(result.current.data).toEqual([]);
+  });
+
+  it('clears debounce timer on query change', async () => {
+    const readDb = createMockDB();
+    const writeDb = createMockDB();
+    (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+
+    const wrapper = createTestWrapper({
+      db: { readDb: readDb as any, writeDb: writeDb as any },
+    });
+
+    const { rerender } = renderHook(
+      ({ query }: { query: string }) =>
+        useSqliteSyncQuery({
+          query,
+          arguments: [],
+          fireOn: [{ table: 'users' }],
+        }),
+      { wrapper, initialProps: { query: 'SELECT * FROM users' } }
+    );
+
+    await act(async () => {});
+
+    // Change query before debounce fires
+    rerender({ query: 'SELECT * FROM users WHERE id = 1' });
+
+    await act(async () => {
+      jest.advanceTimersByTime(500);
+    });
+
+    // Old timer should be cleared — no subscription yet
+    expect(writeDb.reactiveExecute).not.toHaveBeenCalled();
+
+    // After full debounce from rerender
+    await act(async () => {
+      jest.advanceTimersByTime(500);
+    });
+
+    expect(writeDb.reactiveExecute).toHaveBeenCalledWith(
+      expect.objectContaining({
+        query: 'SELECT * FROM users WHERE id = 1',
+      })
+    );
+  });
+
+  it('skips stale subscription when signature changed during debounce', async () => {
+    const readDb = createMockDB();
+    const writeDb = createMockDB();
+    (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+
+    const wrapper = createTestWrapper({
+      db: { readDb: readDb as any, writeDb: writeDb as any },
+    });
+
+    const { rerender } = renderHook(
+      ({ query }: { query: string }) =>
+        useSqliteSyncQuery({
+          query,
+          arguments: [],
+          fireOn: [{ table: 'users' }],
+        }),
+      { wrapper, initialProps: { query: 'SELECT * FROM users' } }
+    );
+
+    await act(async () => {});
+
+    // Let debounce almost fire for original query
+    await act(async () => {
+      jest.advanceTimersByTime(900);
+    });
+
+    // Change query — old debounce fires but signature is stale
+    rerender({ query: 'SELECT * FROM users WHERE active = 1' });
+
+    await act(async () => {});
+
+    // Old debounce fires at 1000ms
+    await act(async () => {
+      jest.advanceTimersByTime(100);
+    });
+
+    // The stale subscription should be skipped — only new query should subscribe
+    // New debounce fires at 1900ms total
+    await act(async () => {
+      jest.advanceTimersByTime(900);
+    });
+
+    // Should only have the new query subscription
+    const calls = (writeDb.reactiveExecute as jest.Mock).mock.calls;
+    const queries = calls.map((c: any) => c[0].query);
+    expect(queries).toContain('SELECT * FROM users WHERE active = 1');
+  });
+
+  it('provides unsubscribe function', async () => {
+    const readDb = createMockDB();
+    const writeDb = createMockDB();
+    (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+    const unsub = jest.fn();
+    (writeDb.reactiveExecute as jest.Mock).mockReturnValue(unsub);
+
+    const wrapper = createTestWrapper({
+      db: { readDb: readDb as any, writeDb: writeDb as any },
+    });
+
+    const { result } = renderHook(
+      () => useSqliteSyncQuery(defaultConfig),
+      { wrapper }
+    );
+
+    await act(async () => {});
+    await act(async () => {
+      jest.advanceTimersByTime(1000);
+    });
+
+    // Manual unsubscribe
+    result.current.unsubscribe();
+
+    await act(async () => {
+      jest.advanceTimersByTime(0);
+    });
+
+    expect(unsub).toHaveBeenCalled();
+  });
+
+  it('unsubscribes previous reactive subscription when query changes', async () => {
+    const readDb = createMockDB();
+    const writeDb = createMockDB();
+    const unsubscribeFirst = jest.fn();
+    const unsubscribeSecond = jest.fn();
+
+    (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] });
+    (writeDb.reactiveExecute as jest.Mock)
+      .mockReturnValueOnce(unsubscribeFirst)
+      .mockReturnValueOnce(unsubscribeSecond);
+
+    const wrapper = createTestWrapper({
+      db: { readDb: readDb as any, writeDb: writeDb as any },
+    });
+
+    const { rerender } = renderHook(
+      ({ query }: { query: string }) =>
+        useSqliteSyncQuery({
+          query,
+          arguments: [],
+          fireOn: [{ table: 'users' }],
+        }),
+      { wrapper, initialProps: { query: 'SELECT * FROM users' } }
+    );
+
+    await act(async () => {});
+    await act(async () => {
+      jest.advanceTimersByTime(1000);
+    });
+
+    rerender({ query: 'SELECT * FROM users WHERE id = 1' });
+
+    await act(async () => {});
+    await act(async () => {
+      jest.advanceTimersByTime(1000);
+    });
+    await act(async () => {
+      jest.advanceTimersByTime(0);
+    });
+
+    expect(unsubscribeFirst).toHaveBeenCalled();
+    expect(unsubscribeSecond).not.toHaveBeenCalled();
+  });
+
+  it('ignores stale read results after the query changes', async () => {
+    const readDb = createMockDB();
+    const writeDb = createMockDB();
+    let resolveFirst: ((value: any) => void) | undefined;
+
+    (readDb.execute as jest.Mock)
+      .mockImplementationOnce(
+        () =>
+          new Promise((resolve) => {
+            resolveFirst = resolve;
+          })
+      )
+      .mockResolvedValueOnce({ rows: [{ id: 2, name: 'Bob' }] });
+
+    const wrapper = createTestWrapper({
+      db: { readDb: readDb as any, writeDb: writeDb as any },
+    });
+
+    const { result, rerender } = renderHook(
+      ({ args }: { args: any[] }) =>
+        useSqliteSyncQuery({
+          query: 'SELECT * FROM users WHERE id = ?',
+          arguments: args,
+          fireOn: [{ table: 'users' }],
+        }),
+      { wrapper, initialProps: { args: [1] } }
+    );
+
+    rerender({ args: [2] });
+
+    await act(async () => {
+      resolveFirst?.({ rows: [{ id: 1, name: 'Alice' }] });
+    });
+
+    await act(async () => {});
+
+    expect(result.current.data).toEqual([{ id: 2, name: 'Bob' }]);
+  });
+});
diff --git a/src/hooks/sync/__tests__/useTriggerSqliteSync.test.ts b/src/hooks/sync/__tests__/useTriggerSqliteSync.test.ts
new file mode 100644
index 0000000..9d6ee99
--- /dev/null
+++ b/src/hooks/sync/__tests__/useTriggerSqliteSync.test.ts
@@ -0,0 +1,24 @@
+import { renderHook } from '@testing-library/react-native';
+import { useTriggerSqliteSync } from '../useTriggerSqliteSync';
+import { createTestWrapper } from '../../../testUtils';
+
+describe('useTriggerSqliteSync', () => {
+  it('returns triggerSync from context', () => {
+    const triggerSync = jest.fn().mockResolvedValue(undefined);
+    const wrapper = createTestWrapper({ actions: { triggerSync } });
+
+    const { result } = renderHook(() => useTriggerSqliteSync(), { wrapper });
+
+    expect(result.current.triggerSync).toBe(triggerSync);
+  });
+
+  it('calls through to context triggerSync', async () => {
+    const triggerSync = jest.fn().mockResolvedValue(undefined);
+    const wrapper = createTestWrapper({ actions: { triggerSync } });
+
+    const { result } = renderHook(() => useTriggerSqliteSync(), { wrapper });
+
+    await result.current.triggerSync();
+    expect(triggerSync).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/src/hooks/sync/useTriggerSqliteSync.ts b/src/hooks/sync/useTriggerSqliteSync.ts
index 2bca6be..50fe405 100644
--- a/src/hooks/sync/useTriggerSqliteSync.ts
+++ b/src/hooks/sync/useTriggerSqliteSync.ts
@@ -10,11 +10,12 @@ import { SQLiteSyncActionsContext } from '../../contexts/SQLiteSyncActionsContex
  * updated correctly, allowing all hooks (useSqliteSyncQuery)
  * to react properly.
  *
- * @returns Object containing triggerSync function and current syncing state
+ * @returns Object containing the triggerSync function
  *
  * @example
  * ```typescript
- * const { triggerSync, isSyncing } = useTriggerSqliteSync();
+ * const { triggerSync } = useTriggerSqliteSync();
+ * const { isSyncing } = useSyncStatus();
  *
  * return (
  *   <Button
diff --git a/src/testUtils.tsx b/src/testUtils.tsx
new file mode 100644
index 0000000..17b0677
--- /dev/null
+++ b/src/testUtils.tsx
@@ -0,0 +1,62 @@
+import { type ReactNode } from 'react';
+import { SQLiteDbContext } from './contexts/SQLiteDbContext';
+import { SQLiteSyncStatusContext } from './contexts/SQLiteSyncStatusContext';
+import { SQLiteSyncActionsContext } from './contexts/SQLiteSyncActionsContext';
+import { SQLiteInternalContext } from './contexts/SQLiteInternalContext';
+import { createLogger } from './core/common/logger';
+import type { SQLiteDbContextValue } from './types/SQLiteDbContextValue';
+import type { SQLiteSyncStatusContextValue } from './types/SQLiteSyncStatusContextValue';
+import type { SQLiteSyncActionsContextValue } from './types/SQLiteSyncActionsContextValue';
+import { createMockDB } from './__mocks__/@op-engineering/op-sqlite';
+
+const defaultDbContext: SQLiteDbContextValue = {
+  writeDb: null,
+  readDb: null,
+  initError: null,
+};
+
+const defaultStatusContext: SQLiteSyncStatusContextValue = {
+  syncMode: 'polling',
+  isSyncReady: false,
+  isSyncing: false,
+  lastSyncTime: null,
+  lastSyncChanges: 0,
+  syncError: null,
+  currentSyncInterval: 5000,
+  consecutiveEmptySyncs: 0,
+  consecutiveSyncErrors: 0,
+  isAppInBackground: false,
+  isNetworkAvailable: true,
+};
+
+const defaultActionsContext: SQLiteSyncActionsContextValue = {
+  triggerSync: jest.fn().mockResolvedValue(undefined),
+};
+
+export function createTestWrapper(overrides?: {
+  db?: Partial<SQLiteDbContextValue>;
+  status?: Partial<SQLiteSyncStatusContextValue>;
+  actions?: Partial<SQLiteSyncActionsContextValue>;
+  logger?: ReturnType<typeof createLogger>;
+}) {
+  const dbValue = { ...defaultDbContext, ...overrides?.db };
+  const statusValue = { ...defaultStatusContext, ...overrides?.status };
+  const actionsValue = { ...defaultActionsContext, ...overrides?.actions };
+  const logger = overrides?.logger ?? createLogger(false);
+
+  return function TestWrapper({ children }: { children: ReactNode }) {
+    return (
+      <SQLiteInternalContext.Provider value={{ logger }}>
+        <SQLiteDbContext.Provider value={dbValue}>
+          <SQLiteSyncStatusContext.Provider value={statusValue}>
+            <SQLiteSyncActionsContext.Provider value={actionsValue}>
+              {children}
+            </SQLiteSyncActionsContext.Provider>
+          </SQLiteSyncStatusContext.Provider>
+        </SQLiteDbContext.Provider>
+      </SQLiteInternalContext.Provider>
+    );
+  };
+}
+
+export { createMockDB };
diff --git a/src/types/SQLiteSyncProviderProps.ts b/src/types/SQLiteSyncProviderProps.ts
index 1410bde..d889a9c 100644
--- a/src/types/SQLiteSyncProviderProps.ts
+++ b/src/types/SQLiteSyncProviderProps.ts
@@ -58,9 +58,9 @@ export interface AdaptivePollingConfig {
  */
 interface CommonProviderProps {
   /**
-   * SQLite Cloud connection string
+   * CloudSync database ID used by runtime sync APIs and native network init.
    */
-  connectionString: string;
+  databaseId: string;
 
   /**
    * Name of the local database file
@@ -130,7 +130,7 @@ interface WithAccessToken {
 
 /**
  * Polling mode configuration
- * Adaptive polling is required when using polling mode
+ * Adaptive polling is optional and falls back to runtime defaults
  */
 interface PollingMode {
   /**
@@ -140,10 +140,10 @@ interface PollingMode {
   syncMode: 'polling';
 
   /**
-   * Adaptive polling configuration (required in polling mode)
-   * Controls polling intervals and backoff behavior
+   * Adaptive polling configuration (optional in polling mode)
+   * When omitted, runtime defaults are used
    */
-  adaptivePolling: AdaptivePollingConfig;
+  adaptivePolling?: AdaptivePollingConfig;
 
   /**
    * Not available in polling mode
diff --git a/tsconfig.json b/tsconfig.json
index d4e3d0e..adc5435 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -25,6 +25,7 @@
     "skipLibCheck": true,
     "strict": true,
     "target": "ESNext",
+    "types": ["jest", "node"],
     "verbatimModuleSyntax": true
   }
 }
diff --git a/yarn.lock b/yarn.lock
index 31b7971..008bb21 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2608,6 +2608,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jest/diff-sequences@npm:30.0.1":
+  version: 30.0.1
+  resolution: "@jest/diff-sequences@npm:30.0.1"
+  checksum: 10c0/3a840404e6021725ef7f86b11f7b2d13dd02846481264db0e447ee33b7ee992134e402cdc8b8b0ac969d37c6c0183044e382dedee72001cdf50cfb3c8088de74
+  languageName: node
+  linkType: hard
+
 "@jest/environment@npm:^29.7.0":
   version: 29.7.0
   resolution: "@jest/environment@npm:29.7.0"
@@ -2653,6 +2660,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jest/get-type@npm:30.1.0":
+  version: 30.1.0
+  resolution: "@jest/get-type@npm:30.1.0"
+  checksum: 10c0/3e65fd5015f551c51ec68fca31bbd25b466be0e8ee8075d9610fa1c686ea1e70a942a0effc7b10f4ea9a338c24337e1ad97ff69d3ebacc4681b7e3e80d1b24ac
+  languageName: node
+  linkType: hard
+
 "@jest/globals@npm:^29.7.0":
   version: 29.7.0
   resolution: "@jest/globals@npm:29.7.0"
@@ -2702,6 +2716,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jest/schemas@npm:30.0.5":
+  version: 30.0.5
+  resolution: "@jest/schemas@npm:30.0.5"
+  dependencies:
+    "@sinclair/typebox": "npm:^0.34.0"
+  checksum: 10c0/449dcd7ec5c6505e9ac3169d1143937e67044ae3e66a729ce4baf31812dfd30535f2b3b2934393c97cfdf5984ff581120e6b38f62b8560c8b5b7cc07f4175f65
+  languageName: node
+  linkType: hard
+
 "@jest/schemas@npm:^29.6.3":
   version: 29.6.3
   resolution: "@jest/schemas@npm:29.6.3"
@@ -3700,6 +3723,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@sinclair/typebox@npm:^0.34.0":
+  version: 0.34.48
+  resolution: "@sinclair/typebox@npm:0.34.48"
+  checksum: 10c0/e09f26d8ad471a07ee64004eea7c4ec185349a1f61c03e87e71ea33cbe98e97959940076c2e52968a955ffd4c215bf5ba7035e77079511aac7935f25e989e29d
+  languageName: node
+  linkType: hard
+
 "@sindresorhus/merge-streams@npm:^2.1.0":
   version: 2.3.0
   resolution: "@sindresorhus/merge-streams@npm:2.3.0"
@@ -3798,8 +3828,10 @@ __metadata:
     "@react-native/babel-preset": "npm:0.81.1"
     "@react-native/eslint-config": "npm:^0.83.0"
     "@release-it/conventional-changelog": "npm:^10.0.1"
+    "@testing-library/react-native": "npm:^13.3.3"
     "@types/jest": "npm:^29.5.14"
     "@types/react": "npm:^19.1.12"
+    "@types/react-test-renderer": "npm:^19"
     del-cli: "npm:^6.0.0"
     eslint: "npm:^9.35.0"
     eslint-config-prettier: "npm:^10.1.8"
@@ -3809,6 +3841,7 @@ __metadata:
     react: "npm:19.1.0"
     react-native: "npm:0.81.5"
     react-native-builder-bob: "npm:^0.40.16"
+    react-test-renderer: "npm:19.1.0"
     release-it: "npm:^19.0.4"
     typescript: "npm:^5.9.2"
   peerDependencies:
@@ -3832,6 +3865,26 @@ __metadata:
   languageName: unknown
   linkType: soft
 
+"@testing-library/react-native@npm:^13.3.3":
+  version: 13.3.3
+  resolution: "@testing-library/react-native@npm:13.3.3"
+  dependencies:
+    jest-matcher-utils: "npm:^30.0.5"
+    picocolors: "npm:^1.1.1"
+    pretty-format: "npm:^30.0.5"
+    redent: "npm:^3.0.0"
+  peerDependencies:
+    jest: ">=29.0.0"
+    react: ">=18.2.0"
+    react-native: ">=0.71"
+    react-test-renderer: ">=18.2.0"
+  peerDependenciesMeta:
+    jest:
+      optional: true
+  checksum: 10c0/ba13066536d5b2c0b625220d4320c6ad1e390c3df4f4b614d859ef467c4974ad52aa79269ae98efdba8f5a074644e3d11583a5485312df5a64387976ecf4225a
+  languageName: node
+  linkType: hard
+
 "@tootallnate/quickjs-emscripten@npm:^0.23.0":
   version: 0.23.0
   resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0"
@@ -3970,7 +4023,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@types/react-test-renderer@npm:^19.1.0":
+"@types/react-test-renderer@npm:^19, @types/react-test-renderer@npm:^19.1.0":
   version: 19.1.0
   resolution: "@types/react-test-renderer@npm:19.1.0"
   dependencies:
@@ -4528,7 +4581,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ansi-styles@npm:^5.0.0":
+"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0":
   version: 5.2.0
   resolution: "ansi-styles@npm:5.2.0"
   checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df
@@ -9145,6 +9198,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"jest-diff@npm:30.2.0":
+  version: 30.2.0
+  resolution: "jest-diff@npm:30.2.0"
+  dependencies:
+    "@jest/diff-sequences": "npm:30.0.1"
+    "@jest/get-type": "npm:30.1.0"
+    chalk: "npm:^4.1.2"
+    pretty-format: "npm:30.2.0"
+  checksum: 10c0/5fac2cd89a10b282c5a68fc6206a95dfff9955ed0b758d24ffb0edcb20fb2f98e1fa5045c5c4205d952712ea864c6a086654f80cdd500cce054a2f5daf5b4419
+  languageName: node
+  linkType: hard
+
 "jest-diff@npm:^29.7.0":
   version: 29.7.0
   resolution: "jest-diff@npm:29.7.0"
@@ -9245,6 +9310,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"jest-matcher-utils@npm:^30.0.5":
+  version: 30.2.0
+  resolution: "jest-matcher-utils@npm:30.2.0"
+  dependencies:
+    "@jest/get-type": "npm:30.1.0"
+    chalk: "npm:^4.1.2"
+    jest-diff: "npm:30.2.0"
+    pretty-format: "npm:30.2.0"
+  checksum: 10c0/f221c8afa04cee693a2be735482c5db4ec6f845f8ca3a04cb419be34c6257f4531dab89c836251f31d1859318c38997e8e9f34bf7b4cdcc8c7be8ae6e2ecb9f2
+  languageName: node
+  linkType: hard
+
 "jest-message-util@npm:^29.7.0":
   version: 29.7.0
   resolution: "jest-message-util@npm:29.7.0"
@@ -10410,6 +10487,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"min-indent@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "min-indent@npm:1.0.1"
+  checksum: 10c0/7e207bd5c20401b292de291f02913230cb1163abca162044f7db1d951fa245b174dc00869d40dd9a9f32a885ad6a5f3e767ee104cf278f399cb4e92d3f582d5c
+  languageName: node
+  linkType: hard
+
 "minimatch@npm:^10.1.1":
   version: 10.1.1
   resolution: "minimatch@npm:10.1.1"
@@ -11445,6 +11529,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"pretty-format@npm:30.2.0, pretty-format@npm:^30.0.5":
+  version: 30.2.0
+  resolution: "pretty-format@npm:30.2.0"
+  dependencies:
+    "@jest/schemas": "npm:30.0.5"
+    ansi-styles: "npm:^5.2.0"
+    react-is: "npm:^18.3.1"
+  checksum: 10c0/8fdacfd281aa98124e5df80b2c17223fdcb84433876422b54863a6849381b3059eb42b9806d92d2853826bcb966bcb98d499bea5b1e912d869a3c3107fd38d35
+  languageName: node
+  linkType: hard
+
 "pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0":
   version: 29.7.0
   resolution: "pretty-format@npm:29.7.0"
@@ -11685,7 +11780,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"react-is@npm:^18.0.0":
+"react-is@npm:^18.0.0, react-is@npm:^18.3.1":
   version: 18.3.1
   resolution: "react-is@npm:18.3.1"
   checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072
@@ -11885,6 +11980,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"redent@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "redent@npm:3.0.0"
+  dependencies:
+    indent-string: "npm:^4.0.0"
+    strip-indent: "npm:^3.0.0"
+  checksum: 10c0/d64a6b5c0b50eb3ddce3ab770f866658a2b9998c678f797919ceb1b586bab9259b311407280bd80b804e2a7c7539b19238ae6a2a20c843f1a7fcff21d48c2eae
+  languageName: node
+  linkType: hard
+
 "reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9":
   version: 1.0.10
   resolution: "reflect.getprototypeof@npm:1.0.10"
@@ -12964,6 +13069,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"strip-indent@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "strip-indent@npm:3.0.0"
+  dependencies:
+    min-indent: "npm:^1.0.0"
+  checksum: 10c0/ae0deaf41c8d1001c5d4fbe16cb553865c1863da4fae036683b474fa926af9fc121e155cb3fc57a68262b2ae7d5b8420aa752c97a6428c315d00efe2a3875679
+  languageName: node
+  linkType: hard
+
 "strip-json-comments@npm:^3.1.1":
   version: 3.1.1
   resolution: "strip-json-comments@npm:3.1.1"
