@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.
- package/README.md +145 -2
- package/app.plugin.js +1 -0
- package/build/codec.d.ts +6 -0
- package/build/codec.d.ts.map +1 -0
- package/build/codec.js +34 -0
- package/build/codec.js.map +1 -0
- package/build/index.d.ts +7 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +9 -0
- package/build/index.js.map +1 -0
- package/build/native.d.ts +86 -0
- package/build/native.d.ts.map +1 -0
- package/build/native.js +7 -0
- package/build/native.js.map +1 -0
- package/build/schema.d.ts +98 -0
- package/build/schema.d.ts.map +1 -0
- package/build/schema.js +83 -0
- package/build/schema.js.map +1 -0
- package/build/sync.d.ts +78 -0
- package/build/sync.d.ts.map +1 -0
- package/build/sync.js +236 -0
- package/build/sync.js.map +1 -0
- package/build/use-cloud-sync.d.ts +5 -0
- package/build/use-cloud-sync.d.ts.map +1 -0
- package/build/use-cloud-sync.js +6 -0
- package/build/use-cloud-sync.js.map +1 -0
- package/expo-module.config.json +7 -0
- package/ios/CloudSyncEngineObject.swift +62 -0
- package/ios/ExpoKitCloudKit.podspec +28 -0
- package/ios/ExpoKitCloudKitModule.swift +50 -0
- package/ios/LocalStore.swift +351 -0
- package/ios/PushSubscriber.swift +14 -0
- package/ios/RecordMapper.swift +237 -0
- package/ios/Records.swift +60 -0
- package/ios/SchemaModel.swift +68 -0
- package/ios/SyncCore.swift +586 -0
- package/package.json +61 -4
- package/plugin/build/index.d.ts +7 -0
- package/plugin/build/index.js +68 -0
- package/src/codec.ts +36 -0
- package/src/index.ts +26 -0
- package/src/native.ts +83 -0
- package/src/schema.ts +169 -0
- package/src/sync.ts +322 -0
- package/src/use-cloud-sync.ts +9 -0
package/src/sync.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { reportError } from '@expo-kit/core';
|
|
2
|
+
import { AppState } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { decodeRecord, encodeFields } from './codec';
|
|
5
|
+
import {
|
|
6
|
+
createNativeEngine,
|
|
7
|
+
type AccountChangeKind,
|
|
8
|
+
type AccountStatus,
|
|
9
|
+
type NativeChange,
|
|
10
|
+
type NativeEngine,
|
|
11
|
+
type NativeStatus,
|
|
12
|
+
} from './native';
|
|
13
|
+
import { toSchemaSpec, type CloudRecord, type CloudSchema, type RecordTypeName } from './schema';
|
|
14
|
+
|
|
15
|
+
export type ConflictRule = 'newest' | 'server' | 'device';
|
|
16
|
+
|
|
17
|
+
type MaybePromise = void | Promise<void>;
|
|
18
|
+
|
|
19
|
+
/** How the app writes changes from iCloud into its own database, per record type. */
|
|
20
|
+
export type ApplyHandlers<S extends CloudSchema> = {
|
|
21
|
+
[T in RecordTypeName<S>]: {
|
|
22
|
+
/** New or changed records from another device. Insert or update them. */
|
|
23
|
+
save: (records: CloudRecord<S, T>[]) => MaybePromise;
|
|
24
|
+
/** Records deleted on another device. */
|
|
25
|
+
delete: (ids: string[]) => MaybePromise;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type CloudSyncOptions<S extends CloudSchema> = {
|
|
30
|
+
/** iCloud container ID, also listed in the config plugin, e.g. `'iCloud.com.example.app'`. */
|
|
31
|
+
container: string;
|
|
32
|
+
schema: S;
|
|
33
|
+
apply: ApplyHandlers<S>;
|
|
34
|
+
/**
|
|
35
|
+
* Who wins when the same record changed on two devices. Default `'newest'`: the later edit wins,
|
|
36
|
+
* and a delete from another device wins over a local edit. `'server'`: iCloud wins. `'device'`: this device wins.
|
|
37
|
+
*/
|
|
38
|
+
conflict?: ConflictRule;
|
|
39
|
+
/** Default `'AppData'`. Only change it to run two separate syncs in one app. */
|
|
40
|
+
zone?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Called when the iCloud account changes (signed out, switched accounts) or this app's iCloud
|
|
43
|
+
* data was deleted. Sync state is reset; the app decides what to do with its local data,
|
|
44
|
+
* e.g. keep it and call `importAll` to upload it to the new account.
|
|
45
|
+
*/
|
|
46
|
+
onAccountChange?: (event: { change: AccountChangeKind; account: AccountStatus }) => void;
|
|
47
|
+
/** Sync when the app comes back to the front. Default `true`. */
|
|
48
|
+
syncOnForeground?: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type CloudSyncStatus = {
|
|
52
|
+
/** `false` in Expo Go, on Android and on web. Everything else is then a no-op. */
|
|
53
|
+
supported: boolean;
|
|
54
|
+
account: AccountStatus;
|
|
55
|
+
syncing: boolean;
|
|
56
|
+
/** The first download finished. Local changes upload only after that. */
|
|
57
|
+
initialSyncDone: boolean;
|
|
58
|
+
/** Local changes not yet in iCloud. */
|
|
59
|
+
pendingUploads: number;
|
|
60
|
+
/** Changes from iCloud not yet applied to the app's database. */
|
|
61
|
+
pendingChanges: number;
|
|
62
|
+
lastSyncedAt: Date | null;
|
|
63
|
+
/** The last error, if the last sync didn't finish. */
|
|
64
|
+
error: string | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type CloudSync<S extends CloudSchema> = {
|
|
68
|
+
/** Starts syncing: downloads first, then uploads. Call once, e.g. after the app's database is ready. */
|
|
69
|
+
start(): Promise<void>;
|
|
70
|
+
/** Queues a new or changed record for upload. Call after every local insert or update. */
|
|
71
|
+
save<T extends RecordTypeName<S>>(type: T, record: CloudRecord<S, T>): void;
|
|
72
|
+
/** Queues a delete. Call after every local delete. */
|
|
73
|
+
delete<T extends RecordTypeName<S>>(type: T, id: string): void;
|
|
74
|
+
/** Queues many records at once, e.g. existing data the first time sync is turned on. */
|
|
75
|
+
importAll<T extends RecordTypeName<S>>(type: T, records: CloudRecord<S, T>[]): void;
|
|
76
|
+
/** Downloads and uploads now, e.g. pull to refresh. */
|
|
77
|
+
syncNow(): Promise<void>;
|
|
78
|
+
getStatus(): CloudSyncStatus;
|
|
79
|
+
/** Calls `listener` on every status change. Returns a function that stops it. */
|
|
80
|
+
subscribe(listener: (status: CloudSyncStatus) => void): () => void;
|
|
81
|
+
/** Stops syncing and releases the native engine. */
|
|
82
|
+
stop(): Promise<void>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const BATCH = 200;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Creates the sync for one schema. Create it once (module level) and call `start()`.
|
|
89
|
+
* In Expo Go (no native module) every method is a safe no-op.
|
|
90
|
+
*/
|
|
91
|
+
export function createCloudSync<S extends CloudSchema>(options: CloudSyncOptions<S>): CloudSync<S> {
|
|
92
|
+
return createCloudSyncWithEngine(options, createNativeEngine);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Internal: lets tests pass a fake engine. */
|
|
96
|
+
export function createCloudSyncWithEngine<S extends CloudSchema>(
|
|
97
|
+
options: CloudSyncOptions<S>,
|
|
98
|
+
makeEngine: typeof createNativeEngine,
|
|
99
|
+
): CloudSync<S> {
|
|
100
|
+
const { schema, apply, conflict = 'newest', zone = 'AppData', syncOnForeground = true } = options;
|
|
101
|
+
let engine: NativeEngine | null = null;
|
|
102
|
+
let status: CloudSyncStatus = toStatus(null, false);
|
|
103
|
+
const listeners = new Set<(status: CloudSyncStatus) => void>();
|
|
104
|
+
const subscriptions: { remove(): void }[] = [];
|
|
105
|
+
let draining: Promise<void> | null = null;
|
|
106
|
+
let drainAgain = false;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
engine = makeEngine({ containerId: options.container, zoneName: zone, conflict, schema: toSchemaSpec(schema) });
|
|
110
|
+
} catch (error) {
|
|
111
|
+
reportError(error, { fn: 'createCloudSync' });
|
|
112
|
+
}
|
|
113
|
+
if (engine) status = toStatus(safeNativeStatus(engine), true);
|
|
114
|
+
|
|
115
|
+
function setStatus(next: CloudSyncStatus) {
|
|
116
|
+
status = next;
|
|
117
|
+
listeners.forEach((listener) => listener(status));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Applies changes from iCloud, then confirms them. iCloud doesn't keep any order between
|
|
122
|
+
* records (a note can arrive before its folder), so a group that fails is skipped and tried
|
|
123
|
+
* again after the others. Stops when a full pass makes no progress; the rest waits for the next sync.
|
|
124
|
+
*/
|
|
125
|
+
function drain(): Promise<void> {
|
|
126
|
+
if (!engine) return Promise.resolve();
|
|
127
|
+
if (draining) {
|
|
128
|
+
drainAgain = true;
|
|
129
|
+
return draining;
|
|
130
|
+
}
|
|
131
|
+
const current = engine;
|
|
132
|
+
draining = (async () => {
|
|
133
|
+
let progressSinceRetry = false;
|
|
134
|
+
do {
|
|
135
|
+
drainAgain = false;
|
|
136
|
+
let failed = new Set<number>();
|
|
137
|
+
while (true) {
|
|
138
|
+
// Failed changes stay in the inbox; skip them in this pass so later ones get their turn.
|
|
139
|
+
const changes = (await current.pendingChanges(BATCH + failed.size)).filter((change) => !failed.has(change.sequence));
|
|
140
|
+
if (changes.length === 0) {
|
|
141
|
+
if (failed.size === 0 || !progressSinceRetry) break;
|
|
142
|
+
failed = new Set(); // Something else was applied since: try the failed ones again.
|
|
143
|
+
progressSinceRetry = false;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const { done, failedSequences } = await applyInOrder(changes.slice(0, BATCH));
|
|
147
|
+
if (done.length > 0) {
|
|
148
|
+
await current.ack(done);
|
|
149
|
+
progressSinceRetry = true;
|
|
150
|
+
}
|
|
151
|
+
failedSequences.forEach((sequence) => failed.add(sequence));
|
|
152
|
+
}
|
|
153
|
+
} while (drainAgain);
|
|
154
|
+
})()
|
|
155
|
+
.catch((error) => reportError(error, { fn: 'cloudSync.drain' }))
|
|
156
|
+
.finally(() => {
|
|
157
|
+
draining = null;
|
|
158
|
+
});
|
|
159
|
+
return draining;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Groups consecutive changes of the same type and operation, keeping their order. */
|
|
163
|
+
async function applyInOrder(changes: NativeChange[]): Promise<{ done: number[]; failedSequences: number[] }> {
|
|
164
|
+
const done: number[] = [];
|
|
165
|
+
const failedSequences: number[] = [];
|
|
166
|
+
let index = 0;
|
|
167
|
+
while (index < changes.length) {
|
|
168
|
+
const first = changes[index]!;
|
|
169
|
+
const group: NativeChange[] = [];
|
|
170
|
+
while (index < changes.length && changes[index]!.type === first.type && changes[index]!.operation === first.operation) {
|
|
171
|
+
group.push(changes[index]!);
|
|
172
|
+
index += 1;
|
|
173
|
+
}
|
|
174
|
+
const handler = (apply as Record<string, { save: (r: unknown[]) => MaybePromise; delete: (ids: string[]) => MaybePromise }>)[first.type];
|
|
175
|
+
if (!handler) {
|
|
176
|
+
// A type no longer in the schema: nothing to apply it to.
|
|
177
|
+
done.push(...group.map((change) => change.sequence));
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
if (first.operation === 'delete') {
|
|
182
|
+
await handler.delete(group.map((change) => change.id));
|
|
183
|
+
} else {
|
|
184
|
+
const records = group
|
|
185
|
+
.map((change) => decodeRecord(schema, change.type, change.id, change.fields))
|
|
186
|
+
.filter((record): record is Record<string, unknown> => record !== null);
|
|
187
|
+
await handler.save(records);
|
|
188
|
+
}
|
|
189
|
+
done.push(...group.map((change) => change.sequence));
|
|
190
|
+
} catch (error) {
|
|
191
|
+
reportError(error, { fn: 'cloudSync.apply', type: first.type, operation: first.operation });
|
|
192
|
+
failedSequences.push(...group.map((change) => change.sequence));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { done, failedSequences };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (engine) {
|
|
199
|
+
const current = engine;
|
|
200
|
+
subscriptions.push(
|
|
201
|
+
current.addListener('status', (next) => setStatus(toStatus(next, true))),
|
|
202
|
+
current.addListener('changes', () => void drain()),
|
|
203
|
+
current.addListener('account', (event) => {
|
|
204
|
+
try {
|
|
205
|
+
options.onAccountChange?.(event);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
reportError(error, { fn: 'cloudSync.onAccountChange' });
|
|
208
|
+
}
|
|
209
|
+
}),
|
|
210
|
+
current.addListener('error', (event) => reportError(new Error(event.message), { fn: 'cloudSync', context: event.context })),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let foreground: { remove(): void } | null = null;
|
|
215
|
+
let lastState = AppState.currentState;
|
|
216
|
+
|
|
217
|
+
const sync: CloudSync<S> = {
|
|
218
|
+
async start() {
|
|
219
|
+
if (!engine) return;
|
|
220
|
+
try {
|
|
221
|
+
await drain(); // Changes saved before the last app kill.
|
|
222
|
+
await engine.start();
|
|
223
|
+
await drain();
|
|
224
|
+
} catch (error) {
|
|
225
|
+
reportError(error, { fn: 'cloudSync.start' });
|
|
226
|
+
}
|
|
227
|
+
if (syncOnForeground && !foreground) {
|
|
228
|
+
foreground = AppState.addEventListener('change', (next) => {
|
|
229
|
+
if (lastState !== 'active' && next === 'active') void sync.syncNow();
|
|
230
|
+
lastState = next;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
save(type, record) {
|
|
236
|
+
if (!engine) return;
|
|
237
|
+
const current = engine;
|
|
238
|
+
guard(() => current.save({ type, id: record.id, fields: encodeFields(schema, type, record as Record<string, unknown>) }), {
|
|
239
|
+
fn: 'cloudSync.save',
|
|
240
|
+
type,
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
delete(type, id) {
|
|
245
|
+
if (!engine) return;
|
|
246
|
+
const current = engine;
|
|
247
|
+
guard(() => current.delete({ type, id }), { fn: 'cloudSync.delete', type });
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
importAll(type, records) {
|
|
251
|
+
for (const record of records) sync.save(type, record);
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async syncNow() {
|
|
255
|
+
if (!engine) return;
|
|
256
|
+
try {
|
|
257
|
+
await engine.syncNow();
|
|
258
|
+
} catch (error) {
|
|
259
|
+
reportError(error, { fn: 'cloudSync.syncNow' });
|
|
260
|
+
}
|
|
261
|
+
await drain();
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
getStatus: () => status,
|
|
265
|
+
|
|
266
|
+
subscribe(listener) {
|
|
267
|
+
listeners.add(listener);
|
|
268
|
+
return () => {
|
|
269
|
+
listeners.delete(listener);
|
|
270
|
+
};
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
async stop() {
|
|
274
|
+
foreground?.remove();
|
|
275
|
+
foreground = null;
|
|
276
|
+
subscriptions.splice(0).forEach((subscription) => subscription.remove());
|
|
277
|
+
const current = engine;
|
|
278
|
+
engine = null;
|
|
279
|
+
setStatus(toStatus(null, false));
|
|
280
|
+
try {
|
|
281
|
+
await current?.stop();
|
|
282
|
+
} catch (error) {
|
|
283
|
+
reportError(error, { fn: 'cloudSync.stop' });
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
return sync;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A rejected record is a bug in the app: throw in development so it's seen right away,
|
|
292
|
+
* report it in production instead of breaking the app's own save.
|
|
293
|
+
*/
|
|
294
|
+
function guard(body: () => void, context: Record<string, unknown>) {
|
|
295
|
+
try {
|
|
296
|
+
body();
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (__DEV__) throw error;
|
|
299
|
+
reportError(error, context);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function safeNativeStatus(engine: NativeEngine): NativeStatus | null {
|
|
304
|
+
try {
|
|
305
|
+
return engine.status();
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function toStatus(native: NativeStatus | null, supported: boolean): CloudSyncStatus {
|
|
312
|
+
return {
|
|
313
|
+
supported,
|
|
314
|
+
account: native?.account ?? 'unknown',
|
|
315
|
+
syncing: native?.syncing ?? false,
|
|
316
|
+
initialSyncDone: native?.initialSyncDone ?? false,
|
|
317
|
+
pendingUploads: native?.pendingUploads ?? 0,
|
|
318
|
+
pendingChanges: native?.pendingChanges ?? 0,
|
|
319
|
+
lastSyncedAt: native?.lastSyncedAt != null ? new Date(native.lastSyncedAt) : null,
|
|
320
|
+
error: native?.error ?? null,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { CloudSchema } from './schema';
|
|
4
|
+
import type { CloudSync, CloudSyncStatus } from './sync';
|
|
5
|
+
|
|
6
|
+
/** The sync status. Re-renders when it changes. */
|
|
7
|
+
export function useCloudSync<S extends CloudSchema>(sync: CloudSync<S>): CloudSyncStatus {
|
|
8
|
+
return useSyncExternalStore(sync.subscribe, sync.getStatus, sync.getStatus);
|
|
9
|
+
}
|