npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

expo-cloudkit

v0.9.0

Published

Expo native module for CloudKit — records, zones, sharing, and sync

Readme

expo-cloudkit

npm version CI MIT license

CloudKit for Expo — save and sync records with iCloud, no Swift required.

expo-cloudkit is a TypeScript-first Expo native module wrapping Apple's CloudKit framework. It covers record CRUD, custom zones, delta sync, push subscriptions, sharing, offline queuing, React hooks, and web support via CloudKit JS — all behind a consistent async/await API.

iOS-first. Android returns CloudKitNotSupportedError on every call. Web is partially supported via tsl-apple-cloudkit (20 of 44 operations).


Table of Contents


Quick Start

// 1. Install: npx expo install expo-cloudkit
// 2. Add to app.json: { "plugins": [["expo-cloudkit", { "containerIds": ["iCloud.com.yourapp"] }]] }
// 3. npx expo prebuild --clean && npx expo run:ios

import { configure, getAccountStatus, createZone, saveRecords, queryRecords } from 'expo-cloudkit';

configure('iCloud.com.yourapp');

const status = await getAccountStatus();
if (status !== 'available') return; // user not signed into iCloud

await createZone('Notes'); // idempotent — safe to call every launch

const [saved] = await saveRecords([{
  recordType: 'Note',
  zoneName: 'Notes',
  fields: {
    title:    { type: 'string', value: 'Hello CloudKit' },
    pinned:   { type: 'number', value: 1 },
    created:  { type: 'date',   value: new Date().toISOString() },
  },
}]);

const { records } = await queryRecords(
  'Note',
  { field: 'pinned', comparator: '=', value: 1 },
  [{ field: 'created', ascending: false }],
  'Notes',
);
console.log(records[0].fields.title.value); // "Hello CloudKit"

Installation

npx expo install expo-cloudkit

Peer dependencies: expo (SDK 51+), expo-modules-core (1.12+), react (18+). Optional: tsl-apple-cloudkit for web platform support.

iOS minimum version: 16.0. CKSyncEngine requires 17.0.

The module is auto-linked — no manual pod install step beyond expo prebuild.


Configuration

Add the config plugin to app.json or app.config.js:

{
  "expo": {
    "plugins": [
      [
        "expo-cloudkit",
        {
          "containerIds": ["iCloud.com.yourcompany.yourapp"],
          "iCloudContainerEnvironment": "Production"
        }
      ]
    ]
  }
}

Plugin options:

| Option | Type | Default | Description | |--------|------|---------|-------------| | containerIds | string[] | required | One or more iCloud.com.* identifiers | | iCloudContainerEnvironment | 'Development' \| 'Production' | 'Production' | Maps to com.apple.developer.icloud-container-environment. Use 'Development' for debug builds. |

Entitlements added automatically:

  • com.apple.developer.icloud-container-identifiers
  • com.apple.developer.icloud-services: ["CloudKit"]
  • UIBackgroundModes: ["remote-notification"] (required for push subscriptions)

After changing the plugin, rebuild:

npx expo prebuild --clean
npx expo run:ios

What is CloudKit?

CloudKit is Apple's iCloud database service. It stores structured records, binary assets, and metadata in Apple's infrastructure — with no server to maintain and no separate auth system to build. Users sign in with their Apple ID, and CloudKit handles identity, encryption, and sync.

The main building blocks:

A CKContainer is your app's isolated storage space in iCloud. Its identifier (iCloud.com.yourcompany.yourapp) is registered in Apple Developer and is what you pass to configure(). Think of it as your Firestore project or Supabase organization.

A CKDatabase is a scope within a container. The private database holds data owned by the current user — only they can read or write it. The public database is shared across all users of your app (world-readable by default). The shared database holds zones that other users have shared with the current user via CKShare. Most apps work exclusively in the private database.

A CKRecordZone is a named namespace within a database. Zones enable atomic writes, delta fetch (only fetching changes since the last sync), and sharing. The default zone (_defaultZone) always exists; create custom zones for all non-trivial data. Comparable to a Firestore collection group or a Supabase schema.

