@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
|
@@ -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
|
+
}
|