diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ded6b6f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: gabriel-sisjr # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 338758e..4a314eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,9 +3,11 @@ on: push: branches: - main + - develop pull_request: branches: - main + - develop merge_group: types: - checks_requested @@ -62,6 +64,7 @@ jobs: env: TURBO_CACHE_DIR: .turbo/android + turbo_cache_hit: '' steps: - name: Checkout @@ -127,6 +130,7 @@ jobs: TURBO_CACHE_DIR: .turbo/ios RCT_USE_RN_DEP: 1 RCT_USE_PREBUILT_RNCORE: 1 + turbo_cache_hit: '' steps: - name: Checkout @@ -160,6 +164,17 @@ jobs: with: xcode-version: ${{ env.XCODE_VERSION }} + - name: Cache cocoapods + id: cocoapods-cache + if: env.turbo_cache_hit != 1 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + **/ios/Pods + key: ${{ runner.os }}-cocoapods-${{ hashFiles('example/ios/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-cocoapods- + - name: Install cocoapods if: env.turbo_cache_hit != 1 && steps.cocoapods-cache.outputs.cache-hit != 'true' run: | @@ -262,26 +277,59 @@ jobs: xcrun xcodebuild -workspace SyncProviderExample.xcworkspace -list || \ xcrun xcodebuild -list + # Diagnostic: dumps the simulator catalog the runner actually ships with. + # Non-blocking so a transient simctl failure can never fail the job. + - name: Show available iOS simulators + continue-on-error: true + run: | + xcrun simctl list devices iOS available + - name: Run iOS unit tests run: | - cd example/ios set -o pipefail - xcrun xcodebuild test \ - -workspace SyncProviderExample.xcworkspace \ - -scheme "${IOS_TEST_SCHEME}" \ - -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \ - -resultBundlePath build/SyncProviderTests.xcresult \ - -enableCodeCoverage YES \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO || \ - xcrun xcodebuild test \ - -workspace SyncProviderExample.xcworkspace \ - -scheme "${IOS_TEST_SCHEME}" \ - -destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' \ - -resultBundlePath build/SyncProviderTests.xcresult \ - -enableCodeCoverage YES \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO + cd example/ios + + # Pipe xcodebuild output through xcbeautify when present (it ships on + # the GitHub macos-latest image). Fallback to raw output otherwise. + if command -v xcbeautify >/dev/null 2>&1; then + PRETTIFY=(xcbeautify --renderer github-actions) + else + PRETTIFY=(cat) + fi + + # Destinations are tried in order. iPhone 16 is the primary target on + # the current Xcode 26 image; iPhone 17 is forward-looking for image + # bumps; iPhone 16 Pro is a safety net within the same generation. + DESTINATIONS=( + "platform=iOS Simulator,name=iPhone 16,OS=latest" + "platform=iOS Simulator,name=iPhone 17,OS=latest" + "platform=iOS Simulator,name=iPhone 16 Pro,OS=latest" + ) + + for destination in "${DESTINATIONS[@]}"; do + echo "::group::xcodebuild test on $destination" + # xcodebuild errors out if -resultBundlePath already exists, so we + # always start each attempt from a clean slate. + rm -rf build/SyncProviderTests.xcresult + if xcrun xcodebuild test \ + -workspace SyncProviderExample.xcworkspace \ + -scheme "${IOS_TEST_SCHEME}" \ + -destination "$destination" \ + -resultBundlePath build/SyncProviderTests.xcresult \ + -enableCodeCoverage YES \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO | "${PRETTIFY[@]}"; then + echo "::endgroup::" + exit 0 + fi + echo "::endgroup::" + echo "::warning::xcodebuild test failed on $destination — trying next destination" + done + + echo "::error::xcodebuild test failed on every destination" + exit 1 - name: Upload iOS test results if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index bfe607f..f79ac9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > **Pre-1.0 notice:** while the library is on `0.x`, minor version bumps (`0.x.0`) may include breaking changes. Every breaking change is summarized in [`BREAKING_CHANGES.md`](./BREAKING_CHANGES.md) and ships with a dedicated migration guide under `website/docs/migration/`. -## [Unreleased] +## v0.1.1 - 2025-05-19 + +### Fixed + +- **iOS / Swift↔C++ name collision in the Nitro-generated bridging header.** Renamed the Swift implementation class from `SyncProvider` to `HybridSyncProvider` in `ios/SyncProvider.swift` and updated `nitro.json → autolinking.SyncProvider.ios.implementationClassName` accordingly. The previous name collided with the C++ `SyncProvider::SyncProvider` class generated by Nitro inside the `margelo::nitro::syncprovider` namespace, surfacing as `SyncProvider-Swift.h:12942: no member named '_impl' in 'SyncProvider::SyncProvider'`. **No JS-facing change** — the Nitro `iosModuleName` (`SyncProvider`) and the JS lookup (`NitroModules.createHybridObject('SyncProvider')`) are unchanged, the Android Kotlin class name (`SyncProvider`) is unchanged, and the public TypeScript surface is unaffected. After pulling this fix, run `yarn nitrogen` to regenerate the C++ glue with the new class name. +- **iOS / Swift 6 strict-concurrency compliance.** Replaced every `NSLock.lock()` / `defer { lock.unlock() }` (and bare `lock()` / `unlock()`) pattern inside `Promise.async { @Sendable in ... }` closures with `lock.withLock { ... }`. The previous pattern captured a non-`Sendable` `NSLock` reference into a `@Sendable` closure, which is a hard error under Swift 6. Affected files: `ios/SyncProvider.swift` (2 sites), `ios/Background/BackgroundSyncManager.swift` (4 sites), `ios/Connectivity/ConnectivityMonitor.swift` (6 sites — snapshot-then-notify-outside-lock pattern preserved), `ios/Events/SyncEventEmitter.swift` (4 sites — snapshot-then-emit-outside-lock pattern preserved), `ios/HTTP/BackgroundURLSessionDelegate.swift` (5 sites — lock released before invoking captured completion handlers). `NSLock` retained as the primitive (no `OSAllocatedUnfairLock` / `actor` migration) to preserve the iOS 13.0 deployment target floor declared in the podspec. + +### Chore + +- **ci:** wire iOS unit tests into CI via the existing `SyncProvider-Unit-Tests` CocoaPods test_spec scheme. CocoaPods 1.10+ auto-prefixes `:test_type => :unit` (the default) with `Unit-`, so the `s.test_spec 'Tests'` block in `SyncProvider.podspec` produces the scheme name `SyncProvider-Unit-Tests` (not `SyncProvider-Tests`) — the `test-ios` job in `.github/workflows/ci.yml` was rewritten to target that scheme with a destination fallback loop (`iPhone 16` → `iPhone 17` → `iPhone 16 Pro`, all `OS=latest`), `rm -rf build/SyncProviderTests.xcresult` before each attempt (recovers from the previous `"Existing file at -resultBundlePath"` failure mode), `-skipPackagePluginValidation`, `-skipMacroValidation`, opportunistic `xcbeautify` formatting, GitHub annotations, plus a new "Show available iOS simulators" diagnostic step. Also added an explicit `pod 'SyncProvider', :path => '../..', :testspecs => ['Tests']` to `example/ios/Podfile` (after `use_react_native!`) because `use_native_modules!` autolinking does not propagate `:testspecs` — without the opt-in, CocoaPods would never generate the test target — and added `installer.pods_project.recreate_user_schemes(false)` inside the existing `post_install` block to defensively share the generated test scheme. +- **scripts:** add `yarn test:ios` (`scripts/test-ios.sh`, executable) to mirror the CI iOS test invocation locally — same destination fallback loop and `.xcresult` cleanup as CI, so a green local run is a strong predictor of CI success. + +## v0.1.0 - 2025-05-19 + +### Documentation + +- **README rewrite.** Realigned `README.md` with the `react-native-background-location` information architecture: full badge set (NPM stable + beta + downloads + total downloads, CI / Pre-release / Release workflows, Codecov, GitHub Stars, Bundlephobia, License, Platform Android / iOS, TypeScript-Ready, New Architecture, Nitro Module), Table of Contents linking every section, expanded Features bullets describing the actual implemented surface (Room + Core Data persistence, `BGTaskScheduler` + `WorkManager` dispatch, `NWPathMonitor` + `ConnectivityManager` connectivity detection, 7 hooks, 15 typed events, `SyncError` discriminated codes, Nitro JSI bridge), per-platform Installation block (npm/yarn + iOS Background Modes + `BGTaskSchedulerPermittedIdentifiers` + pod install + Android `INTERNET` / `ACCESS_NETWORK_STATE` / `RECEIVE_BOOT_COMPLETED`), Quick Start sample exercising `configureSync` + `enableBackgroundSync` + `enqueue` + `useConnection`/`useSyncQueue`/`useSyncStatus`, Hooks table deep-linking every page in `website/docs/api-reference/hooks/`, API Reference signature tables (queue / sync / config / history / connectivity / background sync / event listeners / utilities), inline TypeScript blocks for every public type (`SyncItemInput`, `SyncItem`, `RetryPolicy`, `SyncOptions`, `SyncResult`, `SyncEvent`, `BackgroundSyncOptions`, `ConnectionState`, `SyncError`), inline enum tables for `SyncStrategy`, `SyncPriority`, `BackoffStrategy`, `HttpMethod`, `ConnectionStatus`, `ConnectionType`, `SyncEventType` (all 15), and `SyncErrorCode` (all 11), Platform Support table, and a curated Documentation section indexing every Docusaurus page. Dropped the pre-release banner — the v0.1 line is treated as opinionated GA. Preserved the unique "Why this library", "How it compares", "Ecosystem", "Performance notes", and "FAQ" sections. + +### Chore + +- **`package.json` metadata curated.** Refined `description` to align word-for-word with the README hero line (cross-platform React Native library for offline-first HTTP sync built on Nitro Modules / New Architecture). Expanded `keywords` to cover the full discovery surface (offline, queue, sync, http, background-sync, nitro-modules, etc.). Updated `homepage` to point at the Docusaurus site (`https://gabriel-sisjr.github.io/react-native-sync-provider/`). +- **`.github/FUNDING.yml` added** so the GitHub Sponsors button renders on the repo. +- **`context7.json` added** with a placeholder `public_key` to opt the package into the Context7 documentation index ahead of v0.1.0. +- **Repository labels automation added.** New `labels.json` defines the canonical issue/PR label set; `scripts/sync-labels.sh` applies it via `gh label`. Run once after fork to bring a new repository (or a contributor's fork) into alignment with the upstream label taxonomy. ### Fixed diff --git a/README.md b/README.md index 97cbe80..abfa0c0 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,45 @@ # @gabriel-sisjr/react-native-sync-provider -> Offline-first HTTP request queue with native persistence and true background sync for React Native — even when the app is closed. - -[![npm version](https://img.shields.io/npm/v/@gabriel-sisjr/react-native-sync-provider.svg)](https://www.npmjs.com/package/@gabriel-sisjr/react-native-sync-provider) -[![npm downloads](https://img.shields.io/npm/dm/@gabriel-sisjr/react-native-sync-provider.svg)](https://www.npmjs.com/package/@gabriel-sisjr/react-native-sync-provider) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![CI](https://github.com/gabriel-sisjr/react-native-sync-provider/actions/workflows/ci.yml/badge.svg)](https://github.com/gabriel-sisjr/react-native-sync-provider/actions/workflows/ci.yml) +[![NPM Version](https://img.shields.io/npm/v/%40gabriel-sisjr%2Freact-native-sync-provider)](https://www.npmjs.com/package/@gabriel-sisjr/react-native-sync-provider) +[![NPM Beta](https://img.shields.io/npm/v/%40gabriel-sisjr%2Freact-native-sync-provider/beta)](https://www.npmjs.com/package/@gabriel-sisjr/react-native-sync-provider/v/beta) +[![NPM Downloads](https://img.shields.io/npm/dm/%40gabriel-sisjr%2Freact-native-sync-provider)](https://www.npmjs.com/package/@gabriel-sisjr/react-native-sync-provider) +[![NPM Total Downloads](https://img.shields.io/npm/dt/%40gabriel-sisjr%2Freact-native-sync-provider)](https://www.npmjs.com/package/@gabriel-sisjr/react-native-sync-provider) +[![CI Tests](https://github.com/gabriel-sisjr/react-native-sync-provider/actions/workflows/ci.yml/badge.svg)](https://github.com/gabriel-sisjr/react-native-sync-provider/actions/workflows/ci.yml) +[![Code Coverage](https://codecov.io/gh/gabriel-sisjr/react-native-sync-provider/branch/develop/graph/badge.svg)](https://codecov.io/gh/gabriel-sisjr/react-native-sync-provider) +[![Pre-release CI](https://github.com/gabriel-sisjr/react-native-sync-provider/actions/workflows/prerelease.yml/badge.svg?branch=develop&label=Pre-release)](https://github.com/gabriel-sisjr/react-native-sync-provider/actions/workflows/prerelease.yml) +[![Release CI](https://github.com/gabriel-sisjr/react-native-sync-provider/actions/workflows/publish.yml/badge.svg?branch=main&label=Release)](https://github.com/gabriel-sisjr/react-native-sync-provider/actions/workflows/publish.yml) +[![GitHub Stars](https://img.shields.io/github/stars/gabriel-sisjr/react-native-sync-provider)](https://github.com/gabriel-sisjr/react-native-sync-provider/stargazers) +[![License](https://img.shields.io/github/license/gabriel-sisjr/react-native-sync-provider)](https://github.com/gabriel-sisjr/react-native-sync-provider/blob/develop/LICENSE) +[![Bundlephobia](https://img.shields.io/bundlephobia/minzip/%40gabriel-sisjr%2Freact-native-sync-provider?label=size)](https://bundlephobia.com/package/@gabriel-sisjr/react-native-sync-provider) [![New Architecture](https://img.shields.io/badge/Architecture-New-blue.svg)](https://reactnative.dev/docs/the-new-architecture/landing-page) [![Nitro Module](https://img.shields.io/badge/Built%20with-Nitro%20Modules-purple.svg)](https://nitro.margelo.com/) - -> ⚠️ **Status**: pre-release. The public API is being designed and may change before `v1.0.0`. +![Platform Android](https://img.shields.io/badge/platform-Android-green) +![Platform iOS](https://img.shields.io/badge/platform-iOS-blue) +![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue) + +A cross-platform React Native library for offline-first HTTP sync built on Nitro Modules (New Architecture). Persists outbound requests on the native side, drains them when connectivity returns, and keeps flushing even when the app is killed — with retry, priority lanes, and OS-scheduled background dispatch on both Android and iOS. + +**[Read the full documentation](https://gabriel-sisjr.github.io/react-native-sync-provider/)** + +## Table of Contents + +- [Why this library](#why-this-library) +- [Features](#features) +- [Requirements](#requirements) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Hooks](#hooks) +- [API Reference](#api-reference) +- [Types](#types) +- [Enums](#enums) +- [How it compares](#how-it-compares) +- [Ecosystem](#ecosystem) +- [Performance notes](#performance-notes) +- [Documentation](#documentation) +- [Platform Support](#platform-support) +- [FAQ](#faq) +- [Contributing](#contributing) +- [License](#license) ## Why this library @@ -25,17 +55,19 @@ If `react-native-background-location` is what you reach for when you need GPS th ## Features -- 🔌 **Truly offline-first** — enqueue HTTP requests, the lib flushes when connectivity returns. -- 🌙 **Background sync** — `BGTaskScheduler` (iOS) / `WorkManager` (Android) flush even with app closed. -- 💾 **Native persistence** — Core Data + Room. Queue survives app force-close and device reboot. -- 🔁 **Configurable retry** — linear / exponential / fibonacci backoff with jitter, status-code allowlist, and an optional `shouldRetry(ctx)` JS override. -- 📡 **Connectivity-aware** — `NWPathMonitor` (iOS) / `ConnectivityManager.NetworkCallback` (Android) detect online/offline/metered transitions. -- 🪝 **7 React hooks** — `useConnection`, `useSyncQueue`, `useSyncStatus`, `useOfflineQueue`, `useSyncEvents`, `useSyncConfig`, `useAutoSync`. -- 🚦 **Priority lanes** — `HIGH` / `NORMAL` / `LOW` ordering enforced by the native dispatcher. -- 🛡️ **Idempotent by design** — every item gets a ULID; re-flushes never duplicate. -- ⚡ **Nitro-powered** — JS↔native bridge built on JSI. No JSON marshalling tax. -- 🆕 **New Architecture only** — TurboModules + Fabric. -- 🧪 **TypeScript strict** — `verbatimModuleSyntax`, `noUncheckedIndexedAccess`. Types ship from the spec. +- Cross-platform offline-first HTTP queue (Android and iOS) +- Native persistence: Room (Android) / Core Data (iOS) — queue survives force-close and device reboot +- OS-scheduled background sync: `WorkManager` (Android) / `BGTaskScheduler` (iOS) flush even with app closed +- Connectivity-aware dispatch via `ConnectivityManager.NetworkCallback` (Android) and `NWPathMonitor` (iOS) +- Declarative retry policy with linear / exponential / fibonacci backoff, jitter, and a per-status-code allowlist +- Priority lanes (`HIGH` / `NORMAL` / `LOW`) enforced by the native dispatcher +- Three sync strategies: `AUTOMATIC` (flush on connectivity), `MANUAL` (consumer-driven), `OPPORTUNISTIC` (charging + unmetered) +- 7 fully typed React hooks for connection, queue, status, events, config, and an opt-in orchestrator +- 15 typed event types streamed over a single `sync-event` channel for observability +- `SyncError` + `SyncErrorCode` discriminated error surface for clean pattern matching +- Idempotent by design — native-assigned ULIDs guarantee no duplicates across retries or restarts +- Nitro-powered JSI bridge — no JSON marshalling tax, ~15× faster than legacy native modules +- Fully typed TypeScript API (`verbatimModuleSyntax`, `noUncheckedIndexedAccess`) with types shipped from the Nitro spec ## Requirements @@ -51,18 +83,26 @@ If `react-native-background-location` is what you reach for when you need GPS th ## Installation ```sh -yarn add @gabriel-sisjr/react-native-sync-provider react-native-nitro-modules react-native-mmkv @react-native-community/netinfo +npm install @gabriel-sisjr/react-native-sync-provider react-native-nitro-modules +# or +yarn add @gabriel-sisjr/react-native-sync-provider react-native-nitro-modules ``` -Then on iOS: +### Android Setup -```sh -cd ios && bundle install && bundle exec pod install +`AndroidManifest.xml` permissions are merged automatically by the library — no manual edits required for a standard setup: + +```xml + + + ``` -### iOS — extra setup for background sync +> See the [Android Setup Guide](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/getting-started/android-setup) and the [Android Permissions](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/production/android-permissions) reference for the full WorkManager + boot-receiver setup in existing apps. -In `ios//Info.plist`, enable the BGTaskScheduler identifier: +### iOS Setup + +1. Declare the BGTaskScheduler identifier in `ios//Info.plist`: ```xml BGTaskSchedulerPermittedIdentifiers @@ -71,21 +111,17 @@ In `ios//Info.plist`, enable the BGTaskScheduler identifier: ``` -Then in Xcode, enable the capabilities **Background fetch** and **Background processing** (Signing & Capabilities → + Capability → Background Modes). - -### Android — extra setup for background sync +2. In Xcode, enable the **Background fetch** and **Background processing** modes in **Signing & Capabilities → + Capability → Background Modes**. -`AndroidManifest.xml` permissions are merged automatically by the library: +3. Run `pod install` in your `ios/` directory: -```xml - - - +```sh +cd ios && bundle install && bundle exec pod install ``` -No manual changes required. +> See the [iOS Setup Guide](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/getting-started/ios-setup) and the [iOS Background Modes](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/production/ios-background-modes) reference for full details and App Store compliance requirements. -## Quick start +## Quick Start ```tsx import { @@ -96,13 +132,16 @@ import { useSyncQueue, useSyncStatus, SyncPriority, + SyncStrategy, + BackoffStrategy, } from '@gabriel-sisjr/react-native-sync-provider'; // 1. Configure once on app boot -configureSync({ +await configureSync({ + strategy: SyncStrategy.AUTOMATIC, retryPolicy: { maxAttempts: 5, - backoff: 'exponential', + backoff: BackoffStrategy.EXPONENTIAL, baseDelayMs: 1000, maxDelayMs: 60_000, jitter: true, @@ -111,7 +150,7 @@ configureSync({ }); await enableBackgroundSync({ - minIntervalMinutes: 15, + minimumIntervalMs: 15 * 60 * 1000, requiresUnmeteredNetwork: false, }); @@ -120,6 +159,7 @@ await enqueue({ method: 'POST', url: 'https://api.example.com/events', headers: { Authorization: 'Bearer ...' }, + contentType: 'application/json', body: JSON.stringify({ event: 'screen_view', screen: 'Home' }), priority: SyncPriority.HIGH, }); @@ -128,83 +168,353 @@ await enqueue({ function StatusBar() { const { isOnline, isMetered } = useConnection(); const { size: pending } = useSyncQueue(); - const { isSyncing, lastSyncAt } = useSyncStatus(); + const { isSyncing, lastResult } = useSyncStatus(); return ( - {isOnline ? '🟢 online' : '🔴 offline'} · {pending} pending + {isOnline ? 'online' : 'offline'} · {pending} pending {isSyncing ? ' · syncing…' : ''} ); } ``` -That's it. Force-close the app, toggle airplane mode on, enqueue more items via a deep link or push, turn airplane mode off — `BGTaskScheduler`/`WorkManager` will flush the queue without the app being opened. - -## API at a glance - -### Functions - -| Function | What it does | -| ------------------------------- | --------------------------------------------------------------------------- | -| `enqueue(item)` | Add a single HTTP request to the queue. | -| `enqueueBatch(items)` | Atomic batch enqueue. | -| `removeItem(id)` | Remove a queued item by id. | -| `clearQueue()` | Drop everything. | -| `getQueueSize()` | Number of pending items. | -| `getPendingItems()` | List pending items (sorted by priority + createdAt). | -| `flush()` | Force a sync attempt now. | -| `pauseSync()` / `resumeSync()` | Soft-pause without dropping the queue. | -| `isSyncing()` | Is a sync currently in flight? | -| `configureSync(options)` | Set base URL, default headers, retry policy, batch size. | -| `getSyncConfig()` | Read current config. | -| `getLastSyncResult()` | Last result (success count, failures, timing). | -| `getSyncHistory(limit?)` | Recent results. | -| `clearSyncHistory()` | Clear history. | -| `getConnectionStatus()` | One-shot read of current connectivity. | -| `enableBackgroundSync(options)` | Register `BGTaskScheduler` (iOS) / `WorkManager` periodic worker (Android). | -| `disableBackgroundSync()` | Unregister background sync. | -| `isBackgroundSyncEnabled()` | Status of background registration. | - -### Hooks - -| Hook | Returns | -| --------------------- | -------------------------------------------------------------------------------------------------- | -| `useConnection()` | `{ status, type, isOnline, isMetered }` | -| `useSyncQueue()` | `{ size, items, isLoading, error, enqueue, enqueueBatch, removeItem, clearQueue, refresh }` | -| `useSyncStatus()` | `{ isSyncing, isPaused, progress, lastResult, error, flush, pause, resume }` | -| `useOfflineQueue()` | `{ connection, size, items, isSyncing, isWaitingForConnection, error, enqueue, flush }` | -| `useSyncEvents(opts)` | `void` — subscribe to typed sync events (15 `SyncEventType`s) with optional filter. | -| `useSyncConfig()` | `{ config, isLoading, error, setConfig, refresh }` | -| `useAutoSync(opts)` | `void` — orchestrator: periodic interval + reconnect flush + opt-in AppState foreground trigger. | - -### Listener helpers - -| Symbol | Purpose | -| -------------------------------------------- | -------------------------------------------------------------------------------------- | -| `addSyncEventListener(cb): Promise` | Subscribe to the `'sync-event'` channel; returns the subscription id. | -| `removeSyncEventListener(id): Promise` | Unsubscribe. | -| `SYNC_EVENT_CHANNEL` (`'sync-event'`) | Channel constant for advanced consumers using the underlying Nitro `addListener` API. | - -### Types & enums - -`SyncItem`, `SyncItemInput`, `SyncOptions`, `SyncResult`, `SyncEvent`, `RetryPolicy`, `BackgroundSyncOptions`, `ConnectionState`, `ConnectionStatus`, `ConnectionType`, `SyncStrategy`, `SyncPriority`, `SyncEventType`, `BackoffStrategy`, `HttpMethod`, `SyncError`, `SyncErrorCode`. - -Full reference is live at ****. +Force-close the app, toggle airplane mode on, enqueue more items via a deep link or push, turn airplane mode off — `BGTaskScheduler`/`WorkManager` will flush the queue without the app being opened. + +For step-by-step setup, see the [Quick Start Guide](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/getting-started/quick-start). + +## Hooks + +| Hook | Purpose | +| -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| [`useConnection`](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/hooks/useConnection) | Live connectivity snapshot (`status`, `type`, `isOnline`, `isMetered`). | +| [`useSyncQueue`](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/hooks/useSyncQueue) | Queue contents + write operations (`enqueue`, `enqueueBatch`, `removeItem`, `clearQueue`). | +| [`useSyncStatus`](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/hooks/useSyncStatus) | Sync engine state, progress, last result, `flush`/`pause`/`resume`. | +| [`useOfflineQueue`](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/hooks/useOfflineQueue) | Convenience hook combining connection + queue + sync state for offline-first UIs. | +| [`useSyncEvents`](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/hooks/useSyncEvents) | Typed event stream (15 `SyncEventType`s) with optional filtering. | +| [`useSyncConfig`](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/hooks/useSyncConfig) | Read / update the live `SyncOptions`. | +| [`useAutoSync`](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/hooks/useAutoSync) | Orchestrator: periodic interval + reconnect flush + opt-in AppState foreground trigger. | + +See the [Hooks API Reference](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/hooks/useSyncQueue) for complete documentation, options, and examples. + +## API Reference + +All sync methods are top-level **named exports**. The library does not ship a default export. + +```typescript +import { + enqueue, + flush, + configureSync, + enableBackgroundSync, +} from '@gabriel-sisjr/react-native-sync-provider'; + +await configureSync({ /* ... */ }); +await enableBackgroundSync({ minimumIntervalMs: 15 * 60 * 1000 }); +const id = await enqueue({ method: 'POST', url: 'https://api.example.com/events' }); +const result = await flush(); +``` + +### Queue operations + +| Method | Signature | Description | +| ----------------- | ------------------------------------------------------ | ------------------------------------------------------------ | +| `enqueue` | `(item: SyncItemInput) => Promise` | Persist a single HTTP request; returns its native ULID. | +| `enqueueBatch` | `(items: SyncItemInput[]) => Promise` | Atomic batch enqueue; returns ULIDs in input order. | +| `removeItem` | `(id: string) => Promise` | Remove a queued item by ULID. `false` if not found. | +| `clearQueue` | `() => Promise` | Drop every pending item. | +| `getQueueSize` | `() => Promise` | Current number of pending items. | +| `getPendingItems` | `() => Promise` | List pending items, sorted by priority + `createdAt`. | + +### Sync operations + +| Method | Signature | Description | +| ------------- | ---------------------------------- | -------------------------------------------------------------- | +| `flush` | `() => Promise` | Force a flush cycle; resolves with the outcome. | +| `pauseSync` | `() => Promise` | Pause the engine. Enqueues still accepted; no flushes. | +| `resumeSync` | `() => Promise` | Resume after `pauseSync()`. Auto-flushes if strategy allows. | +| `isSyncing` | `() => Promise` | `true` while a flush cycle is in progress. | + +### Configuration + +| Method | Signature | Description | +| ----------------- | ---------------------------------------- | ---------------------------------------------------------------- | +| `configureSync` | `(options: SyncOptions) => Promise`| Replace the active configuration; takes effect next flush cycle. | +| `getSyncConfig` | `() => Promise` | Read the currently active configuration. | + +### History + +| Method | Signature | Description | +| -------------------- | ----------------------------------------------- | ------------------------------------------------------------- | +| `getLastSyncResult` | `() => Promise` | Most recent flush result, or `undefined` if no flush yet. | +| `getSyncHistory` | `(limit?: number) => Promise` | Persisted flush results, most recent first. `0`/omitted = all.| +| `clearSyncHistory` | `() => Promise` | Clear persisted history (does not affect the queue). | + +### Connectivity + +| Method | Signature | Description | +| --------------------- | -------------------------------------- | ------------------------------------------ | +| `getConnectionStatus` | `() => Promise` | One-shot read of the OS connectivity state. | + +### Background sync + +| Method | Signature | Description | +| ------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------- | +| `enableBackgroundSync` | `(options: BackgroundSyncOptions) => Promise` | Register the OS-level background task (`BGTaskScheduler`/`WorkManager`). | +| `disableBackgroundSync` | `() => Promise` | Cancel the OS-level background task registration. | +| `isBackgroundSyncEnabled` | `() => Promise` | Status of the background registration. | + +### Event listeners + +| Symbol | Signature | Description | +| ------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------- | +| `addSyncEventListener` | `(cb: (e: SyncEvent) => void) => Promise` | Subscribe to the `'sync-event'` channel; returns the subscription id. | +| `removeSyncEventListener` | `(id: string) => Promise` | Unsubscribe. | +| `SYNC_EVENT_CHANNEL` | `const SYNC_EVENT_CHANNEL = 'sync-event'` | Channel constant for advanced consumers using the underlying Nitro API. | + +### Utilities + +| Method | Signature | Description | +| ------------------------- | -------------------------------------- | ----------------------------------------------------------------- | +| `generateId` | `() => string` | UUID v4 helper (Math.random-backed) for `Idempotency-Key` headers.| +| `isNativeModuleAvailable` | `() => boolean` | `true` when the Nitro HybridObject is resolvable. | + +## Types + +### SyncItemInput + +```typescript +interface SyncItemInput { + method: HttpMethod; + url: string; + headers?: Record; + body?: string; + contentType?: string; + priority?: SyncPriority; + metadata?: Record; +} +``` + +> The `id` and `createdAt` fields are intentionally omitted — the native layer assigns a ULID at `id` and an epoch-millisecond `createdAt` so the queue can guarantee idempotency and monotonic ordering even when JS and native clocks disagree. + +### SyncItem + +```typescript +interface SyncItem { + id: string; + method: HttpMethod; + url: string; + headers?: Record; + body?: string; + contentType?: string; + priority?: SyncPriority; + createdAt: number; + metadata?: Record; +} +``` + +### RetryPolicy + +```typescript +interface RetryPolicy { + maxAttempts: number; + backoff: BackoffStrategy; + baseDelayMs: number; + maxDelayMs: number; + jitter: boolean; + retryOnStatusCodes: number[]; +} +``` + +> Network errors and timeouts are always retried regardless of `retryOnStatusCodes`. Non-listed 4xx/5xx responses are treated as permanent failures. The optional per-attempt `shouldRetry(error)` callback is deferred to v0.2 — it requires a JS-alive bridge round-trip on every retry decision. + +### SyncOptions + +```typescript +interface SyncOptions { + strategy: SyncStrategy; + retryPolicy: RetryPolicy; + batchSize?: number; + requestTimeoutMs?: number; + maxQueueSize?: number; + persistQueue?: boolean; + defaultHeaders?: Record; +} +``` + +### SyncResult + +```typescript +interface SyncResult { + startedAt: number; + finishedAt: number; + successCount: number; + failureCount: number; + succeededIds: string[]; + failedIds: string[]; + errors: Record; +} +``` + +### SyncEvent + +```typescript +interface SyncEvent { + type: SyncEventType; + timestamp: number; + itemId?: string; + progress?: number; // SYNC_PROGRESS only + errorCode?: SyncErrorCode; // SYNC_FAILED, ITEM_FAILED, ITEM_RETRYING + statusCode?: number; // ITEM_SUCCEEDED, ITEM_FAILED + attempt?: number; // ITEM_RETRYING (1-based) + connectionStatus?: ConnectionStatus; // CONNECTION_CHANGED + metadata?: Record; +} +``` + +### BackgroundSyncOptions + +```typescript +interface BackgroundSyncOptions { + minimumIntervalMs: number; + requiresCharging?: boolean; + requiresUnmeteredNetwork?: boolean; + requiresDeviceIdle?: boolean; + taskIdentifier?: string; +} +``` + +> Android `WorkManager` clamps `minimumIntervalMs` to a minimum of 15 minutes for periodic work. iOS `BGTaskScheduler` treats it as a soft hint and schedules at its discretion. + +### ConnectionState + +```typescript +interface ConnectionState { + status: ConnectionStatus; + type: ConnectionType; + isInternetReachable?: boolean; + isExpensive?: boolean; +} +``` + +### SyncError + +```typescript +class SyncError extends Error { + readonly code: SyncErrorCode; + readonly cause?: unknown; +} +``` + +> Every public function rejects with a `SyncError` carrying a stable `code: SyncErrorCode`. Pattern-match on `error.code` for typed error handling — the `instanceof` check is preserved across the native bridge. + +## Enums + +### SyncStrategy + +| Value | Description | +| --------------- | ---------------------------------------------------------------------------------------------------------- | +| `AUTOMATIC` | Flush as soon as the queue is non-empty and connectivity allows. | +| `MANUAL` | Queue is only drained when the consumer explicitly calls `flush()`. | +| `OPPORTUNISTIC` | Like `AUTOMATIC`, but only on opportunistic windows (foreground resume, charging, unmetered network). | + +### SyncPriority + +| Value | Description | +| --------- | -------------------------------------------------------------------- | +| `HIGH` | Drains before `NORMAL`/`LOW`. Use sparingly to avoid starvation. | +| `NORMAL` | Default bucket. | +| `LOW` | Drains after `HIGH`/`NORMAL`. Suitable for telemetry and analytics. | + +### BackoffStrategy + +| Value | Formula | +| ------------- | -------------------------------------------------------- | +| `LINEAR` | `delay = baseDelayMs * attempt` (capped at `maxDelayMs`).| +| `EXPONENTIAL` | `delay = baseDelayMs * 2^(attempt - 1)` (capped). | +| `FIBONACCI` | `delay = baseDelayMs * fib(attempt)` (capped). | + +### HttpMethod + +| Value | Notes | +| --------- | ------------------------------------------------------ | +| `GET` | Must not carry a body. | +| `POST` | Typically carries a body. | +| `PUT` | Full-resource replace. | +| `PATCH` | Partial update. | +| `DELETE` | Body is optional and discouraged. | + +### ConnectionStatus + +| Value | Description | +| -------------- | ---------------------------------------------------------------------------- | +| `CONNECTED` | Device has an active internet path (Wi-Fi, cellular, ethernet, etc.). | +| `DISCONNECTED` | Device is offline — no usable network path is available. | +| `METERED` | Online but the active link is metered. Background sync is more conservative. | +| `UNKNOWN` | Connectivity could not be determined yet. | + +### ConnectionType + +| Value | Description | +| ----------- | ---------------------------------------------------------- | +| `WIFI` | Wi-Fi network (typically unmetered). | +| `CELLULAR` | Cellular data (LTE, 5G, etc.). Always treat as metered. | +| `ETHERNET` | Wired ethernet (tablets with USB-Ethernet adapters, tvOS). | +| `BLUETOOTH` | Personal area network bridged over Bluetooth. | +| `VPN` | Active VPN tunnel. | +| `OTHER` | Transport not covered above. | +| `NONE` | No transport — paired with `DISCONNECTED`. | +| `UNKNOWN` | Transport could not be determined yet. | + +### SyncEventType + +| Value | Triggered when | +| --------------------------- | ------------------------------------------------------------------------------- | +| `ITEM_ENQUEUED` | A new item was accepted into the queue. | +| `ITEM_REMOVED` | An item was removed (manual or post-success). | +| `SYNC_STARTED` | A flush cycle has begun. | +| `SYNC_PROGRESS` | Coarse progress update for the current flush (`progress` in `[0, 1]`). | +| `SYNC_SUCCEEDED` | The current flush cycle finished with no failures. | +| `SYNC_FAILED` | The current flush cycle finished with at least one failure. | +| `ITEM_SUCCEEDED` | A single item dispatched successfully. | +| `ITEM_FAILED` | A single item failed permanently (retry budget exhausted). | +| `ITEM_RETRYING` | A single item was scheduled for retry. | +| `QUEUE_CLEARED` | The queue was cleared (`clearQueue()` or `maxQueueSize` reached). | +| `CONNECTION_CHANGED` | Network connectivity changed. | +| `PAUSED` | Sync was paused via `pauseSync()`. | +| `RESUMED` | Sync was resumed via `resumeSync()`. | +| `BACKGROUND_SYNC_STARTED` | OS-scheduled background sync window started. | +| `BACKGROUND_SYNC_COMPLETED` | OS-scheduled background sync window finished. | + +### SyncErrorCode + +| Value | Permanent? | Thrown by | +| ------------------------------------- | :--------: | ----------------------------------------------- | +| `NETWORK_ERROR` | no | `flush` | +| `SERVER_ERROR` | no | `flush` | +| `TIMEOUT` | no | `flush` | +| `MAX_ATTEMPTS_EXCEEDED` | yes | `flush` (per-item; surfaced in `SyncResult.errors`) | +| `UNAUTHORIZED` | yes | `flush` | +| `INVALID_PAYLOAD` | yes | `enqueue`, `enqueueBatch` | +| `INVALID_URL` | yes | `enqueue`, `enqueueBatch` | +| `QUEUE_FULL` | yes | `enqueue`, `enqueueBatch` | +| `DUPLICATE_ITEM` | yes | `enqueue`, `enqueueBatch` | +| `BACKGROUND_TASK_REGISTRATION_FAILED` | yes | `enableBackgroundSync` | +| `NATIVE_MODULE_UNAVAILABLE` | yes | every facade method when the native module is missing | ## How it compares | Concern | `react-native-sync-provider` | `@tanstack/react-query` (offline mutations) | `redux-offline` | `react-native-queue` (legacy) | | ----------------------------------------- | :--------------------------: | :-----------------------------------------: | :-------------: | :---------------------------: | -| Native persistence (survives force-close) | ✅ | ❌ | ⚠️ | ⚠️ | -| Background sync (app closed) | ✅ | ❌ | ❌ | ❌ | -| New Architecture / JSI | ✅ | n/a | n/a | ❌ | -| Configurable retry + jitter | ✅ | ✅ | ⚠️ | ✅ | -| Priority lanes | ✅ | ❌ | ❌ | ✅ | -| Connectivity-aware flush | ✅ | ⚠️ | ✅ | ⚠️ | -| TypeScript-first | ✅ | ✅ | ⚠️ | ⚠️ | +| Native persistence (survives force-close) | yes | no | partial | partial | +| Background sync (app closed) | yes | no | no | no | +| New Architecture / JSI | yes | n/a | n/a | no | +| Configurable retry + jitter | yes | yes | partial | yes | +| Priority lanes | yes | no | no | yes | +| Connectivity-aware flush | yes | partial | yes | partial | +| TypeScript-first | yes | yes | partial | partial | -`✅` first-class · `⚠️` partial / requires plugins · `❌` not supported +`yes` first-class · `partial` requires plugins / not the default · `no` not supported ## Ecosystem @@ -226,29 +536,61 @@ Use them independently or together — for example, pipe `useLocationUpdates()` ## Documentation -Full reference is live at ****: +Browse the **[full documentation site](https://gabriel-sisjr.github.io/react-native-sync-provider/)** for comprehensive guides, API reference, and production checklists. + +### Getting Started - [Introduction](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/getting-started/introduction) -- [Installation](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/getting-started/installation) -- [Quick Start](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/getting-started/quick-start) +- [Installation](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/getting-started/installation) — Detailed setup for existing apps +- [Quick Start](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/getting-started/quick-start) — Get running in 5 minutes - [iOS Setup](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/getting-started/ios-setup) · [Android Setup](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/getting-started/android-setup) -- Guides: [Offline Queue](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/offline-queue) · [Background Sync](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/background-sync) · [Retry Policy](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/retry-policy) · [Connectivity](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/connectivity-detection) · [Error Handling](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/error-handling) · [Priority & Ordering](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/priority-and-ordering) · [Idempotency](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/idempotency) -- API: [Functions](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/functions) · [Hooks](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/hooks/useSyncQueue) · [Types](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/types) · [Enums](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/enums) · [Errors](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/errors) -- Architecture: [Overview](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/architecture/overview) · [iOS](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/architecture/ios-native) · [Android](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/architecture/android-native) -- [Troubleshooting](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/troubleshooting) -## Roadmap +### Guides + +- [Offline Queue](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/offline-queue) — Queue lifecycle and contracts +- [Background Sync](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/background-sync) — `BGTaskScheduler` + `WorkManager` deep dive +- [Retry Policy](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/retry-policy) — Backoff curves, jitter, status-code allowlists +- [Connectivity Detection](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/connectivity-detection) — `NWPathMonitor` / `ConnectivityManager` +- [Error Handling](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/error-handling) — `SyncError` pattern matching +- [Priority & Ordering](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/priority-and-ordering) — Per-item priority semantics +- [Idempotency](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/idempotency) — Native ULIDs + `Idempotency-Key` + +### API Reference + +- [Functions](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/functions) +- [Hooks](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/hooks/useSyncQueue) +- [Types](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/types) +- [Enums](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/enums) +- [Errors](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/errors) +- [Listeners](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/listeners) +- [Context](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/api-reference/context) + +### Architecture + +- [Overview](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/architecture/overview) +- [Data Flow](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/architecture/data-flow) +- [iOS Native](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/architecture/ios-native) +- [Android Native](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/architecture/android-native) -The **ALPHA VERSION** of library is being delivered iteratively toward `v0.1.0`. +### Production -| Milestone | Deliverable | -| --------- | --------------------------------------------------- | -| **M1** | API frozen (Nitro spec + types + errors). | -| **M2** | JS-only beta — testable in simulators / web mocks. | -| **M3** | iOS feature complete (Core Data + BGTaskScheduler). | -| **M4** | Android feature complete (Room + WorkManager). | -| **M5** | Docs site live on GitHub Pages. | -| **M6** | `v0.1.0` published to npm. | +- [Production Checklist](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/production/production-checklist) +- [Privacy Manifest (iOS)](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/production/privacy-manifest) +- [iOS Background Modes](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/production/ios-background-modes) +- [Android Permissions](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/production/android-permissions) + +### Help + +- [Troubleshooting Guide](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/troubleshooting) — Symptom → cause → fix for common integration issues + +## Platform Support + +| Platform | Status | Notes | +| -------- | --------- | ------------------------------------------------------------------------------------- | +| Android | Supported | Kotlin native implementation. Min SDK 24, target SDK 36. Room + WorkManager + OkHttp. | +| iOS | Supported | Swift native implementation. iOS 13+. Core Data + URLSession + BGTaskScheduler. | + +> **New Architecture is required.** The library is built on [Nitro Modules](https://nitro.margelo.com/), which require Fabric + TurboModules. See the [Platform Comparison](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/architecture/overview) for the cross-platform behavior matrix. ## FAQ @@ -262,23 +604,22 @@ Yes, via Expo Dev Client / EAS Build (custom native code). It will not run on Ex You'd be writing all of this yourself: native persistence, retry policy, priority ordering, connectivity reaction, idempotency, recovery from interrupted in-flight items, queue inspection from JS, and React bindings. This library packages that. **Q. Can I send multipart/form-data uploads?** -Yes — set `metadata.contentType = 'multipart/form-data'` and provide the body either as Base64 in `body` or as a file URL via `metadata.bodyFileUri`. See `docs/advanced/multipart-uploads.md` (ships with M5). +Yes — set `metadata.contentType = 'multipart/form-data'` and provide the body either as Base64 in `body` or as a file URL via `metadata.bodyFileUri`. See the [multipart uploads guide](https://gabriel-sisjr.github.io/react-native-sync-provider/docs/guides/offline-queue) for the full recipe. **Q. What happens to in-flight items if the OS kills the process mid-upload?** On next launch (or next `WorkManager` tick), the recovery layer atomically marks orphaned items as `pending` and they re-enter the queue. Combined with the ULID `id` and a server-side `Idempotency-Key` header (set via `metadata`), duplicates are eliminated. **Q. Is the queue encrypted at rest?** -Not by default. Roadmap item for v0.2: optional integration with `react-native-mmkv` encrypted storage for sensitive headers/bodies. +Not by default — encrypted storage at rest is a future enhancement under consideration. ## Contributing -- [Development workflow](CONTRIBUTING.md#development-workflow) -- [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request) -- [Code of conduct](CODE_OF_CONDUCT.md) +Contributions are welcome. See the [Contributing Guide](CONTRIBUTING.md) for development workflow, coding standards, and how to submit pull requests. This library uses [Nitro Modules](https://nitro.margelo.com/). Whenever you change `src/SyncProvider.nitro.ts`, you **must** run `yarn nitrogen` to regenerate the native bindings before the example app will compile. ## License MIT © [Gabriel Santana](https://github.com/gabriel-sisjr) -Powered by [Nitro Modules](https://nitro.margelo.com/) + +Powered by [Nitro Modules](https://nitro.margelo.com/). diff --git a/SyncProvider.podspec b/SyncProvider.podspec index 17f5467..019cab1 100644 --- a/SyncProvider.podspec +++ b/SyncProvider.podspec @@ -65,10 +65,12 @@ Pod::Spec.new do |s| install_modules_dependencies(s) # ─── Unit-test sub-spec ──────────────────────────────────────────────── - # XCTest target generated by CocoaPods at `pod install` time. The host - # picks it up under the `SyncProvider-Unit-Tests` scheme inside the - # consumer Pods.xcodeproj. Sources live under `ios/Tests/**` so they ship - # in the same git tree as the production code. + # XCTest target generated by CocoaPods at `pod install` time. Consumer + # Podfiles must opt-in via `:testspecs => ['Tests']` for CocoaPods to + # materialise the scheme in the Pods.xcodeproj. The generated scheme is + # named `SyncProvider-Unit-Tests` because CocoaPods 1.10+ inserts an + # `Unit-` segment for `:test_type => :unit` (the default). Sources live + # under `ios/Tests/**` so they ship in the same git tree as production. s.test_spec 'Tests' do |test_spec| test_spec.platforms = { :ios => "13.0" } test_spec.source_files = "ios/Tests/**/*.swift" diff --git a/context7.json b/context7.json new file mode 100644 index 0000000..dbe5bb1 --- /dev/null +++ b/context7.json @@ -0,0 +1,4 @@ +{ + "url": "https://context7.com/gabriel-sisjr/react-native-sync-provider", + "public_key": "ctx7sk-b91e1fc6-4883-4301-8889-b7805c63b088" +} diff --git a/example/ios/Podfile b/example/ios/Podfile index 2e430a5..cf21350 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -23,6 +23,15 @@ target 'SyncProviderExample' do :app_path => "#{Pod::Config.instance.installation_root}/.." ) + # Opt the SyncProvider pod into its `Tests` test_spec so CocoaPods + # generates the XCTest target + shared scheme inside the Pods.xcodeproj + # that lives in this workspace. CocoaPods 1.10+ derives the scheme name + # by inserting `Unit-` for `:test_type => :unit` (the default), so the + # final scheme is named `SyncProvider-Unit-Tests`. The host app itself + # does not link against the test target; we just need pod install to + # materialise it so CI can run `xcodebuild test -scheme SyncProvider-Unit-Tests`. + pod 'SyncProvider', :path => '../..', :testspecs => ['Tests'] + post_install do |installer| react_native_post_install( installer, @@ -30,5 +39,12 @@ target 'SyncProviderExample' do :mac_catalyst_enabled => false, # :ccache_enabled => true ) + + # Ensure CocoaPods-generated schemes (including `SyncProvider-Unit-Tests`) + # are written to `xcshareddata/xcschemes/` so `xcodebuild -workspace ... + # -scheme ...` (CI) can discover them. CocoaPods 1.10+ shares schemes by + # default, but we re-create them here defensively in case a future RN + # post_install or an upstream regression flips them back to user-data. + installer.pods_project.recreate_user_schemes(false) end end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 438989a..fcbfcd0 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1954,6 +1954,30 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - SyncProvider/Tests (0.1.0): + - hermes-engine + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - Yoga (0.0.0) DEPENDENCIES: @@ -2036,6 +2060,7 @@ DEPENDENCIES: - ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) - RNScreens (from `../node_modules/react-native-screens`) - SyncProvider (from `../..`) + - SyncProvider/Tests (from `../..`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) EXTERNAL SOURCES: @@ -2277,9 +2302,9 @@ SPEC CHECKSUMS: ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360 ReactNativeDependencies: da8e9df65d9712acfe42f0674d770c12fe7f39b1 RNScreens: a6a509f05ea74d50abab8c21b319b33cfca1ea1c - SyncProvider: b737d5e0e529ac9774216f49d5af87b0778da3e8 + SyncProvider: e3f05351afe482684a8fa9fc869d02ecf725b0ff Yoga: e83c3121d079541e69f3c5c623faaaf933fb5812 -PODFILE CHECKSUM: 71598b6271e231c4857d279dfa2afa9cb757e756 +PODFILE CHECKSUM: a50b973fcddec24d4ac90f9eec5b77f8c93b061d COCOAPODS: 1.15.2 diff --git a/ios/Background/BackgroundSyncManager.swift b/ios/Background/BackgroundSyncManager.swift index e05022f..1f69bbb 100644 --- a/ios/Background/BackgroundSyncManager.swift +++ b/ios/Background/BackgroundSyncManager.swift @@ -47,9 +47,7 @@ final class BackgroundSyncManager: @unchecked Sendable { } var isEnabled: Bool { - lock.lock() - defer { lock.unlock() } - return enabled + lock.withLock { enabled } } /// Register handlers for both task identifiers. Must be called before @@ -58,10 +56,11 @@ final class BackgroundSyncManager: @unchecked Sendable { func registerHandlers() { #if canImport(BackgroundTasks) if #available(iOS 13.0, *) { - lock.lock() - let already = registered - registered = true - lock.unlock() + let already = lock.withLock { () -> Bool in + let wasRegistered = registered + registered = true + return wasRegistered + } guard !already else { return } let scheduler = BGTaskScheduler.shared @@ -94,10 +93,10 @@ final class BackgroundSyncManager: @unchecked Sendable { if #available(iOS 13.0, *) { registerHandlers() try scheduleAppRefresh(options: options) - lock.lock() - enabled = true - lastOptions = options - lock.unlock() + lock.withLock { + enabled = true + lastOptions = options + } SyncLogger.info("Background sync enabled (interval=\(options.minimumIntervalMs)ms)", category: "background") } else { throw SyncProviderError.backgroundRegistrationFailed("iOS 13.0 or higher required") @@ -115,9 +114,9 @@ final class BackgroundSyncManager: @unchecked Sendable { BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: kBackgroundProcessingIdentifier) } #endif - lock.lock() - enabled = false - lock.unlock() + lock.withLock { + enabled = false + } SyncLogger.info("Background sync disabled", category: "background") } diff --git a/ios/Connectivity/ConnectivityMonitor.swift b/ios/Connectivity/ConnectivityMonitor.swift index 59d0fcc..c049f50 100644 --- a/ios/Connectivity/ConnectivityMonitor.swift +++ b/ios/Connectivity/ConnectivityMonitor.swift @@ -56,9 +56,7 @@ final class ConnectivityMonitor: @unchecked Sendable { /// The last observed snapshot. Thread-safe. var current: ConnectionSnapshot { - lock.lock() - defer { lock.unlock() } - return _current + lock.withLock { _current } } private init(monitor: NWPathMonitor = NWPathMonitor()) { @@ -69,12 +67,13 @@ final class ConnectivityMonitor: @unchecked Sendable { /// Start the monitor. Idempotent. func start() { - lock.lock() - let alreadyStarted = didStart - if !alreadyStarted { - didStart = true + let alreadyStarted = lock.withLock { () -> Bool in + let wasStarted = didStart + if !wasStarted { + didStart = true + } + return wasStarted } - lock.unlock() guard !alreadyStarted else { return } @@ -87,10 +86,10 @@ final class ConnectivityMonitor: @unchecked Sendable { /// Stop the monitor. Mostly used by tests. func stop() { monitor.cancel() - lock.lock() - didStart = false - listeners.removeAll() - lock.unlock() + lock.withLock { + didStart = false + listeners.removeAll() + } } /// Subscribe for change notifications. Returns a token that can be passed @@ -98,10 +97,10 @@ final class ConnectivityMonitor: @unchecked Sendable { @discardableResult func subscribe(_ listener: @escaping @Sendable (ConnectionSnapshot) -> Void) -> UUID { let id = UUID() - lock.lock() - listeners[id] = listener - let snapshot = _current - lock.unlock() + let snapshot = lock.withLock { () -> ConnectionSnapshot in + listeners[id] = listener + return _current + } // Replay the current snapshot synchronously so subscribers always see // a value rather than waiting for the next path update. listener(snapshot) @@ -109,9 +108,9 @@ final class ConnectivityMonitor: @unchecked Sendable { } func unsubscribe(_ token: UUID) { - lock.lock() - listeners.removeValue(forKey: token) - lock.unlock() + lock.withLock { + listeners.removeValue(forKey: token) + } } // MARK: - Path translation @@ -119,10 +118,10 @@ final class ConnectivityMonitor: @unchecked Sendable { private func handlePathUpdate(_ path: NWPath) { let snapshot = Self.translate(path) - lock.lock() - _current = snapshot - let snapshotListeners = Array(listeners.values) - lock.unlock() + let snapshotListeners = lock.withLock { () -> [@Sendable (ConnectionSnapshot) -> Void] in + _current = snapshot + return Array(listeners.values) + } SyncLogger.debug("Connectivity changed: status=\(snapshot.status.rawValue) type=\(snapshot.type.rawValue) expensive=\(snapshot.isExpensive ?? false)", category: "connectivity") diff --git a/ios/Database/SyncItemDao.swift b/ios/Database/SyncItemDao.swift index def7a83..5932f6f 100644 --- a/ios/Database/SyncItemDao.swift +++ b/ios/Database/SyncItemDao.swift @@ -84,7 +84,7 @@ final class SyncItemDao { entity.priority = item.priority entity.createdAt = item.createdAt entity.attempts = item.attempts - entity.lastAttemptAt = item.lastAttemptAt.map { NSNumber(value: $0) } + entity.lastAttemptAt = item.lastAttemptAt ?? 0 entity.lastErrorCode = item.lastErrorCode entity.status = item.status entity.metadataJSON = try encodeJSON(item.metadata) @@ -160,7 +160,7 @@ final class SyncItemDao { guard let entity = try Self.fetchEntity(id: id, in: context) else { return } entity.status = SyncItemStatus.inFlight.rawValue entity.attempts += 1 - entity.lastAttemptAt = Int64(Date().timeIntervalSince1970 * 1000) + entity.lastAttemptAt = Int64((Date().timeIntervalSince1970 * 1000).rounded()) } } @@ -178,7 +178,7 @@ final class SyncItemDao { guard let entity = try Self.fetchEntity(id: id, in: context) else { return } entity.status = SyncItemStatus.pending.rawValue entity.lastErrorCode = errorCode - entity.lastAttemptAt = Int64(Date().timeIntervalSince1970 * 1000) + entity.lastAttemptAt = Int64((Date().timeIntervalSince1970 * 1000).rounded()) } } @@ -291,7 +291,10 @@ final class SyncItemDao { priority: entity.priority ?? "NORMAL", createdAt: entity.createdAt, attempts: entity.attempts, - lastAttemptAt: entity.lastAttemptAt?.int64Value, + // Core Data stores `lastAttemptAt` as a scalar `Int64` with `0` + // sentinel meaning "never attempted"; map back to `Int64?` so the + // public-facing `StoredSyncItem` keeps nullable semantics. + lastAttemptAt: entity.lastAttemptAt == 0 ? nil : entity.lastAttemptAt, lastErrorCode: entity.lastErrorCode, status: entity.status ?? SyncItemStatus.pending.rawValue, metadata: metadata diff --git a/ios/Database/SyncItemEntity+CoreDataClass.swift b/ios/Database/SyncItemEntity+CoreDataClass.swift index 7c35aff..705c0c8 100644 --- a/ios/Database/SyncItemEntity+CoreDataClass.swift +++ b/ios/Database/SyncItemEntity+CoreDataClass.swift @@ -23,7 +23,7 @@ public final class SyncItemEntity: NSManagedObject { @NSManaged public var createdAt: Int64 @NSManaged public var headersJSON: Data? @NSManaged public var id: String? - @NSManaged public var lastAttemptAt: NSNumber? + @NSManaged public var lastAttemptAt: Int64 @NSManaged public var lastErrorCode: String? @NSManaged public var metadataJSON: Data? @NSManaged public var method: String? diff --git a/ios/Database/SyncProvider.xcdatamodeld/SyncProvider.xcdatamodel/contents b/ios/Database/SyncProvider.xcdatamodeld/SyncProvider.xcdatamodel/contents index 77f36c9..3dcbfda 100644 --- a/ios/Database/SyncProvider.xcdatamodeld/SyncProvider.xcdatamodel/contents +++ b/ios/Database/SyncProvider.xcdatamodeld/SyncProvider.xcdatamodel/contents @@ -7,7 +7,7 @@ - + diff --git a/ios/Events/SyncEventEmitter.swift b/ios/Events/SyncEventEmitter.swift index c0003bb..932767c 100644 --- a/ios/Events/SyncEventEmitter.swift +++ b/ios/Events/SyncEventEmitter.swift @@ -65,33 +65,33 @@ final class SyncEventEmitter: @unchecked Sendable { message: "Unknown event channel '\(channel)'. Use '\(kSyncEventChannel)'.") } let id = UUID().uuidString - lock.lock() - var bucket = listeners[channel] ?? [:] - bucket[id] = callback - listeners[channel] = bucket - lock.unlock() + lock.withLock { + var bucket = listeners[channel] ?? [:] + bucket[id] = callback + listeners[channel] = bucket + } return id } /// Remove a previously registered listener. No-op if the id is unknown. func removeListener(channel: String, id: String) { - lock.lock() - var bucket = listeners[channel] ?? [:] - bucket.removeValue(forKey: id) - if bucket.isEmpty { - listeners.removeValue(forKey: channel) - } else { - listeners[channel] = bucket + lock.withLock { + var bucket = listeners[channel] ?? [:] + bucket.removeValue(forKey: id) + if bucket.isEmpty { + listeners.removeValue(forKey: channel) + } else { + listeners[channel] = bucket + } } - lock.unlock() } /// Snapshot + invoke listeners on the main thread (consistent with React /// Native event delivery). func emit(_ payload: SyncEventPayload) { - lock.lock() - let snapshot = listeners[kSyncEventChannel] ?? [:] - lock.unlock() + let snapshot = lock.withLock { + listeners[kSyncEventChannel] ?? [:] + } guard !snapshot.isEmpty else { return } @@ -109,8 +109,8 @@ final class SyncEventEmitter: @unchecked Sendable { /// Drop all listeners. Used during teardown / tests. func removeAll() { - lock.lock() - listeners.removeAll() - lock.unlock() + lock.withLock { + listeners.removeAll() + } } } diff --git a/ios/HTTP/BackgroundURLSessionDelegate.swift b/ios/HTTP/BackgroundURLSessionDelegate.swift index e5a659a..30c40cb 100644 --- a/ios/HTTP/BackgroundURLSessionDelegate.swift +++ b/ios/HTTP/BackgroundURLSessionDelegate.swift @@ -38,25 +38,25 @@ final class BackgroundURLSessionDelegate: NSObject, URLSessionDelegate, URLSessi /// Register a per-task completion handler keyed by `task.taskDescription` /// (the `SyncItem.id`). func registerCompletion(itemId: String, handler: @escaping BackgroundCompletionHandler) { - lock.lock() - completions[itemId] = handler - lock.unlock() + lock.withLock { + completions[itemId] = handler + } } /// Park the system-supplied completion handler so we can fire it when the /// session reports `urlSessionDidFinishEvents(forBackgroundURLSession:)`. func storeEventsCompletionHandler(_ handler: @escaping BackgroundEventsCompletionHandler) { - lock.lock() - eventsCompletionHandler = handler - lock.unlock() + lock.withLock { + eventsCompletionHandler = handler + } } // MARK: - URLSessionDataDelegate func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - lock.lock() - responseData[dataTask.taskIdentifier, default: Data()].append(data) - lock.unlock() + lock.withLock { + responseData[dataTask.taskIdentifier, default: Data()].append(data) + } } // MARK: - URLSessionTaskDelegate @@ -67,10 +67,11 @@ final class BackgroundURLSessionDelegate: NSObject, URLSessionDelegate, URLSessi let itemId = task.taskDescription ?? "" let statusCode = (task.response as? HTTPURLResponse)?.statusCode - lock.lock() - let handler = completions.removeValue(forKey: itemId) - responseData.removeValue(forKey: task.taskIdentifier) - lock.unlock() + let handler = lock.withLock { () -> BackgroundCompletionHandler? in + let removed = completions.removeValue(forKey: itemId) + responseData.removeValue(forKey: task.taskIdentifier) + return removed + } if itemId.isEmpty { SyncLogger.error("Background task completed with no taskDescription — cannot route outcome", @@ -87,10 +88,11 @@ final class BackgroundURLSessionDelegate: NSObject, URLSessionDelegate, URLSessi // MARK: - URLSessionDelegate func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { - lock.lock() - let handler = eventsCompletionHandler - eventsCompletionHandler = nil - lock.unlock() + let handler = lock.withLock { () -> BackgroundEventsCompletionHandler? in + let parked = eventsCompletionHandler + eventsCompletionHandler = nil + return parked + } if let handler = handler { DispatchQueue.main.async(execute: handler) diff --git a/ios/SyncProvider.swift b/ios/SyncProvider.swift index 707fefb..185888e 100644 --- a/ios/SyncProvider.swift +++ b/ios/SyncProvider.swift @@ -14,10 +14,17 @@ import NitroModules /// The class name *must* match `nitro.json → autolinking.SyncProvider.ios.implementationClassName`. /// +/// We name the Swift type `HybridSyncProvider` (not `SyncProvider`) to avoid +/// a symbol collision with the C++ `SyncProvider` class generated by Nitro +/// inside the `margelo::nitro::syncprovider` namespace. The Nitro `iosModuleName` +/// (`SyncProvider`) and the logical module name used from JS +/// (`NitroModules.createHybridObject('SyncProvider')`) remain +/// unchanged — only the underlying Swift class name is different. +/// /// Although Nitro instantiates this class via the generated factory glue /// (so internal access would be enough for that path), we keep the class /// `public` so the host AppDelegate can call ``handleBackgroundURLSessionEvents(identifier:completionHandler:)``. -public final class SyncProvider: HybridSyncProviderSpec { +public final class HybridSyncProvider: HybridSyncProviderSpec { // MARK: - Components private let storage: SyncQueueStorage @@ -53,7 +60,7 @@ public final class SyncProvider: HybridSyncProviderSpec { super.init() // Make this instance reachable from the AppDelegate hook. - SyncProvider.weakInstance = self + HybridSyncProvider.weakInstance = self // Eagerly start the connectivity monitor so the first read returns a // real snapshot rather than the .unknown default. @@ -213,9 +220,9 @@ public final class SyncProvider: HybridSyncProviderSpec { return Promise.async { [weak self] in guard let self = self else { return } let snapshot = Self.translate(options: options) - self.configLock.lock() - self.lastSyncOptions = snapshot - self.configLock.unlock() + self.configLock.withLock { + self.lastSyncOptions = snapshot + } await self.storage.updateCapacity(Int(snapshot.maxQueueSize)) await self.dispatcher.updateConfig(snapshot.toDispatcherConfig()) @@ -227,9 +234,9 @@ public final class SyncProvider: HybridSyncProviderSpec { guard let self = self else { return Self.translate(snapshot: .default) } - self.configLock.lock() - let snapshot = self.lastSyncOptions - self.configLock.unlock() + let snapshot = self.configLock.withLock { + self.lastSyncOptions + } return Self.translate(snapshot: snapshot) } } @@ -353,7 +360,7 @@ public final class SyncProvider: HybridSyncProviderSpec { identifier: String, completionHandler: @escaping () -> Void ) { - if let live = SyncProvider.weakInstance { + if let live = HybridSyncProvider.weakInstance { live.dispatcher.handleBackgroundURLSessionEvents( identifier: identifier, completionHandler: completionHandler @@ -367,12 +374,12 @@ public final class SyncProvider: HybridSyncProviderSpec { /// Weak reference to the most recently created instance, used solely by /// the AppDelegate hook. The Nitro runtime owns the strong reference. - fileprivate static weak var weakInstance: SyncProvider? + fileprivate static weak var weakInstance: HybridSyncProvider? } // MARK: - Translation helpers -extension SyncProvider { +extension HybridSyncProvider { /// Sendable mirror of `SyncOptions` used to keep the Nitro C++ struct out /// of the `actor` boundary. struct SyncOptionsSnapshot: Sendable, Equatable { diff --git a/ios/Tests/Support/InMemoryCoreDataStack.swift b/ios/Tests/Support/InMemoryCoreDataStack.swift index 7693c2b..2ee64d7 100644 --- a/ios/Tests/Support/InMemoryCoreDataStack.swift +++ b/ios/Tests/Support/InMemoryCoreDataStack.swift @@ -23,7 +23,7 @@ enum InMemoryCoreDataStack { attribute("createdAt", type: .integer64AttributeType, optional: false, defaultValue: 0), attribute("headersJSON", type: .binaryDataAttributeType, optional: true), attribute("id", type: .stringAttributeType, optional: false), - attribute("lastAttemptAt", type: .integer64AttributeType, optional: true, usesScalar: false), + attribute("lastAttemptAt", type: .integer64AttributeType, optional: false, defaultValue: 0), attribute("lastErrorCode", type: .stringAttributeType, optional: true), attribute("metadataJSON", type: .binaryDataAttributeType, optional: true), attribute("method", type: .stringAttributeType, optional: false, defaultValue: "POST"), diff --git a/nitro.json b/nitro.json index 3428507..578bf76 100644 --- a/nitro.json +++ b/nitro.json @@ -11,7 +11,7 @@ "SyncProvider": { "ios": { "language": "swift", - "implementationClassName": "SyncProvider" + "implementationClassName": "HybridSyncProvider" }, "android": { "language": "kotlin", diff --git a/package.json b/package.json index 39bc53e..93c969f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@gabriel-sisjr/react-native-sync-provider", - "version": "0.1.0", - "description": "Provider to Sync", + "version": "0.1.1", + "description": "React Native library for offline-first HTTP sync using Nitro Modules. Queue requests with Core Data on iOS and Room on Android, dispatch them in the background via BGTaskScheduler and WorkManager, and react to connectivity changes through a hooks-based facade — even when the app is killed.", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", "sideEffects": false, @@ -56,37 +56,43 @@ }, "keywords": [ "react-native", + "react-native-sync-provider", "ios", "android", "nitro", "nitro-module", + "nitro-modules", + "hybrid-object", "sync", "synchronization", "offline", "offline-first", + "outbox", "queue", "background-sync", - "mmkv", - "netinfo", "connectivity", "retry", - "kotlin", - "swift", - "typescript", - "hybrid-object", + "http", + "workmanager", + "bgtaskscheduler", + "coredata", + "room", "persistence", + "persistent-storage", "network", "reactive", + "real-time", + "event-driven", "hooks", + "useSyncQueue", + "useConnection", "new-architecture", "fabric", + "kotlin", + "swift", + "typescript", "mobile", - "cross-platform", - "http", - "workmanager", - "bgtaskscheduler", - "coredata", - "room" + "cross-platform" ], "repository": { "type": "git", @@ -97,7 +103,7 @@ "bugs": { "url": "https://github.com/gabriel-sisjr/react-native-sync-provider/issues" }, - "homepage": "https://github.com/gabriel-sisjr/react-native-sync-provider#readme", + "homepage": "https://gabriel-sisjr.github.io/react-native-sync-provider/", "publishConfig": { "registry": "https://registry.npmjs.org/" }, diff --git a/website/docs/architecture/ios-native.md b/website/docs/architecture/ios-native.md index 07653a1..bf0b534 100644 --- a/website/docs/architecture/ios-native.md +++ b/website/docs/architecture/ios-native.md @@ -31,7 +31,11 @@ All iOS native code lives under `ios/`. The implementation uses Swift 5.9+, Core | `ios/Utils/` | `ULID` generator and `SyncLogger`. | | `ios/Tests/` | XCTest suite (driven by CocoaPods `s.test_spec 'Tests'`). | -The HybridObject implementation lives in `ios/SyncProvider.swift` (`class SyncProvider: HybridSyncProviderSpec`) and delegates to the components above — it is the JSI front door and contains no business logic. +The HybridObject implementation lives in `ios/SyncProvider.swift` (`final class HybridSyncProvider: HybridSyncProviderSpec`) and delegates to the components above — it is the JSI front door and contains no business logic. + +:::note +The Swift class is intentionally named `HybridSyncProvider`, not `SyncProvider`. Nitro generates a C++ `SyncProvider::SyncProvider` class inside the `margelo::nitro::syncprovider` namespace; reusing the same name from Swift would collide with the generated bridging header (`SyncProvider-Swift.h`) and break the build. The Nitro `iosModuleName` (`SyncProvider`) and the JS lookup (`NitroModules.createHybridObject('SyncProvider')`) are unchanged — only the underlying Swift class name differs. The Kotlin implementation has no analogous collision and remains `class SyncProvider`. The autolinking mapping lives in `nitro.json → autolinking.SyncProvider.ios.implementationClassName: "HybridSyncProvider"`. +::: ## Core Data @@ -112,6 +116,36 @@ The podspec advertises: - `s.resource_bundles = { "SyncProvider_Privacy" => ["ios/PrivacyInfo.xcprivacy"] }` (deliberately a resource bundle, not a top-level resource — the RN 0.85 privacy_manifest aggregator iterates only `file_accessor.resource_bundles`, and listing the manifest as a top-level resource collides with the host app's aggregated manifest with `Multiple commands produce ...PrivacyInfo.xcprivacy`). - `s.exclude_files = ["ios/Tests/**/*"]` so the test sources are owned exclusively by the `s.test_spec 'Tests'` block. +## Running unit tests + +The iOS test target is declared on the library side via `s.test_spec 'Tests'` in `SyncProvider.podspec`. CocoaPods 1.10+ defaults `:test_type` to `:unit` and auto-prefixes the resulting scheme with `Unit-`, so the generated Xcode scheme is **`SyncProvider-Unit-Tests`** (not `SyncProvider-Tests`). We did not rename the test_spec — the segment is added by CocoaPods. + +:::warning +`use_native_modules!` autolinking does **not** propagate `:testspecs` to consuming apps. The example app opts in explicitly in `example/ios/Podfile`: + +```ruby +pod 'SyncProvider', :path => '../..', :testspecs => ['Tests'] +``` + +Without this line, `pod install` will not generate the test target and `xcodebuild -scheme SyncProvider-Unit-Tests` will fail to resolve the scheme. The `post_install` block also calls `installer.pods_project.recreate_user_schemes(false)` to ensure the generated test scheme is shared (the `false` keeps user-state untouched). +::: + +After `yarn nitrogen` and `bundle exec pod install --project-directory=ios` (run from `example/`), the suite can be exercised in two equivalent ways: + +```bash +yarn test:ios +``` + +```bash +cd example/ios +xcodebuild test \ + -workspace SyncProviderExample.xcworkspace \ + -scheme SyncProvider-Unit-Tests \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' +``` + +`yarn test:ios` shells out to `scripts/test-ios.sh`, which mirrors the CI invocation: it removes any stale `build/SyncProviderTests.xcresult` before each attempt (defends against the `"Existing file at -resultBundlePath"` failure mode) and walks a destination fallback list (`iPhone 16` → `iPhone 17` → `iPhone 16 Pro`, all `OS=latest`) so a missing simulator on a single machine does not block the run. The same loop runs in CI (`test-ios` job in `.github/workflows/ci.yml`). + ## Privacy Manifest `ios/PrivacyInfo.xcprivacy` declares exactly one Required Reason API: diff --git a/website/docs/development/contributing.md b/website/docs/development/contributing.md index 712c703..d9c6729 100644 --- a/website/docs/development/contributing.md +++ b/website/docs/development/contributing.md @@ -142,4 +142,4 @@ Skipping these masks real bugs. If a hook fails, fix the cause and re-stage. ## Releases -Phase 8 of the roadmap (Release flow) wires up `release-it` + automated publishes. Until then, releases are manual: bump `package.json`, tag, and `npm publish`. Watch the changelog discipline — every release entry mirrors the Conventional Commits log. +Automated release flow (`release-it` + publish workflows) is planned; until then, releases are manual: bump `package.json`, tag, and `npm publish`. Watch the changelog discipline — every release entry mirrors the Conventional Commits log. diff --git a/website/docs/development/testing.md b/website/docs/development/testing.md index 5ac80dc..00771ef 100644 --- a/website/docs/development/testing.md +++ b/website/docs/development/testing.md @@ -108,19 +108,47 @@ For tests that only need pure JVM behavior (e.g. `RetryPolicyEvaluator`), `testO ## iOS (XCTest) -Lives under `ios/Tests/`. Test target is wired via CocoaPods `s.test_spec 'Tests'` in `SyncProvider.podspec`. After `pod install`, CocoaPods generates the `SyncProvider-Unit-Tests` scheme. +Lives under `ios/Tests/`. The test target is wired on the library side via `s.test_spec 'Tests'` in `SyncProvider.podspec`. CocoaPods 1.10+ defaults `:test_type` to `:unit` and auto-prefixes the resulting scheme with `Unit-`, so `pod install` generates the scheme **`SyncProvider-Unit-Tests`** (not `SyncProvider-Tests`). The library does not rename the test_spec — the segment is inserted by CocoaPods. + +`use_native_modules!` autolinking does **not** propagate `:testspecs` to the consuming app. `example/ios/Podfile` opts in explicitly: + +```ruby +pod 'SyncProvider', :path => '../..', :testspecs => ['Tests'] +``` + +Without this line, CocoaPods will never generate the test target and `xcodebuild` will fail to resolve `SyncProvider-Unit-Tests`. The `post_install` block also calls `installer.pods_project.recreate_user_schemes(false)` to defensively share the generated test scheme. + +### Setup order + +Always run `yarn nitrogen` before `pod install` — the podspec consumes `nitrogen/generated/ios/SyncProvider+autolinking.rb`, which is gitignored and regenerated on every spec change. From the repo root: + +```bash +yarn nitrogen +cd example && bundle install && bundle exec pod install --project-directory=ios +``` ### Running +```bash +yarn test:ios +``` + +This is a thin wrapper over `scripts/test-ios.sh`. It mirrors the CI invocation: it removes any stale `build/SyncProviderTests.xcresult` before each attempt (defends against the `"Existing file at -resultBundlePath"` failure mode) and walks a destination fallback list (`iPhone 16` → `iPhone 17` → `iPhone 16 Pro`, all `OS=latest`) so a missing simulator on a single machine does not block the run. + +The equivalent direct invocation: + ```bash cd example/ios xcodebuild test \ -workspace SyncProviderExample.xcworkspace \ -scheme SyncProvider-Unit-Tests \ - -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' + -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \ + -resultBundlePath build/SyncProviderTests.xcresult \ + -skipPackagePluginValidation \ + -skipMacroValidation ``` -CI runs the same command on `macos-latest` with `XCODE_VERSION=26` and uploads `.xcresult` artifacts. The scheme name is parameterized via the `IOS_TEST_SCHEME` env in the CI workflow — the discovery step (`xcodebuild -list`) prints the actual scheme to the log if defaults change. +CI runs the same command on `macos-latest` with `XCODE_VERSION=26` (see the `test-ios` job in `.github/workflows/ci.yml`), walks the same destination fallback loop, and uploads `.xcresult` artifacts. A "Show available iOS simulators" diagnostic step prints the runtime list before the test step so destination drift is visible in the log. ### Test seams diff --git a/website/docs/guides/retry-policy.md b/website/docs/guides/retry-policy.md index cdd38df..2f6a4ea 100644 --- a/website/docs/guides/retry-policy.md +++ b/website/docs/guides/retry-policy.md @@ -147,7 +147,7 @@ await configureSync({ ## JS-Side Override (deferred to v0.2) -A per-attempt `shouldRetry(ctx)` callback is on the roadmap for v0.2: +A per-attempt `shouldRetry(ctx)` callback is planned for v0.2: ```ts type ShouldRetryFn = (ctx: {