A CKRecord is a document — a key/value store with typed fields (string, number, date, asset, reference, etc.). Each record has a recordType (like a table name), a recordName (UUID), a changeTag (for conflict detection), and a zoneName. Comparable to a Firestore document or a Supabase row.

CKSyncEngine (iOS 17+) is Apple's automatic sync scheduler. It batches local changes, handles rate limiting, and retries on failure — all OS-managed. On iOS 16, expo-cloudkit falls back to polling with CKFetchRecordZoneChangesOperation. The JS API is identical in both cases.


Core Concepts

  • Containers — one per app (iCloud.com.*), registered in Apple Developer portal
  • Databasesprivate (per-user), public (all users), shared (received shares)
  • Zones — namespaces within a database; required for delta fetch and sharing
  • Records — typed key/value documents with a recordType, recordName, and changeTag
  • Assets — binary files stored as CKAsset; uploaded via file URI, downloaded via downloadAsset()
  • References — typed links between records (CKRecord.Reference); can cascade delete
  • SyncCKSyncEngine (iOS 17+) or manual polling fallback (iOS 16); both exposed via the same startSyncEngine() API

API Reference

Container & Account

| Function | Returns | Description | |----------|---------|-------------| | configure(containerId) | void | Initialize the module. Call once at startup before any other operation. | | getAccountStatus() | Promise<AccountStatus> | Check iCloud sign-in state. Call this before CloudKit operations. | | fetchUserRecordID() | Promise<string> | Stable per-user identifier for this container. | | isCloudKitAvailable() | boolean | true once the module is initialized (or configureWeb() succeeds on web). | | addAccountStatusListener(cb) | Subscription | Subscribe to iCloud account state changes. | | useCloudKitStatus(options?) | CloudKitStatus | Reactive hook — combines account status, availability, and ready shorthand. |

import { configure, getAccountStatus } from 'expo-cloudkit';

configure('iCloud.com.yourapp');

const status = await getAccountStatus();
// 'available' | 'noAccount' | 'restricted' | 'couldNotDetermine' | 'temporarilyUnavailable'
if (status !== 'available') {
  // Prompt user: Settings → [Your Name] → iCloud
}
import { useCloudKitStatus } from 'expo-cloudkit';

function App() {
  const { ready, loading, error } = useCloudKitStatus();
  if (loading) return <ActivityIndicator />;
  if (error) return <Text>{error.recoverySuggestion ?? error.message}</Text>;
  if (!ready) return <Text>Sign in to iCloud to continue</Text>;
  return <MainContent />;
}

Records

| Function | Returns | Description | |----------|---------|-------------| | saveRecords(records, database?, operationConfig?) | Promise<SavedRecord[]> | Insert or update. Auto-chunks at 400. Calls CKModifyRecordsOperation. | | fetchRecord(recordType, recordName, zoneName?, database?, desiredKeys?) | Promise<CloudKitRecord> | Fetch one record by ID. | | queryRecords(recordType, predicate?, sort?, zoneName?, database?, limit?, cursor?, desiredKeys?) | Promise<QueryResult> | Query with optional predicate and cursor pagination. | | deleteRecords(ids, database?) | Promise<void> | Delete by record identifier. Auto-chunks at 400. | | batchFetchRecords(recordIDs, database?, desiredKeys?, operationConfig?) | Promise<BatchFetchResult[]> | Fetch multiple records in one CKFetchRecordsOperation. Per-record success/error, never throws on partial failure. | | fetchRecordZoneChanges(zoneNames, database?) | Promise<ZoneChanges> | Delta fetch — only changes since the last sync token. | | fetchRecordWithReferences(recordName, options) | Promise<ResolvedRecord> | Fetch a record and recursively resolve its reference fields (depth 1–3). | | deleteRecordWithReferences(recordName, recordType, zoneName?, options?) | Promise<string[]> | Walk reference graph and delete the root plus all referenced records in one batch. |

Field types:

