Skip to content

Repository files navigation

expo-cloudkit-bridge

A thin, stateless CloudKit private-database bridge for Expo apps: batch record saves with first-to-server conflict arbitration, change-token delta fetches, account status and identity, and the iCloud key-value store with change events. About 300 lines of Swift; everything else is yours.

Extracted from Hedge, a shipped App Store app that uses it to sync play history across devices with no backend, no accounts, and a privacy label that still says "Data Not Collected" — the data lives in each user's own private iCloud, hosted by Apple under their Apple ID, unreadable by the developer.

This is a bridge, not a sync engine. It moves records and tells you about conflicts; your app owns the merge strategy, the outbox, and all persistence (even the change token round-trips through JS as base64). If you want the production-proven merge recipe this bridge was built for — an append-only fact log with idempotent set-union merges — read SYNC_PATTERN.md.

Scope (frozen)

Private database, one custom zone per client, fail-if-exists saves, change-token fetches, KV store, account events. That's the whole surface, on purpose, and it is not growing. No public database, no queries, no subscriptions/push, no CKShare, no CKSyncEngine, no Android (there is no CloudKit on Android). If you need those, fork freely — the code is small enough to actually read.

Why so small: this survey is what the CloudKit-for-React-Native landscape looked like when I needed it — one wrapper last touched in 2018, a couple of sub-five-star experiments, and one package with 207 commits in five weeks followed by silence. Maximal surfaces die. A small surface that runs in a shipped app can stay alive.

Requirements

  • iOS 15.1+ (raw CKDatabase async APIs; deliberately not CKSyncEngine, which would require iOS 17 and the remote-notifications entitlement)
  • Expo SDK 50+ (expo-modules-core), development build or EAS build (not Expo Go)

Install

npx expo install expo-cloudkit-bridge

Add the iCloud entitlements to your app.json (replace the container id with your own, and register the container in your Apple Developer account):

{
  "expo": {
    "ios": {
      "entitlements": {
        "com.apple.developer.icloud-services": ["CloudKit"],
        "com.apple.developer.icloud-container-identifiers": [
          "iCloud.com.example.myapp"
        ],
        "com.apple.developer.ubiquity-kvstore-identifier": "$(TeamIdentifierPrefix)$(CFBundleIdentifier)"
      }
    }
  }
}

Then rebuild your dev client (npx expo run:ios or an EAS build) — this is a native module; a Metro reload is not enough.

Quickstart

import {
  createCloudKitBridge,
  isCloudKitAvailable,
} from 'expo-cloudkit-bridge';

const ck = createCloudKitBridge({
  containerId: 'iCloud.com.example.myapp',
  // A custom zone is MANDATORY: the private DB's default zone cannot do
  // change-token delta fetches. Use a separate zone for dev builds so test
  // data never mixes with real data.
  zoneName: __DEV__ ? 'MySyncDev' : 'MySync',
  kvPrefix: 'myapp.settings.',
});

async function sync(myChangeToken: string | null) {
  if (!isCloudKitAvailable()) return; // OTA-safe on old binaries
  if ((await ck.getAccountStatus()) !== 'available') return;
  await ck.ensureZone();

  // Push: fail-if-exists → the first device to the server owns each key.
  const result = await ck.saveRecords([
    {
      recordName: 'entry:2026-07-19', // unique per ZONE across ALL types
      recordType: 'JournalEntry',
      fields: { text: 'hello', mood: 4, pinned: false },
    },
  ]);
  // result.saved    → keys this device now owns
  // result.conflicts → the SERVER's records; adopt them locally
  // result.errors   → retry later from your outbox

  // Pull: everything changed since your token (null = from the beginning).
  const { records, tokenB64 } = await ck.fetchChanges(myChangeToken);
  // ...merge records into your store, then persist tokenB64 yourself.
}

Settings-style preferences ride the iCloud key-value store (last-writer-wins, 1MB / 1024-key limits — use it for prefs, never for canonical data):

ck.kvSetItem('theme', JSON.stringify('dark'));

const unsubscribe = ck.onKvChange(({ reason, keys }) => {
  // Another device changed settings (or the initial cloud pull landed).
  const all = ck.kvGetAllItems();
  // ...apply to your local settings store.
});

API

Member What it does
isCloudKitAvailable() false on binaries without the native module (OTA safety) and on non-Apple platforms. Gate every call site.
createCloudKitBridge(config) Returns a client bound to a container + zone (+ KV namespace). The native side stays stateless; multiple clients coexist.
client.getAccountStatus() available, noAccount, restricted, temporarilyUnavailable, couldNotDetermine. A fresh install's first check can transiently fail — retry with backoff and listen to onAccountChanged.
client.getUserRecordId() Opaque, stable per-iCloud-account id. Pin your sync pairing to it so an account switch pauses sync instead of mixing two users' data.
client.ensureZone() Idempotent create-or-confirm. Surfaces per-zone failures the raw API hides.
client.saveRecords(records) Batch save with .ifServerRecordUnchanged (fail-if-exists), non-atomic so one conflict never fails the batch. Chunk to ≤250. Conflicts return the server's record.
client.fetchChanges(tokenB64) Delta fetch since the token; loops moreComing internally. Persist the returned token yourself.
client.kvSetItem/kvGetItem/kvRemoveItem/kvGetAllItems Namespaced iCloud key-value store access. Values are JSON strings; JS owns the schema.
client.onKvChange(fn) External KV changes, keys filtered to your namespace. The initialSync event is unreliable on real devices — also re-apply kvGetAllItems() on every app foreground.
client.onAccountChanged(fn) iCloud sign-in/out/switch.
client.deleteRecords / deleteZone Destructive; meant for dev/test tooling. Gate callers in release builds if your design is append-only.

Errors reject with stable codes on err.code: CK_NOT_AUTHENTICATED, CK_ZONE_NOT_FOUND, CK_TOKEN_EXPIRED (re-fetch from null), CK_QUOTA_EXCEEDED, CK_RETRY_LATER, CK_NETWORK, CK_CONFLICT, CK_BAD_INPUT, CK_ERROR.

Field notes (the potholes, so you don't hit them)

Hard-won on the way to production; the long versions live in SYNC_PATTERN.md:

  • Record names are unique per zone across ALL record types. Two types sharing a natural key silently lose every save to a conflict. Namespace.
  • Deploy your schema to Production. Just-in-time record-type creation only works in CloudKit's Development environment. Simulator/Xcode builds hit Development; TestFlight, App Store, and EAS ad-hoc builds pin to Production. Forget the console's "Deploy Schema Changes" and store users get silent nothing.
  • codesign lies about simulator builds. Sim builds carry entitlements in a linker section, not the signature — an empty codesign -d --entitlements readout does not mean the build is broken (and "fixing" it by signing restricted entitlements makes the app unlaunchable).
  • Container propagation takes time. A fresh App ID ↔ container association can return "Invalid bundle ID for container" for the better part of an hour with a fully correct client. Verify your side once, then wait.
  • A fresh install's first account check races. couldNotDetermine on launch is transient; retry with backoff or sync stays dead until the next foreground.
  • Verify the artifact. After any build-system change, unzip the ipa and strings the binary for your module's name before shipping. This module once shipped missing from a production build because of an unanchored ios/ gitignore pattern in the parent project.

License

MIT © Jason Stiles

About

Thin, stateless CloudKit private-database bridge for Expo apps. Bring your own merge strategy.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages