diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a314eb..a887ac0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,26 @@ jobs: - name: Build package run: yarn prepare + - name: Report npm pack size + run: | + PACK_JSON=$(npm pack --dry-run --json --ignore-scripts) + node -e ' + const fs = require("fs"); + const data = JSON.parse(fs.readFileSync(0, "utf8")); + const pkg = Array.isArray(data) ? data[0] : data; + const fmt = (b) => (b / 1024).toFixed(1) + " KiB"; + const lines = [ + "### Package size (npm pack --dry-run)", + "", + "| Metric | Size |", + "|---|---:|", + `| Tarball (packed) | ${fmt(pkg.size)} (${pkg.size} bytes) |`, + `| Unpacked | ${fmt(pkg.unpackedSize)} (${pkg.unpackedSize} bytes) |`, + `| File count | ${pkg.entryCount} |`, + ]; + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join("\n") + "\n"); + ' <<< "$PACK_JSON" + build-android: runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 91e7a77..6215873 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -64,6 +64,14 @@ jobs: if: steps.version-check.outputs.version_changed == 'true' run: yarn nitrogen + - name: Lint files + if: steps.version-check.outputs.version_changed == 'true' + run: yarn lint + + - name: Typecheck files + if: steps.version-check.outputs.version_changed == 'true' + run: yarn typecheck + - name: Build package if: steps.version-check.outputs.version_changed == 'true' run: yarn prepare diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index cd570fb..25d7e5d 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -47,4 +47,27 @@ Use the table at the bottom of each entry to enumerate every renamed, removed, o ## [Unreleased] -No breaking changes yet -- the library is pre-1.0 and the public API surface is still being shaped. Upcoming breaking changes will be cataloged here before each release and moved into a dated section on publish. +### iOS minimum deployment target `13.0` --> `15.0` + +> **Category:** platform-requirement change (**not** a JS/TS API break). Pre-1.0. +> +> **Changelog:** see the [`[Unreleased]` entry in `CHANGELOG.md`](./CHANGELOG.md#unreleased). + +The iOS minimum deployment target declared in `SyncProvider.podspec` is raised from `13.0` to `15.0`. This is a **platform-requirement change**: the public TypeScript surface (functions, hooks, types, enums, error codes) is unchanged, and no consumer JS code needs to be migrated. The blast radius is **install/compile-time on iOS only** — apps that pin an iOS deployment target below `15.0` must raise it. + +**Rationale:** `ios/HTTP/SyncDispatcher.swift` uses the async `URLSession.data(for:)` API, which requires **iOS 15+**. The previous `13.0` floor was latent: the build only passed because the example app overrode `IPHONEOS_DEPLOYMENT_TARGET` to `>= 15.x`, masking the mismatch. Any consumer honoring the declared `13.0` floor would have hit an availability compile error in `SyncDispatcher.swift`. Raising the podspec floor to `15.0` makes the declared requirement match the code. (`BGTaskScheduler`'s iOS 13+ requirement is comfortably covered by the higher floor.) + +#### Migration steps + +1. In your app's `ios/Podfile`, ensure the platform line is at least `platform :ios, '15.0'`. +2. If you set `IPHONEOS_DEPLOYMENT_TARGET` anywhere (Podfile `post_install`, Xcode build settings), make sure it is `>= 15.0`. +3. Run `cd ios && bundle exec pod install`. + +No JS/TS code changes are required. + +#### Affected symbols + +| Symbol / surface | Change | +| ------------------------------------------------------------- | --------------------------- | +| `SyncProvider.podspec` (`s.platforms`, `test_spec.platforms`) | iOS floor `13.0` --> `15.0` | +| Public TypeScript API | none (no JS break) | diff --git a/CHANGELOG.md b/CHANGELOG.md index f79ac9f..74e40f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ 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] + +### Changed + +- **iOS minimum deployment target raised `13.0` → `15.0`** (P7). `SyncProvider.podspec` (`s.platforms` and `test_spec.platforms`) now declares `ios 15.0`. The implementation already relied on `URLSession.data(for:)` (iOS 15+) in `ios/HTTP/SyncDispatcher.swift`; the previous `13.0` floor only compiled because the example app overrode `IPHONEOS_DEPLOYMENT_TARGET`, so any consumer honoring the declared floor would hit an availability compile error. The always-true `if #available(iOS 13.0, *)` / `@available(iOS 13.0, *)` guards in `ios/HTTP/SyncDispatcher.swift` and `ios/Background/BackgroundSyncManager.swift` were removed as dead code. This is a **platform-requirement change, not a JS/TS API break** — see [`BREAKING_CHANGES.md`](./BREAKING_CHANGES.md). + +### Fixed + +- **android:** retry jitter corrected to `[0.75, 1.25]` equal-jitter (P9b). The Android `RetryPolicyEvaluator` previously applied **full-jitter** over `[0, capped]` and capped the delay _before_ jittering, so delays collapsed toward `0` and never reflected the documented spread. It now jitters the raw delay by `[0.75, 1.25]` (`raw * (0.75 + random.nextDouble() * 0.5)`) and applies the cap _after_ jitter, matching the JS (`src/utils/retryBackoff.ts`) and iOS (`ios/Retry/RetryPolicyEvaluator.swift`) implementations and the documented behavior. + +### Internal + +- **iOS:** demoted the Core Data entity classes `SyncItemEntity` and `SyncResultEntity` (and their members) from `public` to internal (P-CI). The `public` access level leaked the entities into the generated `SyncProvider-Swift.h` bridging header, breaking Objective-C translation units (`Cannot find interface declaration for NSManagedObject`) and the iOS CI build. The entities are implementation-only, so this restores green iOS CI with **no public-API impact**. Also updated the example AppDelegate to call the renamed `HybridSyncProvider.handleBackgroundURLSessionEvents`. + ## v0.1.1 - 2025-05-19 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 729ba30..0b39226 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ The full lifecycle for adding or changing a Nitro method: 1. Edit the interface in `src/SyncProvider.nitro.ts`. 2. Run `yarn nitrogen` (regenerates the Swift/Kotlin/C++ base classes — old impls break loudly). 3. Implement the new method in **both** `ios/SyncProvider.swift` and `android/src/main/java/com/margelo/nitro/syncprovider/SyncProvider.kt`. -4. Surface it in the JS facade by re-exporting from `src/index.tsx`. If the method needs platform-specific JS shims, add `src/.native.tsx` and a throwing fallback in `src/.tsx`. +4. Surface it in the JS facade by adding the wrapper directly in `src/index.tsx` (via `nativeOrThrow()`). The facade lives wholly in `src/index.tsx`; the platform split is at the entry level — `src/index.tsx` (native) vs `src/index.web.tsx` (web fallback that throws). When the new function/hook should be unusable on web, add a throwing twin in `src/index.web.tsx`. There is no per-method `.native.tsx` / `.tsx` split. 5. Run `yarn typecheck && yarn lint && yarn test`. 6. Rebuild the example app (`yarn example android` / `ios`) — JS-only changes hot-reload, native changes need a rebuild. @@ -109,6 +109,20 @@ Remember to add tests for your change if possible. Run the unit tests by: yarn test ``` +### Pre-merge checklist + +Run the checks that match what your change touched before opening or merging a PR. The JS gate is **always** required; the native gates and regeneration steps are conditional. + +| Check | Command | When | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| **V-JS** — JS gate (always) | `yarn typecheck && yarn lint && yarn test` | Every change, no exceptions. | +| **V-AND** — Android gate | `yarn nitrogen` then `yarn turbo run build:android`, plus the Gradle unit tests (`gradlew :gabriel-sisjr_react-native-sync-provider:testDebugUnitTest`) | Android native (`android/**`) **or** the Nitro bridge (`src/SyncProvider.nitro.ts`) is touched. | +| **V-IOS** — iOS gate | `cd example && bundle exec pod install --project-directory=ios` then `xcodebuild test` with scheme `SyncProvider-Unit-Tests` | iOS native (`ios/**`) **or** the Nitro bridge is touched. | + +And these correctness gates, regardless of platform: + +- **R-A** — Re-run `yarn nitrogen` on any change to `src/SyncProvider.nitro.ts` or to any spec-reachable type. Stale generated base classes are a silent source of build drift. +- **R-E** — Every new facade function or hook needs a web twin in `src/index.web.tsx` (a throwing fallback for functions, inert state for hooks). A new export with no web twin breaks the web/SSR bundle. ### Commit message convention @@ -123,7 +137,6 @@ We follow the [conventional commits specification](https://www.conventionalcommi Our pre-commit hooks verify that your commit message matches this format when committing. - ### Releasing This project uses [release-it](https://github.com/release-it/release-it) for local version bumping plus a set of GitHub Actions workflows that handle npm publishing, GitHub releases, documentation deploys, and link checking. The flow is intentionally split so that contributors never run `npm publish` from their machines — every published artifact goes through CI. @@ -141,11 +154,11 @@ Before cutting a release, make sure you have: The release workflows require the following secrets to be configured in **Settings → Secrets and variables → Actions**: -| Secret | Required | How to create | Used by | -| ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| `NPM_TOKEN` | yes | On [npmjs.com → Access Tokens](https://docs.npmjs.com/creating-and-viewing-access-tokens), create a **Granular Access Token** with `Read and write` permission for the package and the `--provenance` scope. Paste the token value into the GitHub secret. | `publish.yml`, `prerelease.yml` | -| `CODECOV_TOKEN` | optional | Generated by [Codecov](https://about.codecov.io/) after adding the repo. Only needed if you want coverage uploaded from release runs (CI runs already upload from `ci.yml`). | `publish.yml`, `prerelease.yml` (coverage) | -| `GITHUB_TOKEN` | auto | Provided automatically by GitHub Actions. No manual setup required. | All workflows (tags, releases, Pages deploy) | +| Secret | Required | How to create | Used by | +| --------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| `NPM_TOKEN` | yes | On [npmjs.com → Access Tokens](https://docs.npmjs.com/creating-and-viewing-access-tokens), create a **Granular Access Token** with `Read and write` permission for the package and the `--provenance` scope. Paste the token value into the GitHub secret. | `publish.yml`, `prerelease.yml` | +| `CODECOV_TOKEN` | optional | Generated by [Codecov](https://about.codecov.io/) after adding the repo. Only needed if you want coverage uploaded from release runs (CI runs already upload from `ci.yml`). | `publish.yml`, `prerelease.yml` (coverage) | +| `GITHUB_TOKEN` | auto | Provided automatically by GitHub Actions. No manual setup required. | All workflows (tags, releases, Pages deploy) | > **Token rotation:** `NPM_TOKEN` expires after the lifetime you set on npm. If `publish.yml` starts failing with `403 Forbidden` or `EUNAVAILABLE`, regenerate the token and update the secret. @@ -184,7 +197,7 @@ What happens next is automatic: 1. The push to `main` triggers **`publish.yml`**. 2. The workflow re-reads `package.json`, compares the version against the last version on npm, and only proceeds if it changed (the **version-diff guard**). This is what prevents accidental re-publishes when you push unrelated commits to `main`. -3. CI runs `yarn install --immutable` → `yarn lint` → `yarn typecheck` → `yarn test` → `yarn nitrogen` → `yarn prepare`. +3. CI runs `yarn install --immutable` → `yarn nitrogen` → `yarn lint` → `yarn typecheck` → `yarn prepare` (build) → `yarn test`. The `yarn lint` and `yarn typecheck` gates run inside `publish.yml` (right after nitrogen, before the build) so the publish path fails fast on lint/type errors, not just on test failures. 4. CI runs `npm publish --access public --provenance`. The `--provenance` flag attaches a signed attestation linking the published tarball to this exact commit and workflow run. 5. CI creates the `v` GitHub release with notes generated from the commit log between tags. @@ -215,7 +228,7 @@ This runs `release-it --preRelease=beta`, which bumps the version to a `-beta.N` #### CI-driven publish details -`publish.yml` triggers on push to `main` (and `workflow_dispatch` for manual reruns). The version-diff guard is the load-bearing piece — without it, every push to `main` would attempt to republish the current version and fail. Concretely: +`publish.yml` triggers on push to `main` only — there is no `workflow_dispatch` trigger, so it cannot be rerun manually from the Actions tab. The version-diff guard is the load-bearing piece — without it, every push to `main` would attempt to republish the current version and fail. Concretely: ```yaml # .github/workflows/publish.yml (excerpt) @@ -268,15 +281,15 @@ feat(ios): add expedited dispatch helper to SyncDispatcher #### Troubleshooting -| Symptom | Likely cause | Fix | -| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `publish.yml` fails with `EOIDCTOKEN` or `unable to read OIDC token` | The `publish` job is missing `permissions: id-token: write`, which `--provenance` requires. | Restore the `permissions:` block at the top of the job in `.github/workflows/publish.yml`. | -| `npm publish` returns `403 Forbidden` | `NPM_TOKEN` expired or was rotated; or 2FA "Authorization and Writes" is enabled and the token lacks the publish scope. | Regenerate a granular access token on npm with publish permission for the package, update the `NPM_TOKEN` repository secret, and rerun the workflow. | -| `publish.yml` runs but skips the publish step (`version_changed=false`) | The version in `package.json` matches what is already on npm — the version-diff guard intentionally short-circuits. | Bump the version (`yarn release`) and push again. Do not patch the workflow to bypass the guard. | -| Lefthook rejects the release commit (`subject may not be empty`) | release-it's commit message (`chore: release v`) was modified in flight or commitlint config was loosened. | Keep the default `commitMessage` template in `package.json → release-it`. If you need to change it, also update `commitlint.config.js`. | -| `deploy-docs.yml` fails at `Deploy to GitHub Pages` with `Get Pages site failed` | Pages source is still set to "Deploy from a branch". | Switch **Settings → Pages → Source** to **GitHub Actions** and rerun the workflow. | -| `docs-link-check.yml` fails on an internal anchor that exists | Docusaurus camelCases anchor slugs (`SyncItemInput` → `synciteminput`, not `syncitem-input`). | Use the literal slug emitted by Docusaurus. Verify by running `yarn docs:build` locally and checking the generated HTML. | -| `prerelease.yml` cannot find the branch | The `target_branch` input does not exist on the remote, or the workflow is dispatched from a fork. | Push the branch to the canonical remote (no forks for releases) and re-run the dispatch. | +| Symptom | Likely cause | Fix | +| -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `publish.yml` fails with `EOIDCTOKEN` or `unable to read OIDC token` | The `publish` job is missing `permissions: id-token: write`, which `--provenance` requires. | Restore the `permissions:` block at the top of the job in `.github/workflows/publish.yml`. | +| `npm publish` returns `403 Forbidden` | `NPM_TOKEN` expired or was rotated; or 2FA "Authorization and Writes" is enabled and the token lacks the publish scope. | Regenerate a granular access token on npm with publish permission for the package, update the `NPM_TOKEN` repository secret, and rerun the workflow. | +| `publish.yml` runs but skips the publish step (`version_changed=false`) | The version in `package.json` matches what is already on npm — the version-diff guard intentionally short-circuits. | Bump the version (`yarn release`) and push again. Do not patch the workflow to bypass the guard. | +| Lefthook rejects the release commit (`subject may not be empty`) | release-it's commit message (`chore: release v`) was modified in flight or commitlint config was loosened. | Keep the default `commitMessage` template in `package.json → release-it`. If you need to change it, also update the commitlint config (the `"commitlint"` key in `package.json`, which extends `@commitlint/config-conventional`). | +| `deploy-docs.yml` fails at `Deploy to GitHub Pages` with `Get Pages site failed` | Pages source is still set to "Deploy from a branch". | Switch **Settings → Pages → Source** to **GitHub Actions** and rerun the workflow. | +| `docs-link-check.yml` fails on an internal anchor that exists | Docusaurus camelCases anchor slugs (`SyncItemInput` → `synciteminput`, not `syncitem-input`). | Use the literal slug emitted by Docusaurus. Verify by running `yarn docs:build` locally and checking the generated HTML. | +| `prerelease.yml` cannot find the branch | The `target_branch` input does not exist on the remote, or the workflow is dispatched from a fork. | Push the branch to the canonical remote (no forks for releases) and re-run the dispatch. | ### Scripts @@ -289,7 +302,7 @@ The `package.json` file contains various scripts for common tasks: - `yarn example start`: start the Metro server for the example app. - `yarn example android`: run the example app on Android. - `yarn example ios`: run the example app on iOS. - + ### Sending a pull request > **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github). diff --git a/README.md b/README.md index abfa0c0..6094114 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ If `react-native-background-location` is what you reach for when you need GPS th | | Minimum | | ---------------------------- | -------------------------- | -| iOS | 13.0 (BGTaskScheduler API) | +| iOS | 15.0 (`URLSession.data(for:)`) | | Android `minSdkVersion` | 24 | | React Native | 0.73+ | | React | 18.2+ | @@ -588,7 +588,7 @@ Browse the **[full documentation site](https://gabriel-sisjr.github.io/react-nat | 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. | +| iOS | Supported | Swift native implementation. iOS 15+. 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. diff --git a/SyncProvider.podspec b/SyncProvider.podspec index 019cab1..228c567 100644 --- a/SyncProvider.podspec +++ b/SyncProvider.podspec @@ -10,9 +10,11 @@ Pod::Spec.new do |s| s.license = package["license"] s.authors = package["author"] - # BGTaskScheduler requires iOS 13.0+. We pin the floor explicitly rather than - # relying on the Cocoapods helper because this library has a strict minimum. - s.platforms = { :ios => "13.0" } + # iOS 15.0 floor: `ios/HTTP/SyncDispatcher.swift` uses the async + # `URLSession.data(for:)` API, which is iOS 15+. We pin the floor explicitly + # rather than relying on the Cocoapods helper because this library has a + # strict minimum. (BGTaskScheduler, iOS 13+, is comfortably covered.) + s.platforms = { :ios => "15.0" } s.source = { :git => "https://github.com/gabriel-sisjr/react-native-sync-provider.git", :tag => "#{s.version}" } s.source_files = [ @@ -53,7 +55,7 @@ Pod::Spec.new do |s| # Foundation — stdlib # CoreData — queue + history persistence # Network — NWPathMonitor connectivity tracking - # BackgroundTasks — BGTaskScheduler (iOS 13+) + # BackgroundTasks — BGTaskScheduler (iOS 13+; library floor is 15.0) s.frameworks = "Foundation", "CoreData", "Network", "BackgroundTasks" s.dependency 'React-jsi' @@ -72,7 +74,7 @@ Pod::Spec.new do |s| # `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.platforms = { :ios => "15.0" } test_spec.source_files = "ios/Tests/**/*.swift" test_spec.requires_app_host = false test_spec.frameworks = "XCTest", "Foundation", "CoreData", "Network" diff --git a/android/src/main/java/com/margelo/nitro/syncprovider/retry/RetryPolicyEvaluator.kt b/android/src/main/java/com/margelo/nitro/syncprovider/retry/RetryPolicyEvaluator.kt index e009ef0..cc957d8 100644 --- a/android/src/main/java/com/margelo/nitro/syncprovider/retry/RetryPolicyEvaluator.kt +++ b/android/src/main/java/com/margelo/nitro/syncprovider/retry/RetryPolicyEvaluator.kt @@ -38,15 +38,17 @@ internal object RetryPolicyEvaluator { BackoffStrategy.EXPONENTIAL -> base * 2.0.pow((attempt - 1).toDouble()) BackoffStrategy.FIBONACCI -> base * fibonacci(attempt).toDouble() } - val capped = min(raw, cap) - val withJitter = if (policy.jitter && capped > 0) { - // Full jitter [0, capped] (matches the JS-side default behavior). - capped * random.nextDouble() + val jittered = if (policy.jitter && raw > 0) { + // Equal jitter [0.75, 1.25]: spread the back-off ±25% around `raw`, + // matching JS (src/utils/retryBackoff.ts) and iOS + // (ios/Retry/RetryPolicyEvaluator.swift). Cap is applied AFTER jitter so + // the clamped value never exceeds maxDelayMs, exactly like both peers. + raw * (0.75 + random.nextDouble() * 0.5) } else { - capped + raw } - return withJitter.toLong() + return min(jittered, cap).toLong() } /** diff --git a/android/src/test/java/com/margelo/nitro/syncprovider/retry/RetryPolicyEvaluatorTest.kt b/android/src/test/java/com/margelo/nitro/syncprovider/retry/RetryPolicyEvaluatorTest.kt index 667ccf6..cf048f9 100644 --- a/android/src/test/java/com/margelo/nitro/syncprovider/retry/RetryPolicyEvaluatorTest.kt +++ b/android/src/test/java/com/margelo/nitro/syncprovider/retry/RetryPolicyEvaluatorTest.kt @@ -77,7 +77,7 @@ internal class RetryPolicyEvaluatorTest { } @Test - fun `full-jitter delay stays within zero and capped`() { + fun `equal-jitter delay stays within 0_75 to 1_25 of the base and respects the cap`() { val policy = TestData.retryPolicy( backoff = BackoffStrategy.EXPONENTIAL, baseDelayMs = 100L, @@ -88,11 +88,32 @@ internal class RetryPolicyEvaluatorTest { val random = Random(42) repeat(50) { iteration -> val attempt = (iteration % 5) + 1 - val capped = minOf(100.0 * Math.pow(2.0, (attempt - 1).toDouble()), 10_000.0).toLong() + val raw = 100.0 * Math.pow(2.0, (attempt - 1).toDouble()) + // Equal jitter [0.75, 1.25] of `raw`, then capped at maxDelayMs. + val lowerBound = minOf(raw * 0.75, 10_000.0).toLong() + val upperBound = minOf(raw * 1.25, 10_000.0).toLong() val delay = RetryPolicyEvaluator.computeDelayMs(attempt, policy, random) - assertThat(delay).isAtLeast(0L) - assertThat(delay).isAtMost(capped) + assertThat(delay).isAtLeast(lowerBound) + assertThat(delay).isAtMost(upperBound) + } + } + + @Test + fun `equal-jitter clamps to the cap when the high jitter factor overflows`() { + val policy = TestData.retryPolicy( + backoff = BackoffStrategy.EXPONENTIAL, + baseDelayMs = 1_000L, + maxDelayMs = 5_000L, + jitter = true, + ) + + val random = Random(7) + // raw for attempt 10 = 1000 * 2^9 = 512000; even the 1.25x factor stays + // far above the cap, so every sample must clamp exactly to maxDelayMs. + repeat(50) { + val delay = RetryPolicyEvaluator.computeDelayMs(10, policy, random) + assertThat(delay).isEqualTo(5_000L) } } diff --git a/eslint.config.mjs b/eslint.config.mjs index 16b00bb..f51f3db 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,6 +24,13 @@ export default defineConfig([ }, }, { - ignores: ['node_modules/', 'lib/'], + ignores: [ + 'node_modules/', + 'lib/', + 'nitrogen/', + 'website/build/', + 'website/.docusaurus/', + 'coverage/', + ], }, ]); diff --git a/example/ios/SyncProviderExample.xcodeproj/project.pbxproj b/example/ios/SyncProviderExample.xcodeproj/project.pbxproj index c474ffd..fb13682 100644 --- a/example/ios/SyncProviderExample.xcodeproj/project.pbxproj +++ b/example/ios/SyncProviderExample.xcodeproj/project.pbxproj @@ -261,6 +261,10 @@ CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_BITCODE = NO; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "${PODS_ROOT}/Headers/Private/NitroModules", + ); INFOPLIST_FILE = SyncProviderExample/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -276,6 +280,7 @@ PRODUCT_BUNDLE_IDENTIFIER = syncprovider.example; PRODUCT_NAME = SyncProviderExample; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_OBJC_INTEROP_MODE = objcxx; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -290,6 +295,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = 1; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "${PODS_ROOT}/Headers/Private/NitroModules", + ); INFOPLIST_FILE = SyncProviderExample/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -305,6 +314,7 @@ PRODUCT_BUNDLE_IDENTIFIER = syncprovider.example; PRODUCT_NAME = SyncProviderExample; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_OBJC_INTEROP_MODE = objcxx; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; diff --git a/example/ios/SyncProviderExample/AppDelegate.swift b/example/ios/SyncProviderExample/AppDelegate.swift index 39809b0..9d18581 100644 --- a/example/ios/SyncProviderExample/AppDelegate.swift +++ b/example/ios/SyncProviderExample/AppDelegate.swift @@ -40,7 +40,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void ) { - SyncProvider.handleBackgroundURLSessionEvents( + HybridSyncProvider.handleBackgroundURLSessionEvents( identifier: identifier, completionHandler: completionHandler ) diff --git a/ios/Background/BackgroundSyncManager.swift b/ios/Background/BackgroundSyncManager.swift index 1f69bbb..34c003d 100644 --- a/ios/Background/BackgroundSyncManager.swift +++ b/ios/Background/BackgroundSyncManager.swift @@ -55,33 +55,31 @@ final class BackgroundSyncManager: @unchecked Sendable { /// `BGTaskScheduler` contract. Idempotent — safe to call from `init`. func registerHandlers() { #if canImport(BackgroundTasks) - if #available(iOS 13.0, *) { - let already = lock.withLock { () -> Bool in - let wasRegistered = registered - registered = true - return wasRegistered - } - guard !already else { return } - - let scheduler = BGTaskScheduler.shared - scheduler.register(forTaskWithIdentifier: kBackgroundRefreshIdentifier, - using: nil) { [weak self] task in - guard let self = self, let appRefreshTask = task as? BGAppRefreshTask else { - task.setTaskCompleted(success: false) - return - } - self.handleAppRefresh(task: appRefreshTask) + let already = lock.withLock { () -> Bool in + let wasRegistered = registered + registered = true + return wasRegistered + } + guard !already else { return } + + let scheduler = BGTaskScheduler.shared + scheduler.register(forTaskWithIdentifier: kBackgroundRefreshIdentifier, + using: nil) { [weak self] task in + guard let self = self, let appRefreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return } - scheduler.register(forTaskWithIdentifier: kBackgroundProcessingIdentifier, - using: nil) { [weak self] task in - guard let self = self, let processingTask = task as? BGProcessingTask else { - task.setTaskCompleted(success: false) - return - } - self.handleProcessing(task: processingTask) + self.handleAppRefresh(task: appRefreshTask) + } + scheduler.register(forTaskWithIdentifier: kBackgroundProcessingIdentifier, + using: nil) { [weak self] task in + guard let self = self, let processingTask = task as? BGProcessingTask else { + task.setTaskCompleted(success: false) + return } - SyncLogger.info("Registered BGTaskScheduler handlers", category: "background") + self.handleProcessing(task: processingTask) } + SyncLogger.info("Registered BGTaskScheduler handlers", category: "background") #endif } @@ -90,17 +88,13 @@ final class BackgroundSyncManager: @unchecked Sendable { /// OS rejects the submission (most commonly: missing `Info.plist` key). func enable(options: BackgroundSyncOptionsValue) throws { #if canImport(BackgroundTasks) - if #available(iOS 13.0, *) { - registerHandlers() - try scheduleAppRefresh(options: options) - 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") + registerHandlers() + try scheduleAppRefresh(options: options) + lock.withLock { + enabled = true + lastOptions = options } + SyncLogger.info("Background sync enabled (interval=\(options.minimumIntervalMs)ms)", category: "background") #else throw SyncProviderError.backgroundRegistrationFailed("BackgroundTasks framework unavailable") #endif @@ -109,10 +103,8 @@ final class BackgroundSyncManager: @unchecked Sendable { /// Cancel any pending submissions and mark disabled. func disable() { #if canImport(BackgroundTasks) - if #available(iOS 13.0, *) { - BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: kBackgroundRefreshIdentifier) - BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: kBackgroundProcessingIdentifier) - } + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: kBackgroundRefreshIdentifier) + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: kBackgroundProcessingIdentifier) #endif lock.withLock { enabled = false @@ -122,7 +114,6 @@ final class BackgroundSyncManager: @unchecked Sendable { // MARK: - Submission - @available(iOS 13.0, *) private func scheduleAppRefresh(options: BackgroundSyncOptionsValue) throws { #if canImport(BackgroundTasks) let request = BGAppRefreshTaskRequest(identifier: kBackgroundRefreshIdentifier) @@ -137,7 +128,6 @@ final class BackgroundSyncManager: @unchecked Sendable { #endif } - @available(iOS 13.0, *) private func scheduleProcessing(options: BackgroundSyncOptionsValue) { #if canImport(BackgroundTasks) let request = BGProcessingTaskRequest(identifier: kBackgroundProcessingIdentifier) @@ -155,7 +145,6 @@ final class BackgroundSyncManager: @unchecked Sendable { // MARK: - Handlers #if canImport(BackgroundTasks) - @available(iOS 13.0, *) private func handleAppRefresh(task: BGAppRefreshTask) { SyncLogger.info("BGAppRefresh task fired", category: "background") emitter.emit(.now(type: "BACKGROUND_SYNC_STARTED")) @@ -186,7 +175,6 @@ final class BackgroundSyncManager: @unchecked Sendable { } } - @available(iOS 13.0, *) private func handleProcessing(task: BGProcessingTask) { SyncLogger.info("BGProcessing task fired", category: "background") emitter.emit(.now(type: "BACKGROUND_SYNC_STARTED", diff --git a/ios/Database/SyncItemEntity+CoreDataClass.swift b/ios/Database/SyncItemEntity+CoreDataClass.swift index 705c0c8..e7c52b0 100644 --- a/ios/Database/SyncItemEntity+CoreDataClass.swift +++ b/ios/Database/SyncItemEntity+CoreDataClass.swift @@ -12,22 +12,22 @@ import CoreData import Foundation @objc(SyncItemEntity) -public final class SyncItemEntity: NSManagedObject { - @nonobjc public class func fetchRequest() -> NSFetchRequest { +final class SyncItemEntity: NSManagedObject { + @nonobjc class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "SyncItemEntity") } - @NSManaged public var attempts: Int32 - @NSManaged public var body: String? - @NSManaged public var contentType: String? - @NSManaged public var createdAt: Int64 - @NSManaged public var headersJSON: Data? - @NSManaged public var id: String? - @NSManaged public var lastAttemptAt: Int64 - @NSManaged public var lastErrorCode: String? - @NSManaged public var metadataJSON: Data? - @NSManaged public var method: String? - @NSManaged public var priority: String? - @NSManaged public var status: String? - @NSManaged public var url: String? + @NSManaged var attempts: Int32 + @NSManaged var body: String? + @NSManaged var contentType: String? + @NSManaged var createdAt: Int64 + @NSManaged var headersJSON: Data? + @NSManaged var id: String? + @NSManaged var lastAttemptAt: Int64 + @NSManaged var lastErrorCode: String? + @NSManaged var metadataJSON: Data? + @NSManaged var method: String? + @NSManaged var priority: String? + @NSManaged var status: String? + @NSManaged var url: String? } diff --git a/ios/Database/SyncResultEntity+CoreDataClass.swift b/ios/Database/SyncResultEntity+CoreDataClass.swift index 2e57101..d635af0 100644 --- a/ios/Database/SyncResultEntity+CoreDataClass.swift +++ b/ios/Database/SyncResultEntity+CoreDataClass.swift @@ -9,16 +9,16 @@ import CoreData import Foundation @objc(SyncResultEntity) -public final class SyncResultEntity: NSManagedObject { - @nonobjc public class func fetchRequest() -> NSFetchRequest { +final class SyncResultEntity: NSManagedObject { + @nonobjc class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "SyncResultEntity") } - @NSManaged public var errorMessage: String? - @NSManaged public var finishedAt: Int64 - @NSManaged public var id: String? - @NSManaged public var itemsAttempted: Int32 - @NSManaged public var itemsFailed: Int32 - @NSManaged public var itemsSucceeded: Int32 - @NSManaged public var startedAt: Int64 + @NSManaged var errorMessage: String? + @NSManaged var finishedAt: Int64 + @NSManaged var id: String? + @NSManaged var itemsAttempted: Int32 + @NSManaged var itemsFailed: Int32 + @NSManaged var itemsSucceeded: Int32 + @NSManaged var startedAt: Int64 } diff --git a/ios/HTTP/SyncDispatcher.swift b/ios/HTTP/SyncDispatcher.swift index 7a9d42d..f9852d7 100644 --- a/ios/HTTP/SyncDispatcher.swift +++ b/ios/HTTP/SyncDispatcher.swift @@ -90,10 +90,8 @@ actor SyncDispatcher { background.isDiscretionary = false background.sessionSendsLaunchEvents = true background.requestCachePolicy = .reloadIgnoringLocalCacheData - if #available(iOS 13.0, *) { - background.allowsConstrainedNetworkAccess = true - background.allowsExpensiveNetworkAccess = true - } + background.allowsConstrainedNetworkAccess = true + background.allowsExpensiveNetworkAccess = true self.backgroundSession = URLSession(configuration: background, delegate: backgroundDelegate, delegateQueue: nil) diff --git a/package.json b/package.json index 93c969f..9c2515d 100644 --- a/package.json +++ b/package.json @@ -234,7 +234,7 @@ "tagName": "v${version}" }, "npm": { - "publish": true + "publish": false }, "github": { "release": true diff --git a/src/SyncProvider.nitro.ts b/src/SyncProvider.nitro.ts index 3499c28..6ed8755 100644 --- a/src/SyncProvider.nitro.ts +++ b/src/SyncProvider.nitro.ts @@ -10,8 +10,10 @@ * 3. Update the Swift implementation at `ios/SyncProvider.swift`. * 4. Update the Kotlin implementation at * `android/src/main/java/com/margelo/nitro/syncprovider/SyncProvider.kt`. - * 5. Update the JS facade re-exports under `src/index.tsx` and the - * platform-extension files (`.native.tsx` / `.tsx`). + * 5. Update the JS facade in `src/index.tsx` (the entire facade lives + * there) and the web fallback in `src/index.web.tsx`. The platform + * split is at the entry level via the `"browser"` exports condition — + * there is NO per-method `.native.tsx` / `.tsx` split. * * Hard rules for this file: * - Type-only imports (`import type ...`) — no runtime values. @@ -45,7 +47,10 @@ export type { SyncErrorCode }; * The native HybridObject contract for the SyncProvider library. * * Implementations: - * - iOS / Swift → `class SyncProvider: HybridSyncProviderSpec` (`ios/`). + * - iOS / Swift → `final class HybridSyncProvider: HybridSyncProviderSpec` + * (`ios/SyncProvider.swift`) — named `HybridSyncProvider` (not + * `SyncProvider`) to avoid a symbol collision with the C++ `SyncProvider` + * class Nitro generates. * - Android / Kotlin → `class SyncProvider : HybridSyncProviderSpec()` (`android/`). */ export interface SyncProvider extends HybridObject<{ diff --git a/src/types/sync.ts b/src/types/sync.ts index 07ef4d2..8cee97f 100644 --- a/src/types/sync.ts +++ b/src/types/sync.ts @@ -118,7 +118,8 @@ export interface RetryPolicy { maxDelayMs: number; /** * If `true`, the computed delay is multiplied by a uniformly distributed - * factor in `[0.5, 1.5]` to avoid synchronized retry storms. + * equal-jitter factor in `[0.75, 1.25)` (i.e. ±25% around the base delay) to + * avoid synchronized retry storms. */ jitter: boolean; /** diff --git a/tsconfig.build.json b/tsconfig.build.json index 0ce2d2c..701b153 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,7 @@ { "extends": "./tsconfig", + "compilerOptions": { + "incremental": false + }, "exclude": ["example", "lib", "nitrogen"] } diff --git a/tsconfig.json b/tsconfig.json index 5a847b5..892aef3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,8 @@ "customConditions": ["react-native-strict-api"], "esModuleInterop": true, "forceConsistentCasingInFileNames": true, + "incremental": true, + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", "jsx": "react-jsx", "lib": ["ESNext"], "module": "ESNext", diff --git a/turbo.json b/turbo.json index 7ce4c2e..ac520ed 100644 --- a/turbo.json +++ b/turbo.json @@ -3,17 +3,6 @@ "globalDependencies": [".nvmrc", ".yarnrc.yml"], "globalEnv": ["NODE_ENV"], "tasks": { - "test": { - "env": ["NODE_ENV", "CI"], - "inputs": [ - "package.json", - "yarn.lock", - "tsconfig.json", - "src/**", - "!src/**/__snapshots__/**" - ], - "outputs": ["coverage/**"] - }, "build:android": { "env": ["ANDROID_HOME", "ORG_GRADLE_PROJECT_newArchEnabled"], "inputs": [