| Type key | JS value when saving | JS value when reading | |----------|---------------------|----------------------| | 'string' | string | string | | 'number' | number | number | | 'date' | ISO 8601 string | ISO 8601 string | | 'data' | base64 string | base64 string | | 'location' | { latitude, longitude } | { latitude, longitude } | | 'reference' | { recordName, action: 'none' \| 'deleteSelf' } | same | | 'asset' | local file URI | { downloadURL, size } | | 'stringList' | string[] | string[] | | 'numberList' | number[] | number[] |

// Save (insert or update)
const [saved] = await saveRecords([{
  recordType: 'Note',
  zoneName: 'Notes',
  fields: { title: { type: 'string', value: 'My Note' } },
}]);

// Update with conflict detection
await saveRecords([{
  recordType: 'Note',
  recordName: saved.recordName,
  changeTag:  saved.changeTag,  // throws CONFLICT if server changed it
  zoneName:   'Notes',
  fields: { title: { type: 'string', value: 'Updated' } },
}]);

// Query with pagination
const page1 = await queryRecords(
  'Note',
  { field: 'pinned', comparator: '=', value: 1 },
  [{ field: 'createdAt', ascending: false }],
  'Notes', 'private', 25,
);
if (page1.cursor) {
  const page2 = await queryRecords('Note', undefined, undefined, 'Notes', 'private', 25, page1.cursor);
}

Zones

| Function | Returns | Description | |----------|---------|-------------| | createZone(zoneName, database?) | Promise<Zone> | Create a CKRecordZone. Idempotent — safe to call every launch. | | deleteZone(zoneName, database?) | Promise<void> | Delete a zone and all its records. Permanent. | | fetchZones(database?) | Promise<Zone[]> | List all custom zones. Does not include _defaultZone. |

const zone = await createZone('Notes', 'private');
// zone.capabilities → ['fetchChanges', 'atomicChanges', 'sharing']

const zones = await fetchZones('private');

Assets

Assets are binary files stored as CKAsset. Upload by providing a file:// URI in the field value; read back a temporary downloadURL.

Size limits: 250 MB per asset in the public database; larger files are supported in the private database. Throws CloudKitError with code ASSET_TOO_LARGE if exceeded.

// Save a record with an asset field
await saveRecords([{
  recordType: 'Attachment',
  zoneName: 'Notes',
  fields: {
    name: { type: 'string', value: 'report.pdf' },
    file: { type: 'asset', fileURL: 'file:///path/to/report.pdf' },
  },
}]);

// Download an asset to a local path
import * as FileSystem from 'expo-file-system';

const localPath = await downloadAsset(
  'Attachment', saved.recordName, 'file',
  FileSystem.documentDirectory + 'report.pdf',
  'Notes',
);

// Track upload/download progress
const sub = addAssetProgressListener((p) => {
  console.log(`${p.direction} ${p.fieldName}: ${Math.round(p.fraction * 100)}%`);
});

Sharing

CKShare lets you share a record (and its zone) with other iCloud users. The sharer creates a CKShare record; recipients accept it via a URL.

| Function | Returns | Description | |----------|---------|-------------| | createShare(options) | Promise<Share> | Create a CKShare for a root record. One share per record. | | presentSharingUI(options) | Promise<SharingUIResult> | Present native UICloudSharingController (iOS only). | | acceptShare(options) | Promise<AcceptedShare> | Accept an invitation URL. | | fetchShareParticipants(options) | Promise<ShareParticipant[]> | List participants on a share. | | updateSharePermission(options) | Promise<Share> | Change a participant's access level. | | removeShareParticipant(options) | Promise<Share> | Revoke a participant's access. | | deleteShare(options) | Promise<void> | Delete the CKShare, revoking all access. | | fetchSharedDatabaseZones() | Promise<SharedZone[]> | List zones shared with the current user. | | addShareAcceptedListener(cb) | Subscription | Fires when the app opens a share URL. |

// Share a record
const share = await createShare({ recordName: 'abc-123', zoneName: 'Notes', publicPermission: 'readOnly' });
console.log(share.shareURL); // send this URL to participants

// Accept an invitation (typically from a deep link)
const sub = addShareAcceptedListener(async (event) => {
  const accepted = await acceptShare({ shareURL: event.shareURL });
  navigateToZone(accepted.zoneName);
});

Sync Engine

CKSyncEngine (iOS 17+) handles sync scheduling automatically. On iOS 16, expo-cloudkit falls back to polling with CKFetchRecordZoneChangesOperation. The API is identical in both cases.

iOS 17+ is required for automatic scheduling. On iOS 16, getSyncState() returns { usesSyncEngine: false } and polling runs every 30 seconds.

| Function | Returns | Description | |----------|---------|-------------| | startSyncEngine(config) | Promise<void> | Start sync for the specified zones. | | stopSyncEngine() | Promise<void> | Stop the sync engine and release its resources. | | getSyncState() | SyncState | Synchronous snapshot of { status, usesSyncEngine }. | | triggerSync() | Promise<void> | Manually trigger a fetch + send cycle. | | enqueuePendingChange(change) | void | Queue a save or delete for the next sync cycle. | | resolveSyncConflict(requestId, record \| null) | void | Resolve a pending conflict (when resolveConflicts: true). | | isSyncEngineAvailable() | boolean | true when CKSyncEngine (iOS 17+) is active. | | addSyncEngineListener(cb) | Subscription | Subscribe to all sync events. | | useCloudKitSync(options) | hook return | React hook — manages sync engine lifecycle on mount/unmount. | | useSyncHealth() | SyncHealthState | React hook — reactive sync health after each cycle. |

import { startSyncEngine, addSyncEngineListener, enqueuePendingChange } from 'expo-cloudkit';

await startSyncEngine({ zones: ['Notes'], database: 'private' });

const sub = addSyncEngineListener((event) => {
  if (event.type === 'recordsFetched') {
    applyChanges(event.changedRecords, event.deletedRecordIDs);
  }
  if (event.type === 'syncError') {
    console.error(event.error.code, event.error.message);
  }
});

// Queue a local change for the next sync
enqueuePendingChange({ type: 'save', record: { recordType: 'Note', zoneName: 'Notes', fields: { ... } } });

Custom conflict resolution — by default, server-record-wins. To handle conflicts manually:

await startSyncEngine({ zones: ['Notes'], resolveConflicts: true });

addSyncEngineListener((event) => {
  if (event.type === 'conflict') {
    const merged = mergeRecords(event.clientRecord, event.serverRecord);
    resolveSyncConflict(event.requestId, merged);
    // or: resolveSyncConflict(event.requestId, null) to accept server version
  }
});

When resolveConflicts: true, you must call resolveSyncConflict for every conflict event. Failing to do so blocks the sync engine indefinitely.

React hook:

import { useCloudKitSync } from 'expo-cloudkit';

const { state, isRunning, triggerSync } = useCloudKitSync({
  zones: ['Notes', 'Tasks'],
  onRecordsFetched: (event) => applyChanges(event.changedRecords, event.deletedRecordIDs),
  onSyncError: (event) => console.error(event.error.code),
});

Push Subscriptions

Push subscriptions use APNs silent push to notify the app when CloudKit records change. The config plugin adds the required background mode entitlement automatically.

| Function | Description | |----------|-------------| | saveQuerySubscription(options) | Create a CKQuerySubscription. Returns subscription ID. | | saveDatabaseSubscription(database?) | Create a CKDatabaseSubscription for all changes in a database. | | deleteSubscription(id, database?) | Cancel an active subscription by ID. | | fetchSubscriptions(database?) | List all active subscriptions. | | addSubscriptionListener(cb) | Receive push notification events ('query' or 'database'). | | useCloudKitSubscription(recordType, options) | React hook — manages subscription lifecycle, invalidates query hooks on push. |

// Subscribe to record changes
const subId = await saveQuerySubscription({
  recordType: 'Note',
  predicate: { field: 'pinned', comparator: '=', value: 1 },
  firesOnRecordCreation: true,
  firesOnRecordUpdate: true,
  zoneName: 'Notes',
});

addSubscriptionListener((event) => {
  if (event.type === 'query' && event.recordID) {
    void fetchRecord('Note', event.recordID, 'Notes').then(updateUI);
  }
});

Offline Queue

The offline queue persists CloudKit operations to disk and replays them when connectivity is restored. Operations are stored at Library/Application Support/expo-cloudkit/offline-queue.json.

