expo-cloudkit-bridge
v0.1.0
Published
Thin, stateless CloudKit private-database bridge for Expo apps: records, custom zone, change tokens, and the iCloud key-value store. Bring your own merge strategy.
Maintainers
Readme
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
CKDatabaseasync 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-bridgeAdd 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 --entitlementsreadout 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.
couldNotDetermineon 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
stringsthe binary for your module's name before shipping. This module once shipped missing from a production build because of an unanchoredios/gitignore pattern in the parent project.
License
MIT © Jason Stiles
