@expo-kit/cloudkit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +145 -2
  2. package/app.plugin.js +1 -0
  3. package/build/codec.d.ts +6 -0
  4. package/build/codec.d.ts.map +1 -0
  5. package/build/codec.js +34 -0
  6. package/build/codec.js.map +1 -0
  7. package/build/index.d.ts +7 -0
  8. package/build/index.d.ts.map +1 -0
  9. package/build/index.js +9 -0
  10. package/build/index.js.map +1 -0
  11. package/build/native.d.ts +86 -0
  12. package/build/native.d.ts.map +1 -0
  13. package/build/native.js +7 -0
  14. package/build/native.js.map +1 -0
  15. package/build/schema.d.ts +98 -0
  16. package/build/schema.d.ts.map +1 -0
  17. package/build/schema.js +83 -0
  18. package/build/schema.js.map +1 -0
  19. package/build/sync.d.ts +78 -0
  20. package/build/sync.d.ts.map +1 -0
  21. package/build/sync.js +236 -0
  22. package/build/sync.js.map +1 -0
  23. package/build/use-cloud-sync.d.ts +5 -0
  24. package/build/use-cloud-sync.d.ts.map +1 -0
  25. package/build/use-cloud-sync.js +6 -0
  26. package/build/use-cloud-sync.js.map +1 -0
  27. package/expo-module.config.json +7 -0
  28. package/ios/CloudSyncEngineObject.swift +62 -0
  29. package/ios/ExpoKitCloudKit.podspec +28 -0
  30. package/ios/ExpoKitCloudKitModule.swift +50 -0
  31. package/ios/LocalStore.swift +351 -0
  32. package/ios/PushSubscriber.swift +14 -0
  33. package/ios/RecordMapper.swift +237 -0
  34. package/ios/Records.swift +60 -0
  35. package/ios/SchemaModel.swift +68 -0
  36. package/ios/SyncCore.swift +586 -0
  37. package/package.json +61 -4
  38. package/plugin/build/index.d.ts +7 -0
  39. package/plugin/build/index.js +68 -0
  40. package/src/codec.ts +36 -0
  41. package/src/index.ts +26 -0
  42. package/src/native.ts +83 -0
  43. package/src/schema.ts +169 -0
  44. package/src/sync.ts +322 -0
  45. package/src/use-cloud-sync.ts +9 -0
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@expo-kit/cloudkit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Typed CloudKit sync for Expo apps, built on CKSyncEngine",
5
+ "keywords": [
6
+ "expo",
7
+ "expo-kit",
8
+ "react-native",
9
+ "cloudkit",
10
+ "icloud",
11
+ "sync"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/islamashraful/expo-kit.git",
17
+ "directory": "packages/cloudkit"
18
+ },
19
+ "main": "build/index.js",
20
+ "types": "build/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./build/index.d.ts",
24
+ "react-native": "./src/index.ts",
25
+ "default": "./build/index.js"
26
+ },
27
+ "./app.plugin.js": "./app.plugin.js",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "build",
32
+ "src",
33
+ "!src/**/*.test.ts",
34
+ "!src/**/*.test-d.ts",
35
+ "ios",
36
+ "plugin/build",
37
+ "app.plugin.js",
38
+ "expo-module.config.json"
39
+ ],
40
+ "sideEffects": false,
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.build.json && tsc -p plugin/tsconfig.json",
43
+ "typecheck": "tsc --noEmit && tsc --noEmit -p plugin/tsconfig.json",
44
+ "test": "bun test"
45
+ },
46
+ "dependencies": {
47
+ "@expo-kit/core": "^0.1.0"
48
+ },
49
+ "peerDependencies": {
50
+ "expo": "*",
51
+ "react": "*",
52
+ "react-native": "*"
53
+ },
54
+ "devDependencies": {
55
+ "@types/bun": "1.3.0",
56
+ "@types/react": "~19.2.2",
57
+ "expo": "~57.0.28",
58
+ "typescript": "~6.0.3"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ }
63
+ }
@@ -0,0 +1,7 @@
1
+ import { type ConfigPlugin } from 'expo/config-plugins';
2
+ export type CloudKitPluginOptions = {
3
+ /** iCloud container IDs the app uses, e.g. `['iCloud.com.example.app']`. */
4
+ containers: string[];
5
+ };
6
+ declare const _default: ConfigPlugin<CloudKitPluginOptions>;
7
+ export default _default;
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const config_plugins_1 = require("expo/config-plugins");
4
+ // CKSyncEngine needs iOS 17.
5
+ const MIN_IOS = '17.0';
6
+ const withCloudKit = (config, options) => {
7
+ const containers = options?.containers ?? [];
8
+ if (containers.length === 0) {
9
+ throw new Error('@expo-kit/cloudkit: pass at least one iCloud container, e.g. { containers: ["iCloud.com.example.app"] }');
10
+ }
11
+ config = (0, config_plugins_1.withEntitlementsPlist)(config, (mod) => {
12
+ const entitlements = mod.modResults;
13
+ entitlements['com.apple.developer.icloud-container-identifiers'] = unique([
14
+ ...asStrings(entitlements['com.apple.developer.icloud-container-identifiers']),
15
+ ...containers,
16
+ ]);
17
+ entitlements['com.apple.developer.icloud-services'] = unique([
18
+ ...asStrings(entitlements['com.apple.developer.icloud-services']),
19
+ 'CloudKit',
20
+ ]);
21
+ // Silent pushes tell the app that something changed in iCloud.
22
+ entitlements['aps-environment'] ??= 'development';
23
+ return mod;
24
+ });
25
+ config = (0, config_plugins_1.withInfoPlist)(config, (mod) => {
26
+ mod.modResults.UIBackgroundModes = unique([...asStrings(mod.modResults.UIBackgroundModes), 'remote-notification']);
27
+ // Read by the module to refuse unknown containers with a clear error. CloudKit itself
28
+ // crashes the app when a container is missing from the entitlements.
29
+ mod.modResults.ExpoKitCloudKitContainers = containers;
30
+ return mod;
31
+ });
32
+ config = (0, config_plugins_1.withPodfileProperties)(config, (mod) => {
33
+ const current = mod.modResults['ios.deploymentTarget'];
34
+ if (!current || compareVersions(current, MIN_IOS) < 0)
35
+ mod.modResults['ios.deploymentTarget'] = MIN_IOS;
36
+ return mod;
37
+ });
38
+ // The app target must be at least 17.0 too, or it can't link the module.
39
+ config = (0, config_plugins_1.withXcodeProject)(config, (mod) => {
40
+ const configurations = mod.modResults.pbxXCBuildConfigurationSection();
41
+ for (const configuration of Object.values(configurations)) {
42
+ const settings = typeof configuration === 'object' ? configuration.buildSettings : undefined;
43
+ const current = settings?.IPHONEOS_DEPLOYMENT_TARGET;
44
+ if (settings && current && compareVersions(current.replace(/"/g, ''), MIN_IOS) < 0) {
45
+ settings.IPHONEOS_DEPLOYMENT_TARGET = MIN_IOS;
46
+ }
47
+ }
48
+ return mod;
49
+ });
50
+ return config;
51
+ };
52
+ function asStrings(value) {
53
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
54
+ }
55
+ function unique(values) {
56
+ return [...new Set(values)];
57
+ }
58
+ function compareVersions(a, b) {
59
+ const pa = a.split('.').map(Number);
60
+ const pb = b.split('.').map(Number);
61
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
62
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
63
+ if (diff !== 0)
64
+ return diff;
65
+ }
66
+ return 0;
67
+ }
68
+ exports.default = (0, config_plugins_1.createRunOncePlugin)(withCloudKit, '@expo-kit/cloudkit');
package/src/codec.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { CloudSchema } from './schema';
2
+
3
+ /** App record → plain fields for the native side (dates as milliseconds, no `id`, no `undefined`). */
4
+ export function encodeFields(schema: CloudSchema, type: string, record: Record<string, unknown>): Record<string, unknown> {
5
+ const fields = schema.types[type];
6
+ if (!fields) throw new Error(`"${type}" isn't a record type in the schema.`);
7
+ const result: Record<string, unknown> = {};
8
+ for (const [key, value] of Object.entries(record)) {
9
+ if (key === 'id' || value === undefined) continue;
10
+ result[key] = value instanceof Date ? value.getTime() : value;
11
+ }
12
+ return result;
13
+ }
14
+
15
+ /** Native fields → app record (milliseconds back to `Date`, empty optionals as `null`). */
16
+ export function decodeRecord(
17
+ schema: CloudSchema,
18
+ type: string,
19
+ id: string,
20
+ fields: Record<string, unknown>,
21
+ ): Record<string, unknown> | null {
22
+ const spec = schema.types[type];
23
+ if (!spec) return null;
24
+ const record: Record<string, unknown> = { id };
25
+ for (const [name, field] of Object.entries(spec)) {
26
+ const value = fields[name];
27
+ if (value === undefined || value === null) {
28
+ record[name] = null;
29
+ } else if (field.kind === 'date' && typeof value === 'number') {
30
+ record[name] = new Date(value);
31
+ } else {
32
+ record[name] = value;
33
+ }
34
+ }
35
+ return record;
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { nativeModule } from './native';
2
+
3
+ export {
4
+ defineCloudSchema,
5
+ field,
6
+ type CloudRecord,
7
+ type CloudSchema,
8
+ type Field,
9
+ type FieldKind,
10
+ type RecordTypeName,
11
+ } from './schema';
12
+ export {
13
+ createCloudSync,
14
+ type ApplyHandlers,
15
+ type CloudSync,
16
+ type CloudSyncOptions,
17
+ type CloudSyncStatus,
18
+ type ConflictRule,
19
+ } from './sync';
20
+ export { useCloudSync } from './use-cloud-sync';
21
+ export type { AccountChangeKind, AccountStatus } from './native';
22
+
23
+ /** `true` when the native module is linked (a development or release build on iOS). */
24
+ export function isCloudKitSupported(): boolean {
25
+ return nativeModule?.isSupported() ?? false;
26
+ }
package/src/native.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { requireOptionalNativeModule, SharedObject, type NativeModule } from 'expo';
2
+
3
+ import type { SchemaSpec } from './schema';
4
+
5
+ export type AccountStatus =
6
+ | 'available'
7
+ | 'noAccount'
8
+ | 'restricted'
9
+ | 'temporarilyUnavailable'
10
+ | 'couldNotDetermine'
11
+ | 'unknown';
12
+
13
+ export type NativeStatus = {
14
+ account: AccountStatus;
15
+ syncing: boolean;
16
+ initialSyncDone: boolean;
17
+ pendingUploads: number;
18
+ pendingChanges: number;
19
+ /** Milliseconds since 1970. */
20
+ lastSyncedAt: number | null;
21
+ error: string | null;
22
+ };
23
+
24
+ export type NativeChange = {
25
+ sequence: number;
26
+ type: string;
27
+ id: string;
28
+ operation: 'save' | 'delete';
29
+ fields: Record<string, unknown>;
30
+ };
31
+
32
+ export type AccountChangeKind = 'signIn' | 'signOut' | 'switchAccounts' | 'cloudDataDeleted' | 'unknown';
33
+
34
+ export type NativeEvents = {
35
+ status: (status: NativeStatus) => void;
36
+ changes: () => void;
37
+ account: (change: { change: AccountChangeKind; account: AccountStatus }) => void;
38
+ error: (error: { message: string; context: string }) => void;
39
+ };
40
+
41
+ export type EngineOptions = {
42
+ containerId: string;
43
+ zoneName: string;
44
+ conflict: 'newest' | 'server' | 'device';
45
+ schema: SchemaSpec;
46
+ };
47
+
48
+ /** The native engine. Everything that touches iCloud runs in Swift. */
49
+ export interface NativeEngine {
50
+ start(): Promise<void>;
51
+ syncNow(): Promise<void>;
52
+ stop(): Promise<void>;
53
+ save(input: { type: string; id: string; fields: Record<string, unknown> }): void;
54
+ delete(input: { type: string; id: string }): void;
55
+ pendingChanges(limit: number): Promise<NativeChange[]>;
56
+ ack(sequences: number[]): Promise<void>;
57
+ status(): NativeStatus;
58
+ addListener<E extends keyof NativeEvents>(event: E, listener: NativeEvents[E]): { remove(): void };
59
+ }
60
+
61
+ declare class CloudSyncEngine extends SharedObject<NativeEvents> implements NativeEngine {
62
+ constructor(options: EngineOptions);
63
+ start(): Promise<void>;
64
+ syncNow(): Promise<void>;
65
+ stop(): Promise<void>;
66
+ save(input: { type: string; id: string; fields: Record<string, unknown> }): void;
67
+ delete(input: { type: string; id: string }): void;
68
+ pendingChanges(limit: number): Promise<NativeChange[]>;
69
+ ack(sequences: number[]): Promise<void>;
70
+ status(): NativeStatus;
71
+ }
72
+
73
+ declare class ExpoKitCloudKitModule extends NativeModule {
74
+ isSupported(): boolean;
75
+ CloudSyncEngine: typeof CloudSyncEngine;
76
+ }
77
+
78
+ // Null in Expo Go, on Android and on web, where the native module isn't linked.
79
+ export const nativeModule = requireOptionalNativeModule<ExpoKitCloudKitModule>('ExpoKitCloudKit');
80
+
81
+ export function createNativeEngine(options: EngineOptions): NativeEngine | null {
82
+ return nativeModule ? new nativeModule.CloudSyncEngine(options) : null;
83
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,169 @@
1
+ export type FieldKind =
2
+ | 'string'
3
+ | 'int'
4
+ | 'double'
5
+ | 'bool'
6
+ | 'date'
7
+ | 'stringList'
8
+ | 'intList'
9
+ | 'doubleList'
10
+ | 'reference'
11
+ | 'asset';
12
+
13
+ type ValueOfKind = {
14
+ string: string;
15
+ int: number;
16
+ double: number;
17
+ bool: boolean;
18
+ date: Date;
19
+ stringList: string[];
20
+ intList: number[];
21
+ doubleList: number[];
22
+ /** The ID of the linked record. */
23
+ reference: string;
24
+ /** A local file path (`file://…` or an absolute path). */
25
+ asset: string;
26
+ };
27
+
28
+ export type Field<K extends FieldKind = FieldKind, Optional extends boolean = boolean, Target extends string = string> = {
29
+ readonly kind: K;
30
+ readonly isOptional: Optional;
31
+ /** For references: the record type it links to. */
32
+ readonly target: K extends 'reference' ? Target : undefined;
33
+ };
34
+
35
+ /** A field that can also be left out or set to `null`. */
36
+ type Optionalizable<K extends FieldKind, Target extends string = string> = Field<K, false, Target> & {
37
+ optional(): Field<K, true, Target>;
38
+ };
39
+
40
+ function make<K extends FieldKind, Target extends string = string>(
41
+ kind: K,
42
+ target?: Target,
43
+ ): Optionalizable<K, Target> {
44
+ const base = { kind, isOptional: false as const, target: (target ?? undefined) as never };
45
+ return { ...base, optional: () => ({ ...base, isOptional: true as const }) } as unknown as Optionalizable<K, Target>;
46
+ }
47
+
48
+ /** Field kinds for `defineCloudSchema`. Add `.optional()` for a field that can be empty. */
49
+ export const field = {
50
+ string: () => make('string'),
51
+ /** Whole numbers. Stored as a 64-bit integer. */
52
+ int: () => make('int'),
53
+ double: () => make('double'),
54
+ bool: () => make('bool'),
55
+ date: () => make('date'),
56
+ stringList: () => make('stringList'),
57
+ intList: () => make('intList'),
58
+ doubleList: () => make('doubleList'),
59
+ /** A link to another record type in the same schema, by its ID. */
60
+ reference: <Target extends string>(target: Target) => make('reference', target),
61
+ /** A file stored in iCloud. The app passes and gets back a local file path. */
62
+ asset: () => make('asset'),
63
+ };
64
+
65
+ type AnyField = Field<FieldKind, boolean, string>;
66
+ type FieldsShape = Record<string, AnyField>;
67
+ export type SchemaShape = Record<string, FieldsShape>;
68
+
69
+ /** Every reference must point to a record type in the same schema. */
70
+ type CheckReferences<S extends SchemaShape> = {
71
+ [T in keyof S]: {
72
+ [F in keyof S[T]]: S[T][F] extends Field<'reference', boolean, infer Target>
73
+ ? Target extends keyof S
74
+ ? S[T][F]
75
+ : Field<'reference', boolean, Extract<keyof S, string>>
76
+ : S[T][F];
77
+ };
78
+ };
79
+
80
+ export type CloudSchema<S extends SchemaShape = SchemaShape> = {
81
+ readonly types: S;
82
+ };
83
+
84
+ /** The names of the record types in a schema, e.g. `'Shift' | 'Venue'`. */
85
+ export type RecordTypeName<S extends CloudSchema> = Extract<keyof S['types'], string>;
86
+
87
+ type FieldValue<F> = F extends Field<infer K, boolean, string> ? ValueOfKind[K] : never;
88
+ type RequiredKeys<Fs> = { [K in keyof Fs]: Fs[K] extends Field<FieldKind, false, string> ? K : never }[keyof Fs];
89
+ type OptionalKeys<Fs> = { [K in keyof Fs]: Fs[K] extends Field<FieldKind, false, string> ? never : K }[keyof Fs];
90
+ type Simplify<T> = { [K in keyof T]: T[K] } & {};
91
+
92
+ /**
93
+ * The TypeScript type of one record, e.g. `CloudRecord<typeof schema, 'Shift'>`.
94
+ * Every record has a string `id`. Optional fields can be left out or `null`.
95
+ */
96
+ export type CloudRecord<S extends CloudSchema, T extends RecordTypeName<S>> = Simplify<
97
+ { id: string } & { [K in RequiredKeys<S['types'][T]>]: FieldValue<S['types'][T][K]> } & {
98
+ [K in OptionalKeys<S['types'][T]>]?: FieldValue<S['types'][T][K]> | null;
99
+ }
100
+ >;
101
+
102
+ /** Names CloudKit or this package use for themselves. */
103
+ const RESERVED_FIELD_NAMES = new Set([
104
+ 'id',
105
+ 'recordName',
106
+ 'recordID',
107
+ 'recordType',
108
+ 'createdAt',
109
+ 'modifiedAt',
110
+ 'createdBy',
111
+ 'modifiedBy',
112
+ 'creationDate',
113
+ 'modificationDate',
114
+ 'creatorUserRecordID',
115
+ 'lastModifiedUserRecordID',
116
+ 'recordChangeTag',
117
+ 'ekModifiedAt',
118
+ ]);
119
+ // CloudKit: starts with a letter, then letters, digits or underscores, at most 255 characters.
120
+ const NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,254}$/;
121
+
122
+ /**
123
+ * Describes the app's data for iCloud. One entry per record type, one field kind per field.
124
+ * Throws when a name isn't allowed in CloudKit or a reference points to an unknown type.
125
+ *
126
+ * Rule after release: only add fields and types. Never rename or remove one.
127
+ */
128
+ export function defineCloudSchema<const S extends SchemaShape>(types: S & CheckReferences<S>): CloudSchema<S> {
129
+ const typeNames = Object.keys(types);
130
+ if (typeNames.length === 0) throw new Error('defineCloudSchema: add at least one record type.');
131
+
132
+ for (const typeName of typeNames) {
133
+ if (!NAME_PATTERN.test(typeName)) {
134
+ throw new Error(`defineCloudSchema: "${typeName}" isn't a valid record type name (letters, digits, _; starts with a letter).`);
135
+ }
136
+ const fields = types[typeName] as FieldsShape;
137
+ for (const [fieldName, spec] of Object.entries(fields)) {
138
+ if (!NAME_PATTERN.test(fieldName)) {
139
+ throw new Error(`defineCloudSchema: ${typeName}.${fieldName} isn't a valid field name (letters, digits, _; starts with a letter).`);
140
+ }
141
+ if (RESERVED_FIELD_NAMES.has(fieldName) || fieldName.startsWith('ek_')) {
142
+ throw new Error(`defineCloudSchema: ${typeName}.${fieldName} is a reserved name. Pick another one.`);
143
+ }
144
+ if (spec.kind === 'reference' && !typeNames.includes(spec.target as string)) {
145
+ throw new Error(`defineCloudSchema: ${typeName}.${fieldName} links to "${String(spec.target)}", which isn't in the schema.`);
146
+ }
147
+ }
148
+ }
149
+ return { types: types as S };
150
+ }
151
+
152
+ export type SchemaSpec = {
153
+ types: { name: string; fields: { name: string; kind: FieldKind; optional: boolean; target: string | null }[] }[];
154
+ };
155
+
156
+ /** The plain description sent to the native side. */
157
+ export function toSchemaSpec(schema: CloudSchema): SchemaSpec {
158
+ return {
159
+ types: Object.entries(schema.types).map(([name, fields]) => ({
160
+ name,
161
+ fields: Object.entries(fields).map(([fieldName, spec]) => ({
162
+ name: fieldName,
163
+ kind: spec.kind,
164
+ optional: spec.isOptional,
165
+ target: spec.kind === 'reference' ? (spec.target as string) : null,
166
+ })),
167
+ })),
168
+ };
169
+ }