Auto-drain triggers: connectivity restored (NWPathMonitor), app foreground.

Retry policy: exponential backoff starting at 5s, doubling each attempt, capped at 300s. Max 10 retries per entry. Queue capacity: 500 entries.

| Function | Description | |----------|-------------| | enqueueOfflineOperation(options) | Persist a save or delete. Returns { queueId }. | | drainOfflineQueue() | Flush all pending entries immediately. Returns { succeeded, failed, skipped }. | | getOfflineQueueStatus(options?) | Counts: pending, retrying, failed, total. Pass { includeEntries: true } for full list. | | clearOfflineQueue(options?) | Remove entries by status ('failed', 'all', etc.). | | retryFailedOperations() | Reset permanently-failed entries back to pending. | | addOfflineQueueListener(cb) | Events: operationCompleted, operationFailed, operationMovedToFailed, queueDrained, queueStatusChanged. |

// Queue a save when offline
const { queueId } = await enqueueOfflineOperation({
  type: 'save',
  record: { recordType: 'Note', zoneName: 'Notes', fields: { title: { type: 'string', value: 'Written offline' } } },
});

// Or use saveRecords with automatic fallback
await saveRecords(records, 'private', { queueOnFailure: true });

React Hooks

All hooks handle loading/error/refetch states and clean up listeners on unmount.

useCloudKitRecord(recordName, options)

Fetches and tracks a single record. Optionally re-fetches on push notifications.

import { useCloudKitRecord } from 'expo-cloudkit';

const { data, loading, fetching, error, refetch, update, optimisticStatus } =
  useCloudKitRecord(recordName, { recordType: 'Note', zoneName: 'Notes' });

// Optimistic update — applies locally, rolls back on failure
await update({ title: { type: 'string', value: 'New Title' } });

Returns: data, loading, fetching, error, refetch, update, optimisticStatus, optimisticError

useCloudKitQuery(recordType, options)

Queries records with predicates, sorting, and cursor pagination.

import { useCloudKitQuery } from 'expo-cloudkit';

const { data, loading, hasMore, fetchMore, refetch, optimisticAdd, optimisticRemove } =
  useCloudKitQuery('Note', {
    predicate: { field: 'archived', comparator: '=', value: 0 },
    sortDescriptors: [{ field: 'createdAt', ascending: false }],
    zoneName: 'Notes',
    resultsLimit: 25,
  });

Returns: data, loading, fetching, error, hasMore, fetchMore, refetch, optimisticAdd, optimisticRemove, pendingCount, optimisticErrors

useCloudKitSync(options)

Manages the sync engine lifecycle — starts on mount, stops on unmount.

const { state, isRunning, triggerSync, enqueuePendingChange } = useCloudKitSync({
  zones: ['Notes'],
  onRecordsFetched: (event) => applyChanges(event.changedRecords, event.deletedRecordIDs),
});

useCloudKitStatus(options?)

Combines account status, availability, and web auth into one reactive object.

const { ready, loading, accountStatus, error } = useCloudKitStatus({ pollInterval: 30_000 });

useSyncHealth()

Reactive sync health state updated after each sync cycle.

const { isHealthy, lastSyncAt, failedCount, lastDurationMs } = useSyncHealth();

useCloudKitSubscription(recordType, options)

Creates a CKQuerySubscription on mount and deletes it on unmount. Automatically invalidates useCloudKitQuery hooks when a push arrives.

const { subscriptionId, error } = useCloudKitSubscription('Note', { zoneName: 'Notes' });

React Context

CloudKitProvider is an opt-in context that calls configure() (or configureWeb() on web) on mount, exposes reactive account status, and shares a QueryCache for cross-hook push invalidation.

All hooks work without a provider. When present, they gain automatic database defaults and cache-driven invalidation.

import { CloudKitProvider, useAccountStatus } from 'expo-cloudkit';

export default function App() {
  return (
    <CloudKitProvider
      containerId="iCloud.com.example.myapp"
      observeAccountStatus
      webConfig={{ apiToken: process.env.EXPO_PUBLIC_CLOUDKIT_API_TOKEN }}
    >
      <AppContent />
    </CloudKitProvider>
  );
}

