@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
@@ -0,0 +1,351 @@
1
+ import Foundation
2
+ import SQLite3
3
+
4
+ /// The module's own SQLite file. Survives app kills, so no change is lost between
5
+ /// the app, the sync engine and iCloud. Safe to use from any thread.
6
+ final class LocalStore {
7
+ enum Operation: String {
8
+ case save, delete
9
+ }
10
+
11
+ /// A change waiting to be uploaded. Only the latest change per record is kept.
12
+ struct OutboxEntry: Equatable {
13
+ let recordName: String
14
+ let type: String
15
+ let id: String
16
+ let operation: Operation
17
+ /// JSON-safe fields (empty for deletes).
18
+ let fields: [String: AnyHashable]
19
+ let modifiedAt: Date
20
+ /// Grows with every change, so an upload can tell if the record changed meanwhile.
21
+ let sequence: Int64
22
+ }
23
+
24
+ /// A change from iCloud waiting for the app to apply it.
25
+ struct InboxEntry: Equatable {
26
+ let sequence: Int64
27
+ let type: String
28
+ let id: String
29
+ let operation: Operation
30
+ let fields: [String: AnyHashable]
31
+ }
32
+
33
+ private var db: OpaquePointer?
34
+ private let lock = NSRecursiveLock()
35
+ private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
36
+
37
+ init(url: URL) throws {
38
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
39
+ guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
40
+ let message = db.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown error"
41
+ sqlite3_close(db)
42
+ throw CloudKitSyncError.storage("Couldn't open the sync database: \(message)")
43
+ }
44
+ try execute("PRAGMA journal_mode = WAL")
45
+ try migrate()
46
+ }
47
+
48
+ deinit {
49
+ sqlite3_close(db)
50
+ }
51
+
52
+ private func migrate() throws {
53
+ let version = try queryInt("PRAGMA user_version") ?? 0
54
+ if version < 1 {
55
+ try transaction {
56
+ try execute("""
57
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value BLOB);
58
+ CREATE TABLE outbox (
59
+ record_name TEXT PRIMARY KEY, type TEXT NOT NULL, id TEXT NOT NULL, operation TEXT NOT NULL,
60
+ fields TEXT NOT NULL, modified_at REAL NOT NULL, sequence INTEGER NOT NULL
61
+ );
62
+ CREATE TABLE system_fields (record_name TEXT PRIMARY KEY, data BLOB NOT NULL);
63
+ CREATE TABLE inbox (
64
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT, record_name TEXT NOT NULL, type TEXT NOT NULL,
65
+ id TEXT NOT NULL, operation TEXT NOT NULL, fields TEXT NOT NULL
66
+ );
67
+ CREATE INDEX inbox_record_name ON inbox (record_name);
68
+ PRAGMA user_version = 1;
69
+ """)
70
+ }
71
+ }
72
+ }
73
+
74
+ // MARK: Meta
75
+
76
+ func meta(_ key: String) throws -> Data? {
77
+ try locked {
78
+ let statement = try prepare("SELECT value FROM meta WHERE key = ?")
79
+ defer { sqlite3_finalize(statement) }
80
+ bind(statement, 1, key)
81
+ return sqlite3_step(statement) == SQLITE_ROW ? blob(statement, 0) : nil
82
+ }
83
+ }
84
+
85
+ func setMeta(_ key: String, _ value: Data?) throws {
86
+ try locked {
87
+ if let value {
88
+ try run("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") {
89
+ bind($0, 1, key); bind($0, 2, value)
90
+ }
91
+ } else {
92
+ try run("DELETE FROM meta WHERE key = ?") { bind($0, 1, key) }
93
+ }
94
+ }
95
+ }
96
+
97
+ func flag(_ key: String) throws -> Bool {
98
+ try meta(key) == Data([1])
99
+ }
100
+
101
+ func setFlag(_ key: String, _ value: Bool) throws {
102
+ try setMeta(key, value ? Data([1]) : nil)
103
+ }
104
+
105
+ // MARK: Outbox
106
+
107
+ /// Records a change to upload. Replaces any earlier change to the same record.
108
+ @discardableResult
109
+ func enqueue(type: String, id: String, operation: Operation, fields: [String: Any], modifiedAt: Date) throws -> Int64 {
110
+ let json = try encode(fields)
111
+ return try transaction {
112
+ let next = try nextSequence()
113
+ try run("""
114
+ INSERT INTO outbox (record_name, type, id, operation, fields, modified_at, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)
115
+ ON CONFLICT(record_name) DO UPDATE SET type = excluded.type, id = excluded.id, operation = excluded.operation,
116
+ fields = excluded.fields, modified_at = excluded.modified_at, sequence = excluded.sequence
117
+ """) {
118
+ bind($0, 1, RecordName.make(type: type, id: id)); bind($0, 2, type); bind($0, 3, id)
119
+ bind($0, 4, operation.rawValue); bind($0, 5, json); bind($0, 6, modifiedAt.timeIntervalSince1970); bind($0, 7, next)
120
+ }
121
+ return next
122
+ }
123
+ }
124
+
125
+ func outboxEntry(_ recordName: String) throws -> OutboxEntry? {
126
+ try locked {
127
+ let statement = try prepare("SELECT record_name, type, id, operation, fields, modified_at, sequence FROM outbox WHERE record_name = ?")
128
+ defer { sqlite3_finalize(statement) }
129
+ bind(statement, 1, recordName)
130
+ return sqlite3_step(statement) == SQLITE_ROW ? try readOutbox(statement) : nil
131
+ }
132
+ }
133
+
134
+ func outboxEntries() throws -> [OutboxEntry] {
135
+ try locked {
136
+ let statement = try prepare("SELECT record_name, type, id, operation, fields, modified_at, sequence FROM outbox ORDER BY sequence")
137
+ defer { sqlite3_finalize(statement) }
138
+ var entries: [OutboxEntry] = []
139
+ while sqlite3_step(statement) == SQLITE_ROW { entries.append(try readOutbox(statement)) }
140
+ return entries
141
+ }
142
+ }
143
+
144
+ /// Removes an uploaded change, but only if it wasn't changed again while uploading.
145
+ /// Returns false when a newer change is still waiting.
146
+ @discardableResult
147
+ func removeOutbox(_ recordName: String, sequence: Int64) throws -> Bool {
148
+ try locked {
149
+ try run("DELETE FROM outbox WHERE record_name = ? AND sequence = ?") { bind($0, 1, recordName); bind($0, 2, sequence) }
150
+ return sqlite3_changes(db) > 0
151
+ }
152
+ }
153
+
154
+ /// Drops a waiting change no matter what, e.g. when the server's version won a conflict.
155
+ func discardOutbox(_ recordName: String) throws {
156
+ try locked { try run("DELETE FROM outbox WHERE record_name = ?") { bind($0, 1, recordName) } }
157
+ }
158
+
159
+ // MARK: System fields
160
+
161
+ func systemFields(_ recordName: String) throws -> Data? {
162
+ try locked {
163
+ let statement = try prepare("SELECT data FROM system_fields WHERE record_name = ?")
164
+ defer { sqlite3_finalize(statement) }
165
+ bind(statement, 1, recordName)
166
+ return sqlite3_step(statement) == SQLITE_ROW ? blob(statement, 0) : nil
167
+ }
168
+ }
169
+
170
+ func setSystemFields(_ recordName: String, _ data: Data?) throws {
171
+ try locked {
172
+ if let data {
173
+ try run("INSERT INTO system_fields (record_name, data) VALUES (?, ?) ON CONFLICT(record_name) DO UPDATE SET data = excluded.data") {
174
+ bind($0, 1, recordName); bind($0, 2, data)
175
+ }
176
+ } else {
177
+ try run("DELETE FROM system_fields WHERE record_name = ?") { bind($0, 1, recordName) }
178
+ }
179
+ }
180
+ }
181
+
182
+ // MARK: Inbox
183
+
184
+ /// Adds a change from iCloud. An older undelivered change to the same record is replaced.
185
+ func addInbox(type: String, id: String, operation: Operation, fields: [String: Any]) throws {
186
+ let json = try encode(fields)
187
+ try locked {
188
+ let recordName = RecordName.make(type: type, id: id)
189
+ try run("DELETE FROM inbox WHERE record_name = ?") { bind($0, 1, recordName) }
190
+ try run("INSERT INTO inbox (record_name, type, id, operation, fields) VALUES (?, ?, ?, ?, ?)") {
191
+ bind($0, 1, recordName); bind($0, 2, type); bind($0, 3, id); bind($0, 4, operation.rawValue); bind($0, 5, json)
192
+ }
193
+ }
194
+ }
195
+
196
+ func inbox(limit: Int = 500) throws -> [InboxEntry] {
197
+ try locked {
198
+ let statement = try prepare("SELECT sequence, type, id, operation, fields FROM inbox ORDER BY sequence LIMIT ?")
199
+ defer { sqlite3_finalize(statement) }
200
+ bind(statement, 1, Int64(limit))
201
+ var entries: [InboxEntry] = []
202
+ while sqlite3_step(statement) == SQLITE_ROW {
203
+ entries.append(InboxEntry(
204
+ sequence: sqlite3_column_int64(statement, 0),
205
+ type: text(statement, 1),
206
+ id: text(statement, 2),
207
+ operation: Operation(rawValue: text(statement, 3)) ?? .save,
208
+ fields: try decode(text(statement, 4))
209
+ ))
210
+ }
211
+ return entries
212
+ }
213
+ }
214
+
215
+ /// The app applied these changes, so they can go.
216
+ func ackInbox(_ sequences: [Int64]) throws {
217
+ guard !sequences.isEmpty else { return }
218
+ try transaction {
219
+ for sequence in sequences {
220
+ try run("DELETE FROM inbox WHERE sequence = ?") { bind($0, 1, sequence) }
221
+ }
222
+ }
223
+ }
224
+
225
+ // MARK: Counts and reset
226
+
227
+ func counts() throws -> (outbox: Int, inbox: Int) {
228
+ try locked {
229
+ (Int(try queryInt("SELECT COUNT(*) FROM outbox") ?? 0), Int(try queryInt("SELECT COUNT(*) FROM inbox") ?? 0))
230
+ }
231
+ }
232
+
233
+ /// Forgets everything about iCloud (sync position, system fields, undelivered changes).
234
+ /// With `keepOutbox`, local changes stay queued and upload to the next account.
235
+ func reset(keepOutbox: Bool) throws {
236
+ try transaction {
237
+ try execute("DELETE FROM meta; DELETE FROM system_fields; DELETE FROM inbox;")
238
+ if !keepOutbox { try execute("DELETE FROM outbox") }
239
+ }
240
+ }
241
+
242
+ // MARK: SQLite helpers
243
+
244
+ private func nextSequence() throws -> Int64 {
245
+ let key = "outboxSequence"
246
+ let statement = try prepare("SELECT value FROM meta WHERE key = ?")
247
+ defer { sqlite3_finalize(statement) }
248
+ bind(statement, 1, key)
249
+ let stored = sqlite3_step(statement) == SQLITE_ROW ? sqlite3_column_int64(statement, 0) : 0
250
+ // After a reset the counter is gone, but queued changes keep their numbers: never reuse one.
251
+ let next = max(stored, try queryInt("SELECT COALESCE(MAX(sequence), 0) FROM outbox") ?? 0) + 1
252
+ try run("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") {
253
+ bind($0, 1, key); bind($0, 2, next)
254
+ }
255
+ return next
256
+ }
257
+
258
+ func transaction<T>(_ body: () throws -> T) throws -> T {
259
+ try locked {
260
+ try execute("BEGIN IMMEDIATE")
261
+ do {
262
+ let result = try body()
263
+ try execute("COMMIT")
264
+ return result
265
+ } catch {
266
+ try? execute("ROLLBACK")
267
+ throw error
268
+ }
269
+ }
270
+ }
271
+
272
+ private func locked<T>(_ body: () throws -> T) throws -> T {
273
+ lock.lock()
274
+ defer { lock.unlock() }
275
+ return try body()
276
+ }
277
+
278
+ private func execute(_ sql: String) throws {
279
+ guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else { throw lastError() }
280
+ }
281
+
282
+ private func prepare(_ sql: String) throws -> OpaquePointer? {
283
+ var statement: OpaquePointer?
284
+ guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { throw lastError() }
285
+ return statement
286
+ }
287
+
288
+ private func run(_ sql: String, bindings: (OpaquePointer?) -> Void) throws {
289
+ let statement = try prepare(sql)
290
+ defer { sqlite3_finalize(statement) }
291
+ bindings(statement)
292
+ guard sqlite3_step(statement) == SQLITE_DONE else { throw lastError() }
293
+ }
294
+
295
+ private func queryInt(_ sql: String) throws -> Int64? {
296
+ let statement = try prepare(sql)
297
+ defer { sqlite3_finalize(statement) }
298
+ return sqlite3_step(statement) == SQLITE_ROW ? sqlite3_column_int64(statement, 0) : nil
299
+ }
300
+
301
+ private func lastError() -> CloudKitSyncError {
302
+ .storage("Sync database error: \(String(cString: sqlite3_errmsg(db)))")
303
+ }
304
+
305
+ private func bind(_ statement: OpaquePointer?, _ index: Int32, _ value: String) {
306
+ sqlite3_bind_text(statement, index, value, -1, Self.transient)
307
+ }
308
+
309
+ private func bind(_ statement: OpaquePointer?, _ index: Int32, _ value: Int64) {
310
+ sqlite3_bind_int64(statement, index, value)
311
+ }
312
+
313
+ private func bind(_ statement: OpaquePointer?, _ index: Int32, _ value: Double) {
314
+ sqlite3_bind_double(statement, index, value)
315
+ }
316
+
317
+ private func bind(_ statement: OpaquePointer?, _ index: Int32, _ value: Data) {
318
+ _ = value.withUnsafeBytes { sqlite3_bind_blob(statement, index, $0.baseAddress, Int32(value.count), Self.transient) }
319
+ }
320
+
321
+ private func text(_ statement: OpaquePointer?, _ column: Int32) -> String {
322
+ sqlite3_column_text(statement, column).map { String(cString: $0) } ?? ""
323
+ }
324
+
325
+ private func blob(_ statement: OpaquePointer?, _ column: Int32) -> Data? {
326
+ guard let bytes = sqlite3_column_blob(statement, column) else { return Data() }
327
+ return Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, column)))
328
+ }
329
+
330
+ private func readOutbox(_ statement: OpaquePointer?) throws -> OutboxEntry {
331
+ OutboxEntry(
332
+ recordName: text(statement, 0),
333
+ type: text(statement, 1),
334
+ id: text(statement, 2),
335
+ operation: Operation(rawValue: text(statement, 3)) ?? .save,
336
+ fields: try decode(text(statement, 4)),
337
+ modifiedAt: Date(timeIntervalSince1970: sqlite3_column_double(statement, 5)),
338
+ sequence: sqlite3_column_int64(statement, 6)
339
+ )
340
+ }
341
+
342
+ private func encode(_ fields: [String: Any]) throws -> String {
343
+ guard JSONSerialization.isValidJSONObject(fields) else { throw CloudKitSyncError.storage("Fields aren't JSON-safe.") }
344
+ return String(decoding: try JSONSerialization.data(withJSONObject: fields, options: [.sortedKeys]), as: UTF8.self)
345
+ }
346
+
347
+ private func decode(_ json: String) throws -> [String: AnyHashable] {
348
+ let object = try JSONSerialization.jsonObject(with: Data(json.utf8))
349
+ return (object as? [String: AnyHashable]) ?? [:]
350
+ }
351
+ }
@@ -0,0 +1,14 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ /// Registers for silent pushes at launch. CKSyncEngine creates the CloudKit subscription
5
+ /// and fetches changes when a push arrives, so a change on one device shows up on the others.
6
+ public class ExpoKitCloudKitAppDelegateSubscriber: ExpoAppDelegateSubscriber {
7
+ public func application(
8
+ _ application: UIApplication,
9
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
10
+ ) -> Bool {
11
+ application.registerForRemoteNotifications()
12
+ return false
13
+ }
14
+ }
@@ -0,0 +1,237 @@
1
+ import CloudKit
2
+ import Foundation
3
+
4
+ /// Checks values from JS against the schema and converts them to and from CKRecords.
5
+ ///
6
+ /// Values crossing to and from JS are JSON-safe: dates are milliseconds since 1970,
7
+ /// references are the linked record's id, assets are local file paths, empty optionals are NSNull.
8
+ struct RecordMapper {
9
+ /// Set on every record, so the "newest" conflict rule can compare edits.
10
+ static let modifiedAtKey = "ekModifiedAt"
11
+
12
+ /// CloudKit keeps asset files without a name, so the extension is stored next to it.
13
+ static func assetExtensionKey(_ field: String) -> String { "ek_\(field)_ext" }
14
+
15
+ let schema: SchemaModel
16
+ let zoneID: CKRecordZone.ID
17
+
18
+ // MARK: JS → stored
19
+
20
+ /// Checks a record from JS and returns its fields in a JSON-safe form for the outbox.
21
+ func normalize(type typeName: String, id: String, fields: [String: Any]) throws -> [String: Any] {
22
+ let type = try schema.type(typeName)
23
+ try RecordName.validate(type: typeName, id: id)
24
+
25
+ if let unknown = fields.keys.first(where: { type.fields[$0] == nil }) {
26
+ throw CloudKitSyncError.invalidValue("\(typeName).\(unknown) isn't a field in the schema.")
27
+ }
28
+
29
+ var result: [String: Any] = [:]
30
+ for field in type.fields.values {
31
+ let path = "\(typeName).\(field.name)"
32
+ let raw = fields[field.name]
33
+ if raw == nil || raw is NSNull {
34
+ if !field.optional { throw CloudKitSyncError.invalidValue("\(path) is required.") }
35
+ result[field.name] = NSNull()
36
+ continue
37
+ }
38
+ result[field.name] = try normalizeValue(raw!, kind: field.kind, path: path)
39
+ }
40
+ return result
41
+ }
42
+
43
+ private func normalizeValue(_ value: Any, kind: FieldKind, path: String) throws -> Any {
44
+ switch kind {
45
+ case .string, .reference, .asset:
46
+ guard let string = value as? String else { throw typeError(path, "a string") }
47
+ if kind == .reference { try RecordName.validate(type: "reference", id: string) }
48
+ if kind == .asset && string.isEmpty { throw typeError(path, "a file path") }
49
+ return string
50
+ case .int:
51
+ guard let number = plainNumber(value), number.doubleValue.rounded() == number.doubleValue else {
52
+ throw typeError(path, "a whole number")
53
+ }
54
+ return number.int64Value
55
+ case .double, .date:
56
+ guard let number = plainNumber(value), number.doubleValue.isFinite else {
57
+ throw typeError(path, kind == .date ? "a date" : "a number")
58
+ }
59
+ return number.doubleValue
60
+ case .bool:
61
+ guard let bool = boolValue(value) else { throw typeError(path, "true or false") }
62
+ return bool
63
+ case .stringList:
64
+ guard let list = value as? [Any], list.allSatisfy({ $0 is String }) else { throw typeError(path, "a list of strings") }
65
+ return list
66
+ case .intList:
67
+ guard let list = value as? [Any] else { throw typeError(path, "a list of whole numbers") }
68
+ return try list.map { item -> Int64 in
69
+ guard let number = plainNumber(item), number.doubleValue.rounded() == number.doubleValue else {
70
+ throw typeError(path, "a list of whole numbers")
71
+ }
72
+ return number.int64Value
73
+ }
74
+ case .doubleList:
75
+ guard let list = value as? [Any] else { throw typeError(path, "a list of numbers") }
76
+ return try list.map { item -> Double in
77
+ guard let number = plainNumber(item), number.doubleValue.isFinite else { throw typeError(path, "a list of numbers") }
78
+ return number.doubleValue
79
+ }
80
+ }
81
+ }
82
+
83
+ // MARK: stored → CKRecord
84
+
85
+ /// Writes normalized fields onto a record. Fields not in the schema are left untouched,
86
+ /// so data written by a newer app version on another device is kept.
87
+ func apply(fields: [String: Any], modifiedAt: Date, to record: CKRecord) throws {
88
+ let type = try schema.type(record.recordType)
89
+ for field in type.fields.values {
90
+ let value = fields[field.name]
91
+ if value == nil || value is NSNull {
92
+ record[field.name] = nil
93
+ if field.kind == .asset { record[Self.assetExtensionKey(field.name)] = nil }
94
+ continue
95
+ }
96
+ let ckValue = try ckValue(value!, field: field, recordType: type.name)
97
+ record[field.name] = ckValue
98
+ if field.kind == .asset, let asset = ckValue as? CKAsset {
99
+ let ext = asset.fileURL?.pathExtension ?? ""
100
+ record[Self.assetExtensionKey(field.name)] = ext.isEmpty ? nil : ext as NSString
101
+ }
102
+ }
103
+ record[Self.modifiedAtKey] = modifiedAt as NSDate
104
+ }
105
+
106
+ private func ckValue(_ value: Any, field: FieldModel, recordType: String) throws -> CKRecordValue {
107
+ let path = "\(recordType).\(field.name)"
108
+ switch field.kind {
109
+ case .string:
110
+ return try cast(value, path) as String as NSString
111
+ case .int:
112
+ return NSNumber(value: try int64(value, path))
113
+ case .double:
114
+ return NSNumber(value: try double(value, path))
115
+ case .bool:
116
+ guard let bool = boolValue(value) else { throw typeError(path, "true or false") }
117
+ return NSNumber(value: bool)
118
+ case .date:
119
+ return Date(timeIntervalSince1970: try double(value, path) / 1000) as NSDate
120
+ case .stringList:
121
+ return try cast(value, path) as [String] as NSArray
122
+ case .intList:
123
+ let list: [Any] = try cast(value, path)
124
+ return try list.map { NSNumber(value: try int64($0, path)) } as NSArray
125
+ case .doubleList:
126
+ let list: [Any] = try cast(value, path)
127
+ return try list.map { NSNumber(value: try double($0, path)) } as NSArray
128
+ case .reference:
129
+ let id: String = try cast(value, path)
130
+ let recordID = CKRecord.ID(recordName: RecordName.make(type: field.target ?? "", id: id), zoneID: zoneID)
131
+ return CKRecord.Reference(recordID: recordID, action: .none)
132
+ case .asset:
133
+ let filePath: String = try cast(value, path)
134
+ let url = filePath.hasPrefix("file://") ? URL(string: filePath) : URL(fileURLWithPath: filePath)
135
+ guard let url, FileManager.default.fileExists(atPath: url.path) else {
136
+ throw CloudKitSyncError.invalidValue("\(path): no file at \(filePath).")
137
+ }
138
+ return CKAsset(fileURL: url)
139
+ }
140
+ }
141
+
142
+ // MARK: CKRecord → JS
143
+
144
+ struct ReadRecord {
145
+ let type: String
146
+ let id: String
147
+ let fields: [String: Any]
148
+ let modifiedAt: Date?
149
+ }
150
+
151
+ /// Reads a record from iCloud into JSON-safe fields. Returns nil for records that
152
+ /// aren't in this schema (e.g. a type added by a newer app version).
153
+ /// `saveAsset` copies a downloaded file somewhere permanent and returns its path.
154
+ func read(_ record: CKRecord, saveAsset: (AssetTarget) throws -> String?) throws -> ReadRecord? {
155
+ guard let name = RecordName.parse(record.recordID.recordName), let type = schema.types[record.recordType] else {
156
+ return nil
157
+ }
158
+ var fields: [String: Any] = [:]
159
+ for field in type.fields.values {
160
+ fields[field.name] = try jsValue(record, field: field, type: type.name, id: name.id, save: saveAsset) ?? NSNull()
161
+ }
162
+ return ReadRecord(type: type.name, id: name.id, fields: fields, modifiedAt: record[Self.modifiedAtKey] as? Date)
163
+ }
164
+
165
+ /// Where a downloaded file should go.
166
+ struct AssetTarget {
167
+ let asset: CKAsset
168
+ let type: String
169
+ let id: String
170
+ let field: String
171
+ /// Without the dot, empty when the original file had none.
172
+ let fileExtension: String
173
+ }
174
+
175
+ private func jsValue(
176
+ _ record: CKRecord,
177
+ field: FieldModel,
178
+ type: String,
179
+ id: String,
180
+ save: (AssetTarget) throws -> String?
181
+ ) throws -> Any? {
182
+ guard let value = record[field.name] else { return nil }
183
+ switch field.kind {
184
+ case .string: return value as? String
185
+ case .int: return (value as? NSNumber)?.int64Value
186
+ case .double: return (value as? NSNumber)?.doubleValue
187
+ case .bool: return (value as? NSNumber)?.boolValue
188
+ case .date: return (value as? Date).map { $0.timeIntervalSince1970 * 1000 }
189
+ case .stringList: return value as? [String]
190
+ case .intList: return (value as? [NSNumber])?.map(\.int64Value)
191
+ case .doubleList: return (value as? [NSNumber])?.map(\.doubleValue)
192
+ case .reference:
193
+ guard let reference = value as? CKRecord.Reference else { return nil }
194
+ return RecordName.parse(reference.recordID.recordName)?.id
195
+ case .asset:
196
+ guard let asset = value as? CKAsset else { return nil }
197
+ let ext = record[Self.assetExtensionKey(field.name)] as? String ?? ""
198
+ return try save(AssetTarget(asset: asset, type: type, id: id, field: field.name, fileExtension: ext))
199
+ }
200
+ }
201
+
202
+ // MARK: Helpers
203
+
204
+ private func typeError(_ path: String, _ expected: String) -> CloudKitSyncError {
205
+ .invalidValue("\(path) must be \(expected).")
206
+ }
207
+
208
+ private func cast<T>(_ value: Any, _ path: String) throws -> T {
209
+ guard let typed = value as? T else { throw CloudKitSyncError.invalidValue("\(path) has the wrong type.") }
210
+ return typed
211
+ }
212
+
213
+ private func int64(_ value: Any, _ path: String) throws -> Int64 {
214
+ guard let number = plainNumber(value) else { throw typeError(path, "a whole number") }
215
+ return number.int64Value
216
+ }
217
+
218
+ private func double(_ value: Any, _ path: String) throws -> Double {
219
+ guard let number = plainNumber(value) else { throw typeError(path, "a number") }
220
+ return number.doubleValue
221
+ }
222
+
223
+ /// A number that isn't a boolean (NSNumber bridges both).
224
+ private func plainNumber(_ value: Any) -> NSNumber? {
225
+ guard let number = value as? NSNumber, !isBoolean(number) else { return nil }
226
+ return number
227
+ }
228
+
229
+ private func boolValue(_ value: Any) -> Bool? {
230
+ if let bool = value as? Bool, let number = value as? NSNumber, isBoolean(number) { return bool }
231
+ return nil
232
+ }
233
+
234
+ private func isBoolean(_ number: NSNumber) -> Bool {
235
+ CFGetTypeID(number) == CFBooleanGetTypeID()
236
+ }
237
+ }
@@ -0,0 +1,60 @@
1
+ import ExpoModulesCore
2
+
3
+ // Typed shapes that cross between JS and Swift. Expo checks them when converting.
4
+
5
+ struct FieldSpecRecord: Record {
6
+ @Field var name: String = ""
7
+ @Field var kind: String = ""
8
+ @Field var optional: Bool = false
9
+ @Field var target: String?
10
+ }
11
+
12
+ struct TypeSpecRecord: Record {
13
+ @Field var name: String = ""
14
+ @Field var fields: [FieldSpecRecord] = []
15
+ }
16
+
17
+ struct SchemaSpecRecord: Record {
18
+ @Field var types: [TypeSpecRecord] = []
19
+
20
+ func toModel() throws -> SchemaModel {
21
+ var types: [String: TypeModel] = [:]
22
+ for type in self.types {
23
+ var fields: [String: FieldModel] = [:]
24
+ for field in type.fields {
25
+ guard let kind = FieldKind(rawValue: field.kind) else {
26
+ throw CloudKitSyncError.invalidSchema("\(type.name).\(field.name): unknown field kind \"\(field.kind)\".")
27
+ }
28
+ fields[field.name] = FieldModel(name: field.name, kind: kind, optional: field.optional, target: field.target)
29
+ }
30
+ types[type.name] = TypeModel(name: type.name, fields: fields)
31
+ }
32
+ for type in types.values {
33
+ for field in type.fields.values where field.kind == .reference {
34
+ guard let target = field.target, types[target] != nil else {
35
+ throw CloudKitSyncError.invalidSchema("\(type.name).\(field.name) links to an unknown type.")
36
+ }
37
+ }
38
+ }
39
+ return SchemaModel(types: types)
40
+ }
41
+ }
42
+
43
+ struct EngineOptionsRecord: Record {
44
+ @Field var containerId: String = ""
45
+ @Field var zoneName: String = "AppData"
46
+ /// "newest", "server" or "device".
47
+ @Field var conflict: String = "newest"
48
+ @Field var schema: SchemaSpecRecord = SchemaSpecRecord()
49
+ }
50
+
51
+ struct SaveInputRecord: Record {
52
+ @Field var type: String = ""
53
+ @Field var id: String = ""
54
+ @Field var fields: [String: Any] = [:]
55
+ }
56
+
57
+ struct DeleteInputRecord: Record {
58
+ @Field var type: String = ""
59
+ @Field var id: String = ""
60
+ }