@trainstar/synchro-react-native 0.1.0
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/LICENSE +20 -0
- package/README.md +122 -0
- package/SynchroReactNative.podspec +22 -0
- package/android/build.gradle +81 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/kotlin/com/trainstar/synchro/rn/SynchroModule.kt +808 -0
- package/android/src/main/kotlin/com/trainstar/synchro/rn/SynchroPackage.kt +32 -0
- package/ios/SynchroModule.mm +368 -0
- package/ios/SynchroModule.swift +970 -0
- package/lib/module/NativeSynchro.js +5 -0
- package/lib/module/NativeSynchro.js.map +1 -0
- package/lib/module/SynchroClient.js +295 -0
- package/lib/module/SynchroClient.js.map +1 -0
- package/lib/module/errors.js +150 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/hooks/index.js +6 -0
- package/lib/module/hooks/index.js.map +1 -0
- package/lib/module/hooks/usePendingChanges.js +25 -0
- package/lib/module/hooks/usePendingChanges.js.map +1 -0
- package/lib/module/hooks/useQuery.js +74 -0
- package/lib/module/hooks/useQuery.js.map +1 -0
- package/lib/module/hooks/useSyncStatus.js +15 -0
- package/lib/module/hooks/useSyncStatus.js.map +1 -0
- package/lib/module/index.js +8 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeSynchro.d.ts +84 -0
- package/lib/typescript/src/NativeSynchro.d.ts.map +1 -0
- package/lib/typescript/src/SynchroClient.d.ts +30 -0
- package/lib/typescript/src/SynchroClient.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +61 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/hooks/index.d.ts +4 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/usePendingChanges.d.ts +3 -0
- package/lib/typescript/src/hooks/usePendingChanges.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useQuery.d.ts +12 -0
- package/lib/typescript/src/hooks/useQuery.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useSyncStatus.d.ts +4 -0
- package/lib/typescript/src/hooks/useSyncStatus.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +8 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +55 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +146 -0
- package/src/NativeSynchro.ts +114 -0
- package/src/SynchroClient.ts +424 -0
- package/src/errors.ts +205 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/usePendingChanges.ts +32 -0
- package/src/hooks/useQuery.ts +93 -0
- package/src/hooks/useSyncStatus.ts +17 -0
- package/src/index.ts +41 -0
- package/src/types.ts +71 -0
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import React
|
|
3
|
+
import GRDB
|
|
4
|
+
import Synchro
|
|
5
|
+
|
|
6
|
+
// MARK: - Event Delegate Protocol
|
|
7
|
+
|
|
8
|
+
@objc public protocol SynchroEventEmitting: AnyObject {
|
|
9
|
+
func emitEvent(_ name: String, body: NSDictionary)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// MARK: - Transaction Session
|
|
13
|
+
|
|
14
|
+
private class TransactionSession {
|
|
15
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
16
|
+
var pendingOp: TransactionOp?
|
|
17
|
+
var committed = false
|
|
18
|
+
var rolledBack = false
|
|
19
|
+
let isWrite: Bool
|
|
20
|
+
|
|
21
|
+
init(isWrite: Bool) {
|
|
22
|
+
self.isWrite = isWrite
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private enum TransactionOp {
|
|
27
|
+
case query(sql: String, params: [Any], completion: (Result<String, Error>) -> Void)
|
|
28
|
+
case queryOne(sql: String, params: [Any], completion: (Result<String?, Error>) -> Void)
|
|
29
|
+
case execute(sql: String, params: [Any], completion: (Result<[String: Any], Error>) -> Void)
|
|
30
|
+
case commit(completion: (Result<Void, Error>) -> Void)
|
|
31
|
+
case rollback(completion: (Result<Void, Error>) -> Void)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private struct TransactionTimeoutError: Error {}
|
|
35
|
+
private struct TransactionRollbackError: Error {}
|
|
36
|
+
|
|
37
|
+
// MARK: - Implementation
|
|
38
|
+
|
|
39
|
+
@objc(SynchroModuleImpl)
|
|
40
|
+
public class SynchroModuleImpl: NSObject {
|
|
41
|
+
@objc public weak var eventDelegate: SynchroEventEmitting?
|
|
42
|
+
|
|
43
|
+
private var client: SynchroClient?
|
|
44
|
+
private var sessions: [String: TransactionSession] = [:]
|
|
45
|
+
private let sessionsLock = NSLock()
|
|
46
|
+
private var observers: [String: any Synchro.Cancellable] = [:]
|
|
47
|
+
private var statusSubscription: (any Synchro.Cancellable)?
|
|
48
|
+
private var conflictSubscription: (any Synchro.Cancellable)?
|
|
49
|
+
private var snapshotSubscription: (any Synchro.Cancellable)?
|
|
50
|
+
|
|
51
|
+
private var pendingAuthContinuations: [String: CheckedContinuation<String, Error>] = [:]
|
|
52
|
+
private let authLock = NSLock()
|
|
53
|
+
|
|
54
|
+
private var pendingSnapshotContinuations: [String: CheckedContinuation<Bool, Never>] = [:]
|
|
55
|
+
private let snapshotLock = NSLock()
|
|
56
|
+
|
|
57
|
+
private func emit(_ name: String, _ body: [String: Any]) {
|
|
58
|
+
DispatchQueue.main.async { [weak self] in
|
|
59
|
+
self?.eventDelegate?.emitEvent(name, body: body as NSDictionary)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@objc public func rejectWithError(_ reject: @escaping RCTPromiseRejectBlock, _ error: Error) {
|
|
64
|
+
if let synchroError = error as? SynchroError {
|
|
65
|
+
let (code, userInfo) = mapSynchroError(synchroError)
|
|
66
|
+
let base: [String: Any] = ["message": synchroError.localizedDescription]
|
|
67
|
+
reject(code, synchroError.localizedDescription, NSError(
|
|
68
|
+
domain: "SynchroModule",
|
|
69
|
+
code: 0,
|
|
70
|
+
userInfo: base.merging(userInfo) { _, new in new }
|
|
71
|
+
))
|
|
72
|
+
} else if error is TransactionTimeoutError {
|
|
73
|
+
reject("TRANSACTION_TIMEOUT", "Transaction timed out due to inactivity", nil)
|
|
74
|
+
} else {
|
|
75
|
+
reject("UNKNOWN", error.localizedDescription, error)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private func mapSynchroError(_ error: SynchroError) -> (String, [String: Any]) {
|
|
80
|
+
switch error {
|
|
81
|
+
case .notConnected:
|
|
82
|
+
return ("NOT_CONNECTED", [:])
|
|
83
|
+
case .schemaNotLoaded:
|
|
84
|
+
return ("SCHEMA_NOT_LOADED", [:])
|
|
85
|
+
case .tableNotSynced(let table):
|
|
86
|
+
return ("TABLE_NOT_SYNCED", ["table": table])
|
|
87
|
+
case .upgradeRequired(let current, let minimum):
|
|
88
|
+
return ("UPGRADE_REQUIRED", ["currentVersion": current, "minimumVersion": minimum])
|
|
89
|
+
case .schemaMismatch(let version, let hash):
|
|
90
|
+
return ("SCHEMA_MISMATCH", ["serverVersion": "\(version)", "serverHash": hash])
|
|
91
|
+
case .snapshotRequired:
|
|
92
|
+
return ("SNAPSHOT_REQUIRED", [:])
|
|
93
|
+
case .pushRejected(let results):
|
|
94
|
+
let data = try? JSONSerialization.data(withJSONObject: results.map { r in
|
|
95
|
+
["recordID": r.id, "table": r.tableName, "status": r.status] as [String: Any]
|
|
96
|
+
})
|
|
97
|
+
return ("PUSH_REJECTED", ["results": data.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"])
|
|
98
|
+
case .networkError(let underlying):
|
|
99
|
+
return ("NETWORK_ERROR", ["message": underlying.localizedDescription])
|
|
100
|
+
case .serverError(let status, let msg):
|
|
101
|
+
return ("SERVER_ERROR", ["status": "\(status)", "message": msg])
|
|
102
|
+
case .databaseError(let underlying):
|
|
103
|
+
return ("DATABASE_ERROR", ["message": underlying.localizedDescription])
|
|
104
|
+
case .invalidResponse(let msg):
|
|
105
|
+
return ("INVALID_RESPONSE", ["message": msg])
|
|
106
|
+
case .alreadyStarted:
|
|
107
|
+
return ("ALREADY_STARTED", [:])
|
|
108
|
+
case .notStarted:
|
|
109
|
+
return ("NOT_STARTED", [:])
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// MARK: - Lifecycle
|
|
114
|
+
|
|
115
|
+
@objc
|
|
116
|
+
public func initialize(
|
|
117
|
+
_ config: NSDictionary,
|
|
118
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
119
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
120
|
+
) {
|
|
121
|
+
guard let dbPath = config["dbPath"] as? String,
|
|
122
|
+
let serverURL = config["serverURL"] as? String,
|
|
123
|
+
let clientID = config["clientID"] as? String,
|
|
124
|
+
let platform = config["platform"] as? String,
|
|
125
|
+
let appVersion = config["appVersion"] as? String,
|
|
126
|
+
let url = URL(string: serverURL) else {
|
|
127
|
+
reject("INVALID_CONFIG", "Missing required config fields", nil)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
// Resolve relative paths to the app's Documents directory
|
|
131
|
+
let resolvedDbPath: String
|
|
132
|
+
if (dbPath as NSString).isAbsolutePath {
|
|
133
|
+
resolvedDbPath = dbPath
|
|
134
|
+
} else {
|
|
135
|
+
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
136
|
+
resolvedDbPath = documentsURL.appendingPathComponent(dbPath).path
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let syncInterval = config["syncInterval"] as? Double ?? 30
|
|
140
|
+
let pushDebounce = config["pushDebounce"] as? Double ?? 0.5
|
|
141
|
+
let maxRetryAttempts = config["maxRetryAttempts"] as? Int ?? 5
|
|
142
|
+
let pullPageSize = config["pullPageSize"] as? Int ?? 100
|
|
143
|
+
let pushBatchSize = config["pushBatchSize"] as? Int ?? 100
|
|
144
|
+
let snapshotPageSize = config["snapshotPageSize"] as? Int ?? 100
|
|
145
|
+
|
|
146
|
+
do {
|
|
147
|
+
let synchroConfig = SynchroConfig(
|
|
148
|
+
dbPath: resolvedDbPath,
|
|
149
|
+
serverURL: url,
|
|
150
|
+
authProvider: { [weak self] in
|
|
151
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
152
|
+
let requestID = UUID().uuidString
|
|
153
|
+
self?.authLock.lock()
|
|
154
|
+
self?.pendingAuthContinuations[requestID] = continuation
|
|
155
|
+
self?.authLock.unlock()
|
|
156
|
+
self?.emit("onAuthRequest", ["requestID": requestID])
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
clientID: clientID,
|
|
160
|
+
platform: platform,
|
|
161
|
+
appVersion: appVersion,
|
|
162
|
+
syncInterval: syncInterval,
|
|
163
|
+
pushDebounce: pushDebounce,
|
|
164
|
+
maxRetryAttempts: maxRetryAttempts,
|
|
165
|
+
pullPageSize: pullPageSize,
|
|
166
|
+
pushBatchSize: pushBatchSize,
|
|
167
|
+
snapshotPageSize: snapshotPageSize
|
|
168
|
+
)
|
|
169
|
+
try client?.close()
|
|
170
|
+
clearRuntimeState()
|
|
171
|
+
let client = try SynchroClient(config: synchroConfig)
|
|
172
|
+
self.client = client
|
|
173
|
+
wireClientEvents(client)
|
|
174
|
+
resolve(nil)
|
|
175
|
+
} catch {
|
|
176
|
+
rejectWithError(reject, error)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@objc
|
|
181
|
+
public func resolveAuthRequest(_ requestID: String, token: String) {
|
|
182
|
+
authLock.lock()
|
|
183
|
+
let continuation = pendingAuthContinuations.removeValue(forKey: requestID)
|
|
184
|
+
authLock.unlock()
|
|
185
|
+
continuation?.resume(returning: token)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@objc
|
|
189
|
+
public func rejectAuthRequest(_ requestID: String, error: String) {
|
|
190
|
+
authLock.lock()
|
|
191
|
+
let continuation = pendingAuthContinuations.removeValue(forKey: requestID)
|
|
192
|
+
authLock.unlock()
|
|
193
|
+
continuation?.resume(throwing: NSError(domain: "Auth", code: 0, userInfo: [NSLocalizedDescriptionKey: error]))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@objc
|
|
197
|
+
public func resolveSnapshotRequest(_ requestID: String, approved: Bool) {
|
|
198
|
+
snapshotLock.lock()
|
|
199
|
+
let continuation = pendingSnapshotContinuations.removeValue(forKey: requestID)
|
|
200
|
+
snapshotLock.unlock()
|
|
201
|
+
continuation?.resume(returning: approved)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@objc
|
|
205
|
+
public func close(
|
|
206
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
207
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
208
|
+
) {
|
|
209
|
+
do {
|
|
210
|
+
try client?.close()
|
|
211
|
+
clearRuntimeState()
|
|
212
|
+
client = nil
|
|
213
|
+
resolve(nil)
|
|
214
|
+
} catch {
|
|
215
|
+
rejectWithError(reject, error)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@objc
|
|
220
|
+
public func getPath(
|
|
221
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
222
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
223
|
+
) {
|
|
224
|
+
guard let client = client else {
|
|
225
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
resolve(client.path)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// MARK: - Core SQL
|
|
232
|
+
|
|
233
|
+
@objc
|
|
234
|
+
public func query(
|
|
235
|
+
_ sql: String,
|
|
236
|
+
paramsJson: String,
|
|
237
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
238
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
239
|
+
) {
|
|
240
|
+
guard let client = client else {
|
|
241
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
do {
|
|
245
|
+
let params = try parseParams(paramsJson)
|
|
246
|
+
let rows = try client.query(sql, params: params)
|
|
247
|
+
resolve(try rowsToJson(rows))
|
|
248
|
+
} catch {
|
|
249
|
+
rejectWithError(reject, error)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@objc
|
|
254
|
+
public func queryOne(
|
|
255
|
+
_ sql: String,
|
|
256
|
+
paramsJson: String,
|
|
257
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
258
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
259
|
+
) {
|
|
260
|
+
guard let client = client else {
|
|
261
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
do {
|
|
265
|
+
let params = try parseParams(paramsJson)
|
|
266
|
+
let row = try client.queryOne(sql, params: params)
|
|
267
|
+
if let row = row {
|
|
268
|
+
resolve(try rowToJson(row))
|
|
269
|
+
} else {
|
|
270
|
+
resolve(nil)
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
rejectWithError(reject, error)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
@objc
|
|
278
|
+
public func execute(
|
|
279
|
+
_ sql: String,
|
|
280
|
+
paramsJson: String,
|
|
281
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
282
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
283
|
+
) {
|
|
284
|
+
guard let client = client else {
|
|
285
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
do {
|
|
289
|
+
let params = try parseParams(paramsJson)
|
|
290
|
+
let result = try client.execute(sql, params: params)
|
|
291
|
+
resolve(["rowsAffected": result.rowsAffected])
|
|
292
|
+
} catch {
|
|
293
|
+
rejectWithError(reject, error)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
@objc
|
|
298
|
+
public func executeBatch(
|
|
299
|
+
_ statementsJson: String,
|
|
300
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
301
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
302
|
+
) {
|
|
303
|
+
guard let client = client else {
|
|
304
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
do {
|
|
308
|
+
let data = statementsJson.data(using: .utf8)!
|
|
309
|
+
let array = try JSONSerialization.jsonObject(with: data) as! [[String: Any]]
|
|
310
|
+
let statements: [Synchro.SQLStatement] = array.map { item in
|
|
311
|
+
let sql = item["sql"] as! String
|
|
312
|
+
let params = (item["params"] as? [Any])?.compactMap { jsonValueToParam($0) }
|
|
313
|
+
return Synchro.SQLStatement(sql: sql, params: params)
|
|
314
|
+
}
|
|
315
|
+
let total = try client.executeBatch(statements)
|
|
316
|
+
resolve(["totalRowsAffected": total])
|
|
317
|
+
} catch {
|
|
318
|
+
rejectWithError(reject, error)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// MARK: - Transactions
|
|
323
|
+
|
|
324
|
+
@objc
|
|
325
|
+
public func beginWriteTransaction(
|
|
326
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
327
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
328
|
+
) {
|
|
329
|
+
beginTransaction(isWrite: true, resolve: resolve, reject: reject)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
@objc
|
|
333
|
+
public func beginReadTransaction(
|
|
334
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
335
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
336
|
+
) {
|
|
337
|
+
beginTransaction(isWrite: false, resolve: resolve, reject: reject)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private func beginTransaction(
|
|
341
|
+
isWrite: Bool,
|
|
342
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
343
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
344
|
+
) {
|
|
345
|
+
guard let client = client else {
|
|
346
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let txID = UUID().uuidString
|
|
351
|
+
let session = TransactionSession(isWrite: isWrite)
|
|
352
|
+
|
|
353
|
+
sessionsLock.lock()
|
|
354
|
+
sessions[txID] = session
|
|
355
|
+
sessionsLock.unlock()
|
|
356
|
+
|
|
357
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
358
|
+
do {
|
|
359
|
+
let txBlock: (GRDB.Database) throws -> Void = { db in
|
|
360
|
+
resolve(txID)
|
|
361
|
+
|
|
362
|
+
while true {
|
|
363
|
+
let result = session.semaphore.wait(timeout: .now() + 5)
|
|
364
|
+
if result == .timedOut {
|
|
365
|
+
throw TransactionTimeoutError()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
guard let op = session.pendingOp else {
|
|
369
|
+
break
|
|
370
|
+
}
|
|
371
|
+
session.pendingOp = nil
|
|
372
|
+
|
|
373
|
+
switch op {
|
|
374
|
+
case .query(let sql, let params, let completion):
|
|
375
|
+
do {
|
|
376
|
+
let args = StatementArguments(params.compactMap { self?.jsonValueToParam($0) }) ?? StatementArguments()
|
|
377
|
+
let rows = try Row.fetchAll(db, sql: sql, arguments: args)
|
|
378
|
+
let json = try self?.rowsToJson(rows) ?? "[]"
|
|
379
|
+
completion(.success(json))
|
|
380
|
+
} catch {
|
|
381
|
+
completion(.failure(error))
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
case .queryOne(let sql, let params, let completion):
|
|
385
|
+
do {
|
|
386
|
+
let args = StatementArguments(params.compactMap { self?.jsonValueToParam($0) }) ?? StatementArguments()
|
|
387
|
+
let row = try Row.fetchOne(db, sql: sql, arguments: args)
|
|
388
|
+
let json = row != nil ? try self?.rowToJson(row!) : nil
|
|
389
|
+
completion(.success(json))
|
|
390
|
+
} catch {
|
|
391
|
+
completion(.failure(error))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
case .execute(let sql, let params, let completion):
|
|
395
|
+
do {
|
|
396
|
+
let args = StatementArguments(params.compactMap { self?.jsonValueToParam($0) }) ?? StatementArguments()
|
|
397
|
+
try db.execute(sql: sql, arguments: args)
|
|
398
|
+
let changes = db.changesCount
|
|
399
|
+
completion(.success(["rowsAffected": changes]))
|
|
400
|
+
} catch {
|
|
401
|
+
completion(.failure(error))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
case .commit(let completion):
|
|
405
|
+
session.committed = true
|
|
406
|
+
completion(.success(()))
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
case .rollback(let completion):
|
|
410
|
+
session.rolledBack = true
|
|
411
|
+
completion(.success(()))
|
|
412
|
+
throw TransactionRollbackError()
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if isWrite {
|
|
418
|
+
try client.writeTransaction { db in try txBlock(db) }
|
|
419
|
+
} else {
|
|
420
|
+
try client.readTransaction { db in try txBlock(db) }
|
|
421
|
+
}
|
|
422
|
+
} catch is TransactionTimeoutError {
|
|
423
|
+
// auto-rollback
|
|
424
|
+
} catch is TransactionRollbackError {
|
|
425
|
+
// intentional rollback
|
|
426
|
+
} catch {
|
|
427
|
+
// other failure
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
self?.sessionsLock.lock()
|
|
431
|
+
self?.sessions.removeValue(forKey: txID)
|
|
432
|
+
self?.sessionsLock.unlock()
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
@objc
|
|
437
|
+
public func txQuery(
|
|
438
|
+
_ txID: String,
|
|
439
|
+
sql: String,
|
|
440
|
+
paramsJson: String,
|
|
441
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
442
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
443
|
+
) {
|
|
444
|
+
guard let session = getSession(txID) else {
|
|
445
|
+
reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
do {
|
|
449
|
+
let params = try parseParamsRaw(paramsJson)
|
|
450
|
+
session.pendingOp = .query(sql: sql, params: params) { result in
|
|
451
|
+
switch result {
|
|
452
|
+
case .success(let json): resolve(json)
|
|
453
|
+
case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
session.semaphore.signal()
|
|
457
|
+
} catch {
|
|
458
|
+
rejectWithError(reject, error)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
@objc
|
|
463
|
+
public func txQueryOne(
|
|
464
|
+
_ txID: String,
|
|
465
|
+
sql: String,
|
|
466
|
+
paramsJson: String,
|
|
467
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
468
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
469
|
+
) {
|
|
470
|
+
guard let session = getSession(txID) else {
|
|
471
|
+
reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
do {
|
|
475
|
+
let params = try parseParamsRaw(paramsJson)
|
|
476
|
+
session.pendingOp = .queryOne(sql: sql, params: params) { result in
|
|
477
|
+
switch result {
|
|
478
|
+
case .success(let json): resolve(json)
|
|
479
|
+
case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
session.semaphore.signal()
|
|
483
|
+
} catch {
|
|
484
|
+
rejectWithError(reject, error)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
@objc
|
|
489
|
+
public func txExecute(
|
|
490
|
+
_ txID: String,
|
|
491
|
+
sql: String,
|
|
492
|
+
paramsJson: String,
|
|
493
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
494
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
495
|
+
) {
|
|
496
|
+
guard let session = getSession(txID) else {
|
|
497
|
+
reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
do {
|
|
501
|
+
let params = try parseParamsRaw(paramsJson)
|
|
502
|
+
session.pendingOp = .execute(sql: sql, params: params) { result in
|
|
503
|
+
switch result {
|
|
504
|
+
case .success(let dict): resolve(dict)
|
|
505
|
+
case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
session.semaphore.signal()
|
|
509
|
+
} catch {
|
|
510
|
+
rejectWithError(reject, error)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
@objc
|
|
515
|
+
public func commitTransaction(
|
|
516
|
+
_ txID: String,
|
|
517
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
518
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
519
|
+
) {
|
|
520
|
+
guard let session = getSession(txID) else {
|
|
521
|
+
reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
session.pendingOp = .commit { result in
|
|
525
|
+
switch result {
|
|
526
|
+
case .success: resolve(nil)
|
|
527
|
+
case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
session.semaphore.signal()
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
@objc
|
|
534
|
+
public func rollbackTransaction(
|
|
535
|
+
_ txID: String,
|
|
536
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
537
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
538
|
+
) {
|
|
539
|
+
guard let session = getSession(txID) else {
|
|
540
|
+
resolve(nil)
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
session.pendingOp = .rollback { result in
|
|
544
|
+
switch result {
|
|
545
|
+
case .success: resolve(nil)
|
|
546
|
+
case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
session.semaphore.signal()
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private func getSession(_ txID: String) -> TransactionSession? {
|
|
553
|
+
sessionsLock.lock()
|
|
554
|
+
defer { sessionsLock.unlock() }
|
|
555
|
+
return sessions[txID]
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private func wireClientEvents(_ client: SynchroClient) {
|
|
559
|
+
statusSubscription?.cancel()
|
|
560
|
+
conflictSubscription?.cancel()
|
|
561
|
+
snapshotSubscription?.cancel()
|
|
562
|
+
|
|
563
|
+
statusSubscription = client.onStatusChange { [weak self] status in
|
|
564
|
+
self?.emit("onStatusChange", self?.statusPayload(status) ?? [:])
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
conflictSubscription = client.onConflict { [weak self] event in
|
|
568
|
+
self?.emit("onConflict", self?.conflictPayload(event) ?? [:])
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
snapshotSubscription = client.onSnapshotRequired { [weak self] in
|
|
572
|
+
await withCheckedContinuation { continuation in
|
|
573
|
+
let requestID = UUID().uuidString
|
|
574
|
+
self?.snapshotLock.lock()
|
|
575
|
+
self?.pendingSnapshotContinuations[requestID] = continuation
|
|
576
|
+
self?.snapshotLock.unlock()
|
|
577
|
+
self?.emit("onSnapshotRequired", ["requestID": requestID])
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private func clearRuntimeState() {
|
|
583
|
+
statusSubscription?.cancel()
|
|
584
|
+
conflictSubscription?.cancel()
|
|
585
|
+
snapshotSubscription?.cancel()
|
|
586
|
+
statusSubscription = nil
|
|
587
|
+
conflictSubscription = nil
|
|
588
|
+
snapshotSubscription = nil
|
|
589
|
+
|
|
590
|
+
observers.values.forEach { $0.cancel() }
|
|
591
|
+
observers.removeAll()
|
|
592
|
+
|
|
593
|
+
sessionsLock.lock()
|
|
594
|
+
sessions.removeAll()
|
|
595
|
+
sessionsLock.unlock()
|
|
596
|
+
|
|
597
|
+
authLock.lock()
|
|
598
|
+
let authContinuations = pendingAuthContinuations
|
|
599
|
+
pendingAuthContinuations.removeAll()
|
|
600
|
+
authLock.unlock()
|
|
601
|
+
authContinuations.values.forEach { continuation in
|
|
602
|
+
continuation.resume(throwing: NSError(
|
|
603
|
+
domain: "SynchroModule",
|
|
604
|
+
code: 0,
|
|
605
|
+
userInfo: [NSLocalizedDescriptionKey: "client closed"]
|
|
606
|
+
))
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
snapshotLock.lock()
|
|
610
|
+
let snapshotContinuations = pendingSnapshotContinuations
|
|
611
|
+
pendingSnapshotContinuations.removeAll()
|
|
612
|
+
snapshotLock.unlock()
|
|
613
|
+
snapshotContinuations.values.forEach { continuation in
|
|
614
|
+
continuation.resume(returning: false)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private func statusPayload(_ status: SyncStatus) -> [String: Any] {
|
|
619
|
+
let formatter = ISO8601DateFormatter()
|
|
620
|
+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
621
|
+
|
|
622
|
+
switch status {
|
|
623
|
+
case .idle:
|
|
624
|
+
return ["status": "idle", "retryAt": NSNull()]
|
|
625
|
+
case .syncing:
|
|
626
|
+
return ["status": "syncing", "retryAt": NSNull()]
|
|
627
|
+
case .stopped:
|
|
628
|
+
return ["status": "stopped", "retryAt": NSNull()]
|
|
629
|
+
case .error(let retryAt):
|
|
630
|
+
return [
|
|
631
|
+
"status": "error",
|
|
632
|
+
"retryAt": retryAt.map { formatter.string(from: $0) } ?? NSNull()
|
|
633
|
+
]
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private func conflictPayload(_ event: ConflictEvent) -> [String: Any] {
|
|
638
|
+
[
|
|
639
|
+
"table": event.table,
|
|
640
|
+
"recordID": event.recordID,
|
|
641
|
+
"clientDataJson": encodeAnyCodableMap(event.clientData) ?? NSNull(),
|
|
642
|
+
"serverDataJson": encodeAnyCodableMap(event.serverData) ?? NSNull()
|
|
643
|
+
]
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private func encodeAnyCodableMap(_ value: [String: AnyCodable]?) -> String? {
|
|
647
|
+
guard let value else { return nil }
|
|
648
|
+
let encoder = JSONEncoder()
|
|
649
|
+
guard let data = try? encoder.encode(value) else { return nil }
|
|
650
|
+
return String(data: data, encoding: .utf8)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// MARK: - Schema
|
|
654
|
+
|
|
655
|
+
@objc
|
|
656
|
+
public func createTable(
|
|
657
|
+
_ name: String,
|
|
658
|
+
columnsJson: String,
|
|
659
|
+
optionsJson: String?,
|
|
660
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
661
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
662
|
+
) {
|
|
663
|
+
guard let client = client else {
|
|
664
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
do {
|
|
668
|
+
let columns = try parseColumns(columnsJson)
|
|
669
|
+
let options = try optionsJson.flatMap { try parseTableOptions($0) }
|
|
670
|
+
try client.createTable(name, columns: columns, options: options)
|
|
671
|
+
resolve(nil)
|
|
672
|
+
} catch {
|
|
673
|
+
rejectWithError(reject, error)
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
@objc
|
|
678
|
+
public func alterTable(
|
|
679
|
+
_ name: String,
|
|
680
|
+
columnsJson: String,
|
|
681
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
682
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
683
|
+
) {
|
|
684
|
+
guard let client = client else {
|
|
685
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
do {
|
|
689
|
+
let columns = try parseColumns(columnsJson)
|
|
690
|
+
try client.alterTable(name, addColumns: columns)
|
|
691
|
+
resolve(nil)
|
|
692
|
+
} catch {
|
|
693
|
+
rejectWithError(reject, error)
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
@objc
|
|
698
|
+
public func createIndex(
|
|
699
|
+
_ table: String,
|
|
700
|
+
columns: [String],
|
|
701
|
+
unique: Bool,
|
|
702
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
703
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
704
|
+
) {
|
|
705
|
+
guard let client = client else {
|
|
706
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
707
|
+
return
|
|
708
|
+
}
|
|
709
|
+
do {
|
|
710
|
+
try client.createIndex(table, columns: columns, unique: unique)
|
|
711
|
+
resolve(nil)
|
|
712
|
+
} catch {
|
|
713
|
+
rejectWithError(reject, error)
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// MARK: - Observation
|
|
718
|
+
|
|
719
|
+
@objc
|
|
720
|
+
public func addChangeObserver(
|
|
721
|
+
_ observerID: String,
|
|
722
|
+
tables: [String],
|
|
723
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
724
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
725
|
+
) {
|
|
726
|
+
guard let client = client else {
|
|
727
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
let cancellable = client.onChange(tables: tables) { [weak self] in
|
|
731
|
+
self?.emit("onChange", ["observerID": observerID])
|
|
732
|
+
}
|
|
733
|
+
observers[observerID] = cancellable
|
|
734
|
+
resolve(nil)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
@objc
|
|
738
|
+
public func addQueryObserver(
|
|
739
|
+
_ observerID: String,
|
|
740
|
+
sql: String,
|
|
741
|
+
paramsJson: String,
|
|
742
|
+
tables: [String],
|
|
743
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
744
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
745
|
+
) {
|
|
746
|
+
guard let client = client else {
|
|
747
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
do {
|
|
751
|
+
let params = try parseParams(paramsJson)
|
|
752
|
+
let cancellable = client.watch(sql, params: params, tables: tables) { [weak self] rows in
|
|
753
|
+
if let json = try? self?.rowsToJson(rows) {
|
|
754
|
+
self?.emit("onQueryResult", ["observerID": observerID, "rowsJson": json])
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
observers[observerID] = cancellable
|
|
758
|
+
resolve(nil)
|
|
759
|
+
} catch {
|
|
760
|
+
rejectWithError(reject, error)
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
@objc
|
|
765
|
+
public func removeObserver(
|
|
766
|
+
_ observerID: String,
|
|
767
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
768
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
769
|
+
) {
|
|
770
|
+
if let cancellable = observers.removeValue(forKey: observerID) {
|
|
771
|
+
cancellable.cancel()
|
|
772
|
+
}
|
|
773
|
+
resolve(nil)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// MARK: - WAL / Sync
|
|
777
|
+
|
|
778
|
+
@objc
|
|
779
|
+
public func checkpoint(
|
|
780
|
+
_ mode: String,
|
|
781
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
782
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
783
|
+
) {
|
|
784
|
+
guard let client = client else {
|
|
785
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
do {
|
|
789
|
+
let checkpointMode: Synchro.CheckpointMode
|
|
790
|
+
switch mode {
|
|
791
|
+
case "full": checkpointMode = .full
|
|
792
|
+
case "restart": checkpointMode = .restart
|
|
793
|
+
case "truncate": checkpointMode = .truncate
|
|
794
|
+
default: checkpointMode = .passive
|
|
795
|
+
}
|
|
796
|
+
try client.checkpoint(mode: checkpointMode)
|
|
797
|
+
resolve(nil)
|
|
798
|
+
} catch {
|
|
799
|
+
rejectWithError(reject, error)
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
@objc
|
|
804
|
+
public func start(
|
|
805
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
806
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
807
|
+
) {
|
|
808
|
+
guard let client = client else {
|
|
809
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
Task {
|
|
813
|
+
do {
|
|
814
|
+
try await client.start()
|
|
815
|
+
DispatchQueue.main.async {
|
|
816
|
+
resolve(nil)
|
|
817
|
+
}
|
|
818
|
+
} catch {
|
|
819
|
+
DispatchQueue.main.async {
|
|
820
|
+
self.rejectWithError(reject, error)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
@objc
|
|
827
|
+
public func stop(
|
|
828
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
829
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
830
|
+
) {
|
|
831
|
+
guard let client = client else {
|
|
832
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
client.stop()
|
|
836
|
+
DispatchQueue.main.async {
|
|
837
|
+
resolve(nil)
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
@objc
|
|
842
|
+
public func syncNow(
|
|
843
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
844
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
845
|
+
) {
|
|
846
|
+
guard let client = client else {
|
|
847
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
Task {
|
|
851
|
+
do {
|
|
852
|
+
try await client.syncNow()
|
|
853
|
+
DispatchQueue.main.async {
|
|
854
|
+
resolve(nil)
|
|
855
|
+
}
|
|
856
|
+
} catch {
|
|
857
|
+
DispatchQueue.main.async {
|
|
858
|
+
self.rejectWithError(reject, error)
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
@objc
|
|
865
|
+
public func pendingChangeCount(
|
|
866
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
867
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
868
|
+
) {
|
|
869
|
+
guard let client = client else {
|
|
870
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
871
|
+
return
|
|
872
|
+
}
|
|
873
|
+
do {
|
|
874
|
+
resolve(try client.pendingChangeCount())
|
|
875
|
+
} catch {
|
|
876
|
+
rejectWithError(reject, error)
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// MARK: - Helpers
|
|
881
|
+
|
|
882
|
+
private func jsonValueToParam(_ value: Any) -> (any DatabaseValueConvertible)? {
|
|
883
|
+
switch value {
|
|
884
|
+
case is NSNull:
|
|
885
|
+
return nil
|
|
886
|
+
case let intVal as Int:
|
|
887
|
+
return intVal
|
|
888
|
+
case let doubleVal as Double:
|
|
889
|
+
return doubleVal
|
|
890
|
+
case let stringVal as String:
|
|
891
|
+
return stringVal
|
|
892
|
+
case let boolVal as Bool:
|
|
893
|
+
return boolVal ? 1 : 0
|
|
894
|
+
default:
|
|
895
|
+
return String(describing: value)
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private func parseParams(_ json: String) throws -> [any DatabaseValueConvertible] {
|
|
900
|
+
let data = json.data(using: .utf8)!
|
|
901
|
+
let array = try JSONSerialization.jsonObject(with: data) as? [Any] ?? []
|
|
902
|
+
return array.compactMap { jsonValueToParam($0) }
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
private func parseParamsRaw(_ json: String) throws -> [Any] {
|
|
906
|
+
let data = json.data(using: .utf8)!
|
|
907
|
+
return try JSONSerialization.jsonObject(with: data) as? [Any] ?? []
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private func parseColumns(_ json: String) throws -> [ColumnDef] {
|
|
911
|
+
let data = json.data(using: .utf8)!
|
|
912
|
+
let array = try JSONSerialization.jsonObject(with: data) as! [[String: Any]]
|
|
913
|
+
return array.map { item in
|
|
914
|
+
ColumnDef(
|
|
915
|
+
name: item["name"] as! String,
|
|
916
|
+
type: item["type"] as! String,
|
|
917
|
+
nullable: item["nullable"] as? Bool ?? true,
|
|
918
|
+
defaultValue: item["defaultValue"] as? String,
|
|
919
|
+
primaryKey: item["primaryKey"] as? Bool ?? false
|
|
920
|
+
)
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
private func parseTableOptions(_ json: String) throws -> Synchro.TableOptions {
|
|
925
|
+
let data = json.data(using: .utf8)!
|
|
926
|
+
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
|
927
|
+
return Synchro.TableOptions(
|
|
928
|
+
ifNotExists: dict["ifNotExists"] as? Bool ?? true,
|
|
929
|
+
withoutRowid: dict["withoutRowid"] as? Bool ?? false
|
|
930
|
+
)
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
private func databaseValueToFoundation(_ dbValue: DatabaseValue) -> Any {
|
|
934
|
+
switch dbValue.storage {
|
|
935
|
+
case .null:
|
|
936
|
+
return NSNull()
|
|
937
|
+
case .int64(let v):
|
|
938
|
+
return NSNumber(value: v)
|
|
939
|
+
case .double(let v):
|
|
940
|
+
return NSNumber(value: v)
|
|
941
|
+
case .string(let v):
|
|
942
|
+
return v
|
|
943
|
+
case .blob(let v):
|
|
944
|
+
return v.base64EncodedString()
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
private func rowToJson(_ row: Row) throws -> String {
|
|
949
|
+
var dict: [String: Any] = [:]
|
|
950
|
+
for column in row.columnNames {
|
|
951
|
+
let dbValue: DatabaseValue = row[column]
|
|
952
|
+
dict[column] = databaseValueToFoundation(dbValue)
|
|
953
|
+
}
|
|
954
|
+
let data = try JSONSerialization.data(withJSONObject: dict)
|
|
955
|
+
return String(data: data, encoding: .utf8)!
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
private func rowsToJson(_ rows: [Row]) throws -> String {
|
|
959
|
+
let array: [[String: Any]] = rows.map { row in
|
|
960
|
+
var dict: [String: Any] = [:]
|
|
961
|
+
for column in row.columnNames {
|
|
962
|
+
let dbValue: DatabaseValue = row[column]
|
|
963
|
+
dict[column] = databaseValueToFoundation(dbValue)
|
|
964
|
+
}
|
|
965
|
+
return dict
|
|
966
|
+
}
|
|
967
|
+
let data = try JSONSerialization.data(withJSONObject: array)
|
|
968
|
+
return String(data: data, encoding: .utf8)!
|
|
969
|
+
}
|
|
970
|
+
}
|