function Header() {
  const status = useAccountStatus(); // reactive, no prop-drilling
  return <Text>{status === 'available' ? 'Synced' : 'Offline'}</Text>;
}

CloudKitProvider props:

| Prop | Type | Default | Description | |------|------|---------|-------------| | containerId | string | required | CloudKit container identifier | | defaultDatabase | DatabaseScope | 'private' | Default database for all hooks in the tree | | observeAccountStatus | boolean | true | Subscribe to onAccountStatusChanged events | | webConfig | WebConfigOptions | undefined | Auto-calls configureWeb() on web |


Web Platform

On web, 20 of 44 operations are supported via CloudKit JS using the optional peer dependency tsl-apple-cloudkit.

Supported on web: configure, account status, zones (CRUD), record CRUD, query, fetchRecordZoneChanges, subscriptions (server-side only — no APNs on web), reference deep linking.

Not supported on web (throws CloudKitNotSupportedError): CKSyncEngine, asset download, offline queue, presentSharingUI, permission mutations.

npm install tsl-apple-cloudkit

| Function | Description | |----------|-------------| | configureWeb(containerId, options) | Initialize CloudKit JS. No-op on native. | | authenticateWeb() | Trigger Apple ID sign-in popup on web. Returns AccountStatus. | | signOutWeb() | Clear the web auth session. No-op on native. | | isWebAuthenticated() | Synchronous check for an active CloudKit JS session. |

await configureWeb('iCloud.com.example.myapp', {
  apiToken: 'YOUR_CLOUDKIT_JS_API_TOKEN', // CloudKit Dashboard → API Access → Tokens
  environment: 'development',
  persistSession: true,
});

const status = await authenticateWeb(); // triggers Apple ID sign-in popup

Error Handling

All async functions throw CloudKitError on failure. On non-iOS platforms, they throw CloudKitNotSupportedError.

import { CloudKitError, CloudKitErrorCode, CloudKitNotSupportedError } from 'expo-cloudkit';

try {
  await saveRecords([record]);
} catch (err) {
  if (err instanceof CloudKitNotSupportedError) return; // Android / web

  if (err instanceof CloudKitError) {
    if (err.recoverySuggestion) Alert.alert('CloudKit Error', err.recoverySuggestion);

    if (err.code === CloudKitErrorCode.CONFLICT) {
      const merged = mergeRecords(record, err.serverRecord!);
      await saveRecords([merged]);
    }
    if (err.code === CloudKitErrorCode.RATE_LIMITED && err.retryAfterSeconds) {
      await new Promise(r => setTimeout(r, err.retryAfterSeconds! * 1000));
    }
  }
}

CloudKitError properties: code (CloudKitErrorCode), message, recoverySuggestion (display-ready hint), retryAfterSeconds (rate-limit cases), serverRecord (CONFLICT cases only).

All error codes:

| Code | When it fires | |------|---------------| | NOT_AUTHENTICATED | User is not signed into iCloud | | NETWORK_UNAVAILABLE | No network connectivity | | QUOTA_EXCEEDED | User's iCloud storage is full | | ZONE_NOT_FOUND | Zone does not exist — call createZone first | | RECORD_NOT_FOUND | Record ID does not exist | | CONFLICT | Server record changed since last fetch — check err.serverRecord | | PERMISSION_DENIED | Insufficient permissions for this operation | | SERVER_REJECTED | CloudKit server rejected the request | | ASSET_TOO_LARGE | CKAsset file exceeds size limit | | LIMIT_EXCEEDED | Batch over 400 records — saveRecords auto-chunks | | RATE_LIMITED | CloudKit rate limiting — check err.retryAfterSeconds | | VALIDATION_FAILED | Record failed runtime schema validation | | SYNC_ENGINE_NOT_RUNNING | startSyncEngine() not called or engine was stopped | | TOKEN_EXPIRED | Sync token expired — full re-sync follows automatically | | ACCOUNT_CHANGED | iCloud account switched — tokens reset, re-sync follows | | SUBSCRIPTION_NOT_FOUND | Subscription ID does not exist | | ALREADY_SHARED | Record is already the root of an existing CKShare | | SHARE_NOT_FOUND | CKShare record does not exist | | PARTICIPANT_NOT_FOUND | Participant is not on this share | | PARTICIPANT_NEEDS_VERIFICATION | Participant must verify identity before being added | | SHARING_UI_UNAVAILABLE | UICloudSharingController could not be presented | | REFERENCE_VIOLATION | Operation would violate a CKRecord.Reference integrity constraint | | NOT_SUPPORTED | CloudKit unavailable on this platform (Android, web) | | UNKNOWN | Unexpected error — check err.message |


Operation Config

All fetch and write operations accept an optional operationConfig parameter to control CloudKit's quality-of-service scheduling and network timeout.

import type { OperationConfig } from 'expo-cloudkit';

| Option | Type | Default | Description | |--------|------|---------|-------------| | qos | 'userInitiated' \| 'utility' \| 'background' \| 'default' | 'userInitiated' | QoS priority for the underlying CKOperation | | timeout | number | system default | Network timeout in seconds | | collectMetrics | boolean | false | Attach _metrics: { durationMs, retryCount } to results |

// High priority — user tapped refresh
const records = await queryRecords('Note', undefined, undefined, 'Notes', 'private', 25,
  undefined, undefined, { qos: 'userInitiated' });

// Background prefetch
await fetchRecordZoneChanges(['Notes'], 'private', undefined, { qos: 'utility', timeout: 60 });

// Bulk import — lowest priority, with metrics
const results = await saveRecords(largeArray, 'private', { qos: 'background', collectMetrics: true });

Rate-limit auto-retry: all operations silently retry up to 3 times on CKError.requestRateLimited, serviceUnavailable, or zoneBusy. Subscribe to addRateLimitedListener to observe retries.


Platform Support Matrix

| Feature group | iOS 16 | iOS 17+ | Web | Android | |---------------|--------|---------|-----|---------| | Core Record CRUD | ✅ | ✅ | ✅ | ❌ | | Zones | ✅ | ✅ | ✅ | ❌ | | Assets (CKAsset) | ✅ | ✅ | ⚠️ upload only | ❌ | | Sharing (CKShare) | ✅ | ✅ | ⚠️ no presentSharingUI | ❌ | | CKSyncEngine | ⚠️ polling fallback | ✅ | ❌ | ❌ | | Push Subscriptions | ✅ | ✅ | ⚠️ server-side only | ❌ | | Offline Queue | ✅ | ✅ | ❌ | ❌ | | React Hooks | ✅ | ✅ | ✅ | ❌ | | Web Platform | ❌ | ❌ | ✅ | ❌ | | Mac Catalyst | ✅ | ✅ | n/a | ❌ |

Legend: ✅ fully supported / ⚠️ partial / ❌ not available


Migration Guide

All releases to date are backwards compatible. No call sites require updates when upgrading. Additions per version:

| Version | Notable additions | |---------|------------------| | 0.8.0 | batchFetchRecords, useCloudKitStatus hook, useSyncHealth hook, CloudKitError.recoverySuggestion, RATE_LIMITED error code | | 0.7.0 | createCloudKitClient (multi-container), deleteRecordWithReferences, persistCursor on queryRecords, clearPersistedCursors | | 0.6.0 | desiredKeys on fetch operations, fetchUserRecordID implemented, operationConfig on all operations, resolveSyncConflict + resolveConflicts option | | 0.5.0 | Web platform via configureWeb / tsl-apple-cloudkit. Add npm install tsl-apple-cloudkit for web support. | | 0.4.0 | CloudKitProvider, useAccountStatus, useCloudKitSubscription, optimistic updates on hooks | | 0.3.0 | Offline queue, useCloudKitRecord, useCloudKitQuery, useCloudKitSync, saveRecords({ queueOnFailure }) | | 0.2.0 | CKSyncEngine, push subscriptions, CKShare |

See CHANGELOG for the full diff per release.


Contributing / License

Issues and pull requests are welcome. API changes require a GitHub issue discussion before implementation.

See CHANGELOG for full version history.

MIT — see LICENSE.