@trainstar/synchro-react-native 0.1.2 → 0.3.2
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 +103 -24
- package/SynchroReactNative.podspec +3 -2
- package/android/build.gradle +8 -3
- package/android/src/main/kotlin/com/trainstar/synchro/rn/SynchroModule.kt +1106 -238
- package/ios/SynchroModule.mm +178 -51
- package/ios/SynchroModule.swift +1382 -295
- package/lib/module/NativeSynchro.js +5 -0
- package/lib/module/NativeSynchro.js.map +1 -1
- package/lib/module/SynchroClient.js +637 -65
- package/lib/module/SynchroClient.js.map +1 -1
- package/lib/module/digest.js +82 -0
- package/lib/module/digest.js.map +1 -0
- package/lib/module/errors.js +165 -22
- package/lib/module/errors.js.map +1 -1
- package/lib/module/hooks/useQuery.js +37 -3
- package/lib/module/hooks/useQuery.js.map +1 -1
- package/lib/module/hooks/useSyncStatus.js +51 -6
- package/lib/module/hooks/useSyncStatus.js.map +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/inspection.js +121 -0
- package/lib/module/inspection.js.map +1 -0
- package/lib/module/sqliteValues.js +87 -0
- package/lib/module/sqliteValues.js.map +1 -0
- package/lib/module/types.js +10 -0
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/example/e2e/corpus-harness.d.ts +34 -0
- package/lib/typescript/example/e2e/corpus-harness.d.ts.map +1 -0
- package/lib/typescript/example/src/conformance/runner.d.ts +125 -0
- package/lib/typescript/example/src/conformance/runner.d.ts.map +1 -0
- package/lib/typescript/example/src/conformance/types.d.ts +71 -0
- package/lib/typescript/example/src/conformance/types.d.ts.map +1 -0
- package/lib/typescript/example/src/timeouts.d.ts +2 -0
- package/lib/typescript/example/src/timeouts.d.ts.map +1 -0
- package/lib/typescript/src/NativeSynchro.d.ts +59 -17
- package/lib/typescript/src/NativeSynchro.d.ts.map +1 -1
- package/lib/typescript/src/SynchroClient.d.ts +27 -10
- package/lib/typescript/src/SynchroClient.d.ts.map +1 -1
- package/lib/typescript/src/digest.d.ts +3 -0
- package/lib/typescript/src/digest.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +37 -9
- package/lib/typescript/src/errors.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useQuery.d.ts +2 -2
- package/lib/typescript/src/hooks/useQuery.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useSyncStatus.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +4 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/inspection.d.ts +43 -0
- package/lib/typescript/src/inspection.d.ts.map +1 -0
- package/lib/typescript/src/sqliteValues.d.ts +8 -0
- package/lib/typescript/src/sqliteValues.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +203 -11
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +20 -8
- package/src/NativeSynchro.ts +93 -26
- package/src/SynchroClient.ts +900 -120
- package/src/digest.ts +93 -0
- package/src/errors.ts +273 -33
- package/src/hooks/useQuery.ts +59 -16
- package/src/hooks/useSyncStatus.ts +40 -7
- package/src/index.ts +44 -3
- package/src/inspection.ts +226 -0
- package/src/sqliteValues.ts +105 -0
- package/src/types.ts +318 -16
package/ios/SynchroModule.swift
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
+
import Darwin
|
|
2
3
|
import React
|
|
3
4
|
import GRDB
|
|
4
|
-
import Synchro
|
|
5
|
+
@_spi(Inspection) import Synchro
|
|
5
6
|
|
|
6
7
|
// MARK: - Event Delegate Protocol
|
|
7
8
|
|
|
@@ -12,27 +13,177 @@ import Synchro
|
|
|
12
13
|
// MARK: - Transaction Session
|
|
13
14
|
|
|
14
15
|
private class TransactionSession {
|
|
15
|
-
let
|
|
16
|
-
var
|
|
17
|
-
var
|
|
18
|
-
var
|
|
16
|
+
private let condition = NSCondition()
|
|
17
|
+
private var operations: [TransactionOp] = []
|
|
18
|
+
private var closed = false
|
|
19
|
+
private var abortError: Error?
|
|
20
|
+
private var finalCompletion: ((Result<Void, Error>) -> Void)?
|
|
21
|
+
private var finished = false
|
|
22
|
+
private var finishWaiters: [CheckedContinuation<Void, Never>] = []
|
|
19
23
|
let isWrite: Bool
|
|
20
24
|
|
|
21
25
|
init(isWrite: Bool) {
|
|
22
26
|
self.isWrite = isWrite
|
|
23
27
|
}
|
|
28
|
+
|
|
29
|
+
func enqueue(_ op: TransactionOp) -> Bool {
|
|
30
|
+
condition.lock()
|
|
31
|
+
defer { condition.unlock() }
|
|
32
|
+
guard !closed else {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
operations.append(op)
|
|
36
|
+
condition.signal()
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func nextOperation(timeout: TimeInterval) throws -> TransactionOp? {
|
|
41
|
+
condition.lock()
|
|
42
|
+
defer { condition.unlock() }
|
|
43
|
+
|
|
44
|
+
let deadline = Date().addingTimeInterval(timeout)
|
|
45
|
+
while operations.isEmpty && !closed {
|
|
46
|
+
if !condition.wait(until: deadline) {
|
|
47
|
+
closed = true
|
|
48
|
+
throw TransactionTimeoutError()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if let abortError {
|
|
53
|
+
throw abortError
|
|
54
|
+
}
|
|
55
|
+
guard !operations.isEmpty else {
|
|
56
|
+
return nil
|
|
57
|
+
}
|
|
58
|
+
return operations.removeFirst()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@discardableResult
|
|
62
|
+
func close() -> Bool {
|
|
63
|
+
condition.lock()
|
|
64
|
+
guard !closed else {
|
|
65
|
+
condition.unlock()
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
closed = true
|
|
69
|
+
let pending = operations
|
|
70
|
+
operations.removeAll()
|
|
71
|
+
condition.broadcast()
|
|
72
|
+
condition.unlock()
|
|
73
|
+
let error = TransactionAbortedError(message: "Transaction already completed")
|
|
74
|
+
pending.forEach { $0.fail(error) }
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func abort(_ error: Error) {
|
|
79
|
+
condition.lock()
|
|
80
|
+
guard !closed else {
|
|
81
|
+
condition.unlock()
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
closed = true
|
|
85
|
+
abortError = error
|
|
86
|
+
let pending = operations
|
|
87
|
+
operations.removeAll()
|
|
88
|
+
condition.broadcast()
|
|
89
|
+
condition.unlock()
|
|
90
|
+
pending.forEach { $0.fail(error) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func currentAbortError() -> Error? {
|
|
94
|
+
condition.lock()
|
|
95
|
+
defer { condition.unlock() }
|
|
96
|
+
return abortError
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func setFinalCompletion(_ completion: @escaping (Result<Void, Error>) -> Void) {
|
|
100
|
+
condition.lock()
|
|
101
|
+
finalCompletion = completion
|
|
102
|
+
condition.unlock()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func completeFinal(_ result: Result<Void, Error>) {
|
|
106
|
+
condition.lock()
|
|
107
|
+
let completion = finalCompletion
|
|
108
|
+
condition.unlock()
|
|
109
|
+
completion?(result)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func markFinished() {
|
|
113
|
+
condition.lock()
|
|
114
|
+
finished = true
|
|
115
|
+
let waiters = finishWaiters
|
|
116
|
+
finishWaiters.removeAll()
|
|
117
|
+
condition.unlock()
|
|
118
|
+
waiters.forEach { $0.resume() }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func waitUntilFinished() async {
|
|
122
|
+
await withCheckedContinuation { continuation in
|
|
123
|
+
condition.lock()
|
|
124
|
+
if finished {
|
|
125
|
+
condition.unlock()
|
|
126
|
+
continuation.resume()
|
|
127
|
+
} else {
|
|
128
|
+
finishWaiters.append(continuation)
|
|
129
|
+
condition.unlock()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
24
133
|
}
|
|
25
134
|
|
|
26
135
|
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)
|
|
136
|
+
case query(sql: String, params: [Any], completion: (Result<[[String: Any]], Error>) -> Void)
|
|
137
|
+
case queryOne(sql: String, params: [Any], completion: (Result<[String: Any]?, Error>) -> Void)
|
|
29
138
|
case execute(sql: String, params: [Any], completion: (Result<[String: Any], Error>) -> Void)
|
|
30
139
|
case commit(completion: (Result<Void, Error>) -> Void)
|
|
31
140
|
case rollback(completion: (Result<Void, Error>) -> Void)
|
|
141
|
+
|
|
142
|
+
func fail(_ error: Error) {
|
|
143
|
+
switch self {
|
|
144
|
+
case .query(_, _, let completion): completion(.failure(error))
|
|
145
|
+
case .queryOne(_, _, let completion): completion(.failure(error))
|
|
146
|
+
case .execute(_, _, let completion): completion(.failure(error))
|
|
147
|
+
case .commit(let completion): completion(.failure(error))
|
|
148
|
+
case .rollback(let completion): completion(.failure(error))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
32
151
|
}
|
|
33
152
|
|
|
34
153
|
private struct TransactionTimeoutError: Error {}
|
|
35
154
|
private struct TransactionRollbackError: Error {}
|
|
155
|
+
private struct DatabaseAlreadyExistsError: Error {}
|
|
156
|
+
private struct TransactionAbortedError: LocalizedError {
|
|
157
|
+
let message: String
|
|
158
|
+
|
|
159
|
+
var errorDescription: String? { message }
|
|
160
|
+
}
|
|
161
|
+
private struct TransactionReadOnlyError: LocalizedError {
|
|
162
|
+
var errorDescription: String? { "Read transactions cannot execute SQL" }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private actor LifecycleMutex {
|
|
166
|
+
private var locked = false
|
|
167
|
+
private var waiters: [CheckedContinuation<Void, Never>] = []
|
|
168
|
+
|
|
169
|
+
func lock() async {
|
|
170
|
+
if !locked {
|
|
171
|
+
locked = true
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
await withCheckedContinuation { continuation in
|
|
175
|
+
waiters.append(continuation)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func unlock() {
|
|
180
|
+
if waiters.isEmpty {
|
|
181
|
+
locked = false
|
|
182
|
+
} else {
|
|
183
|
+
waiters.removeFirst().resume()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
36
187
|
|
|
37
188
|
// MARK: - Implementation
|
|
38
189
|
|
|
@@ -41,19 +192,22 @@ public class SynchroModuleImpl: NSObject {
|
|
|
41
192
|
@objc public weak var eventDelegate: SynchroEventEmitting?
|
|
42
193
|
|
|
43
194
|
private var client: SynchroClient?
|
|
195
|
+
private let lifecycleMutex = LifecycleMutex()
|
|
44
196
|
private var sessions: [String: TransactionSession] = [:]
|
|
45
197
|
private let sessionsLock = NSLock()
|
|
198
|
+
private var acceptingTransactions = false
|
|
46
199
|
private var observers: [String: any Synchro.Cancellable] = [:]
|
|
47
200
|
private var statusSubscription: (any Synchro.Cancellable)?
|
|
201
|
+
private var syncEventSubscription: (any Synchro.Cancellable)?
|
|
48
202
|
private var conflictSubscription: (any Synchro.Cancellable)?
|
|
49
|
-
private
|
|
203
|
+
private let statusDetailsLock = NSLock()
|
|
204
|
+
private var cachedBackoff: SyncBackoffEvent?
|
|
205
|
+
private var cachedFailure: SyncFailure?
|
|
206
|
+
private var transportObservations: TransportObservationCollector?
|
|
50
207
|
|
|
51
208
|
private var pendingAuthContinuations: [String: CheckedContinuation<String, Error>] = [:]
|
|
52
209
|
private let authLock = NSLock()
|
|
53
210
|
|
|
54
|
-
private var pendingSnapshotContinuations: [String: CheckedContinuation<Bool, Never>] = [:]
|
|
55
|
-
private let snapshotLock = NSLock()
|
|
56
|
-
|
|
57
211
|
private func emit(_ name: String, _ body: [String: Any]) {
|
|
58
212
|
DispatchQueue.main.async { [weak self] in
|
|
59
213
|
self?.eventDelegate?.emitEvent(name, body: body as NSDictionary)
|
|
@@ -71,6 +225,8 @@ public class SynchroModuleImpl: NSObject {
|
|
|
71
225
|
))
|
|
72
226
|
} else if error is TransactionTimeoutError {
|
|
73
227
|
reject("TRANSACTION_TIMEOUT", "Transaction timed out due to inactivity", nil)
|
|
228
|
+
} else if error is TransactionAbortedError {
|
|
229
|
+
reject("NOT_CONNECTED", "Client closed during transaction", error)
|
|
74
230
|
} else {
|
|
75
231
|
reject("UNKNOWN", error.localizedDescription, error)
|
|
76
232
|
}
|
|
@@ -88,21 +244,35 @@ public class SynchroModuleImpl: NSObject {
|
|
|
88
244
|
return ("UPGRADE_REQUIRED", ["currentVersion": current, "minimumVersion": minimum])
|
|
89
245
|
case .schemaMismatch(let version, let hash):
|
|
90
246
|
return ("SCHEMA_MISMATCH", ["serverVersion": "\(version)", "serverHash": hash])
|
|
91
|
-
case .snapshotRequired:
|
|
92
|
-
return ("SNAPSHOT_REQUIRED", [:])
|
|
93
247
|
case .pushRejected(let results):
|
|
94
|
-
|
|
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) } ?? "[]"])
|
|
248
|
+
return ("PUSH_REJECTED", ["results": encodeRejectedMutations(results)])
|
|
98
249
|
case .networkError(let underlying):
|
|
99
250
|
return ("NETWORK_ERROR", ["message": underlying.localizedDescription])
|
|
100
251
|
case .serverError(let status, let msg):
|
|
101
252
|
return ("SERVER_ERROR", ["status": "\(status)", "message": msg])
|
|
253
|
+
case .protocolError(let status, let code, let msg):
|
|
254
|
+
return (
|
|
255
|
+
"PROTOCOL_ERROR",
|
|
256
|
+
["status": "\(status)", "protocolCode": code.rawValue, "message": msg]
|
|
257
|
+
)
|
|
102
258
|
case .databaseError(let underlying):
|
|
103
259
|
return ("DATABASE_ERROR", ["message": underlying.localizedDescription])
|
|
104
260
|
case .invalidResponse(let msg):
|
|
105
261
|
return ("INVALID_RESPONSE", ["message": msg])
|
|
262
|
+
case .blocked(let failure):
|
|
263
|
+
return (
|
|
264
|
+
"SYNC_BLOCKED",
|
|
265
|
+
[
|
|
266
|
+
"failure": failurePayload(failure),
|
|
267
|
+
]
|
|
268
|
+
)
|
|
269
|
+
case .unsupportedSchema(let reason):
|
|
270
|
+
return ("UNSUPPORTED_SCHEMA", ["reason": reason.rawValue])
|
|
271
|
+
case .invalidStateTransition(let from, let to):
|
|
272
|
+
return (
|
|
273
|
+
"INVALID_STATE_TRANSITION",
|
|
274
|
+
["from": from.rawValue, "to": to.rawValue]
|
|
275
|
+
)
|
|
106
276
|
case .alreadyStarted:
|
|
107
277
|
return ("ALREADY_STARTED", [:])
|
|
108
278
|
case .notStarted:
|
|
@@ -127,53 +297,96 @@ public class SynchroModuleImpl: NSObject {
|
|
|
127
297
|
reject("INVALID_CONFIG", "Missing required config fields", nil)
|
|
128
298
|
return
|
|
129
299
|
}
|
|
130
|
-
|
|
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
|
-
}
|
|
300
|
+
let resolvedDbPath = resolveDatabasePath(dbPath)
|
|
138
301
|
|
|
139
302
|
let syncInterval = config["syncInterval"] as? Double ?? 30
|
|
140
303
|
let pushDebounce = config["pushDebounce"] as? Double ?? 0.5
|
|
141
304
|
let maxRetryAttempts = config["maxRetryAttempts"] as? Int ?? 5
|
|
142
305
|
let pullPageSize = config["pullPageSize"] as? Int ?? 100
|
|
143
306
|
let pushBatchSize = config["pushBatchSize"] as? Int ?? 100
|
|
144
|
-
let
|
|
307
|
+
let seedDatabasePath = config["seedDatabasePath"] as? String
|
|
308
|
+
let configuredTransportCapacity = config["transportObservationCapacity"] as? Int ?? 0
|
|
309
|
+
let requireNewDatabase = config["requireNewDatabase"] as? Bool ?? false
|
|
310
|
+
if configuredTransportCapacity < 0 || configuredTransportCapacity > 512 {
|
|
311
|
+
reject("INVALID_CONFIG", "Transport observation capacity is invalid", nil)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
let transportCapacity = configuredTransportCapacity == 0 ? nil : configuredTransportCapacity
|
|
145
315
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
316
|
+
let resolvedSeedPath: String?
|
|
317
|
+
if let seedPath = seedDatabasePath {
|
|
318
|
+
if (seedPath as NSString).isAbsolutePath {
|
|
319
|
+
resolvedSeedPath = seedPath
|
|
320
|
+
} else {
|
|
321
|
+
// Bundled app resources live in Bundle.main, not Documents.
|
|
322
|
+
// Check the app bundle first, fall back to Documents.
|
|
323
|
+
let name = (seedPath as NSString).deletingPathExtension
|
|
324
|
+
let ext = (seedPath as NSString).pathExtension
|
|
325
|
+
if let bundlePath = Bundle.main.path(forResource: name, ofType: ext.isEmpty ? nil : ext) {
|
|
326
|
+
resolvedSeedPath = bundlePath
|
|
327
|
+
} else {
|
|
328
|
+
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
329
|
+
resolvedSeedPath = documentsURL.appendingPathComponent(seedPath).path
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
resolvedSeedPath = nil
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let transportObservations = transportCapacity.map(TransportObservationCollector.init(capacity:))
|
|
337
|
+
let synchroConfig = SynchroConfig(
|
|
338
|
+
dbPath: resolvedDbPath,
|
|
339
|
+
serverURL: url,
|
|
340
|
+
authProvider: { [weak self] in
|
|
341
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
342
|
+
let requestID = UUID().uuidString
|
|
343
|
+
self?.authLock.lock()
|
|
344
|
+
self?.pendingAuthContinuations[requestID] = continuation
|
|
345
|
+
self?.authLock.unlock()
|
|
346
|
+
self?.emit("onAuthRequest", ["requestID": requestID])
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
clientID: clientID,
|
|
350
|
+
platform: platform,
|
|
351
|
+
appVersion: appVersion,
|
|
352
|
+
syncInterval: syncInterval,
|
|
353
|
+
pushDebounce: pushDebounce,
|
|
354
|
+
maxRetryAttempts: maxRetryAttempts,
|
|
355
|
+
pullPageSize: pullPageSize,
|
|
356
|
+
pushBatchSize: pushBatchSize,
|
|
357
|
+
seedDatabasePath: resolvedSeedPath,
|
|
358
|
+
transportObservationCollector: transportObservations
|
|
359
|
+
)
|
|
360
|
+
Task { [weak self] in
|
|
361
|
+
guard let self else { return }
|
|
362
|
+
do {
|
|
363
|
+
try await self.withLifecycleLock {
|
|
364
|
+
await self.clearRuntimeState()
|
|
365
|
+
try await self.client?.close()
|
|
366
|
+
if requireNewDatabase && FileManager.default.fileExists(atPath: resolvedDbPath) {
|
|
367
|
+
throw DatabaseAlreadyExistsError()
|
|
157
368
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
369
|
+
let client = try SynchroClient(config: synchroConfig)
|
|
370
|
+
self.setClient(client, acceptingTransactions: true)
|
|
371
|
+
self.transportObservations = transportObservations
|
|
372
|
+
self.wireClientEvents(client)
|
|
373
|
+
}
|
|
374
|
+
DispatchQueue.main.async {
|
|
375
|
+
resolve(nil)
|
|
376
|
+
}
|
|
377
|
+
} catch is DatabaseAlreadyExistsError {
|
|
378
|
+
DispatchQueue.main.async {
|
|
379
|
+
reject("INVALID_CONFIG", "Database already exists", nil)
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
DispatchQueue.main.async {
|
|
383
|
+
if resolvedSeedPath != nil {
|
|
384
|
+
reject("INVALID_SEED", "Seed database failed validation", error)
|
|
385
|
+
} else {
|
|
386
|
+
self.rejectWithError(reject, error)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
177
390
|
}
|
|
178
391
|
}
|
|
179
392
|
|
|
@@ -193,26 +406,27 @@ public class SynchroModuleImpl: NSObject {
|
|
|
193
406
|
continuation?.resume(throwing: NSError(domain: "Auth", code: 0, userInfo: [NSLocalizedDescriptionKey: error]))
|
|
194
407
|
}
|
|
195
408
|
|
|
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
409
|
@objc
|
|
205
410
|
public func close(
|
|
206
411
|
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
207
412
|
reject: @escaping RCTPromiseRejectBlock
|
|
208
413
|
) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
414
|
+
Task { [weak self] in
|
|
415
|
+
guard let self else { return }
|
|
416
|
+
do {
|
|
417
|
+
try await self.withLifecycleLock {
|
|
418
|
+
await self.clearRuntimeState()
|
|
419
|
+
try await self.client?.close()
|
|
420
|
+
self.setClient(nil, acceptingTransactions: false)
|
|
421
|
+
}
|
|
422
|
+
DispatchQueue.main.async {
|
|
423
|
+
resolve(nil)
|
|
424
|
+
}
|
|
425
|
+
} catch {
|
|
426
|
+
DispatchQueue.main.async {
|
|
427
|
+
self.rejectWithError(reject, error)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
216
430
|
}
|
|
217
431
|
}
|
|
218
432
|
|
|
@@ -228,12 +442,20 @@ public class SynchroModuleImpl: NSObject {
|
|
|
228
442
|
resolve(client.path)
|
|
229
443
|
}
|
|
230
444
|
|
|
445
|
+
private func resolveDatabasePath(_ dbPath: String) -> String {
|
|
446
|
+
if (dbPath as NSString).isAbsolutePath {
|
|
447
|
+
return dbPath
|
|
448
|
+
}
|
|
449
|
+
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
450
|
+
return documentsURL.appendingPathComponent(dbPath).path
|
|
451
|
+
}
|
|
452
|
+
|
|
231
453
|
// MARK: - Core SQL
|
|
232
454
|
|
|
233
455
|
@objc
|
|
234
456
|
public func query(
|
|
235
457
|
_ sql: String,
|
|
236
|
-
|
|
458
|
+
params: [Any],
|
|
237
459
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
238
460
|
reject: @escaping RCTPromiseRejectBlock
|
|
239
461
|
) {
|
|
@@ -242,9 +464,8 @@ public class SynchroModuleImpl: NSObject {
|
|
|
242
464
|
return
|
|
243
465
|
}
|
|
244
466
|
do {
|
|
245
|
-
let
|
|
246
|
-
|
|
247
|
-
resolve(try rowsToJson(rows))
|
|
467
|
+
let rows = try client.query(sql, params: try bridgeParams(params))
|
|
468
|
+
resolve(rowsToBridgeRows(rows))
|
|
248
469
|
} catch {
|
|
249
470
|
rejectWithError(reject, error)
|
|
250
471
|
}
|
|
@@ -253,7 +474,7 @@ public class SynchroModuleImpl: NSObject {
|
|
|
253
474
|
@objc
|
|
254
475
|
public func queryOne(
|
|
255
476
|
_ sql: String,
|
|
256
|
-
|
|
477
|
+
params: [Any],
|
|
257
478
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
258
479
|
reject: @escaping RCTPromiseRejectBlock
|
|
259
480
|
) {
|
|
@@ -262,12 +483,11 @@ public class SynchroModuleImpl: NSObject {
|
|
|
262
483
|
return
|
|
263
484
|
}
|
|
264
485
|
do {
|
|
265
|
-
let
|
|
266
|
-
let row = try client.queryOne(sql, params: params)
|
|
486
|
+
let row = try client.queryOne(sql, params: try bridgeParams(params))
|
|
267
487
|
if let row = row {
|
|
268
|
-
resolve(
|
|
488
|
+
resolve(rowToBridgeRow(row))
|
|
269
489
|
} else {
|
|
270
|
-
resolve(
|
|
490
|
+
resolve(NSNull())
|
|
271
491
|
}
|
|
272
492
|
} catch {
|
|
273
493
|
rejectWithError(reject, error)
|
|
@@ -277,7 +497,29 @@ public class SynchroModuleImpl: NSObject {
|
|
|
277
497
|
@objc
|
|
278
498
|
public func execute(
|
|
279
499
|
_ sql: String,
|
|
280
|
-
|
|
500
|
+
params: [Any],
|
|
501
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
502
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
503
|
+
) {
|
|
504
|
+
guard let client = client else {
|
|
505
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
do {
|
|
509
|
+
let result = try client.execute(sql, params: try bridgeParams(params))
|
|
510
|
+
resolve(["rowsAffected": result.rowsAffected])
|
|
511
|
+
} catch {
|
|
512
|
+
rejectWithError(reject, error)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
@objc
|
|
517
|
+
public func executeAuthoredWrite(
|
|
518
|
+
_ tableName: String,
|
|
519
|
+
operation: String,
|
|
520
|
+
columnNames: [String],
|
|
521
|
+
sql: String,
|
|
522
|
+
values: [Any],
|
|
281
523
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
282
524
|
reject: @escaping RCTPromiseRejectBlock
|
|
283
525
|
) {
|
|
@@ -285,9 +527,19 @@ public class SynchroModuleImpl: NSObject {
|
|
|
285
527
|
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
286
528
|
return
|
|
287
529
|
}
|
|
530
|
+
guard let authoredOperation = Synchro.Operation(rawValue: operation) else {
|
|
531
|
+
reject("INVALID_CONFIG", "Invalid authored write operation", nil)
|
|
532
|
+
return
|
|
533
|
+
}
|
|
288
534
|
do {
|
|
289
|
-
let params = try
|
|
290
|
-
let result = try client.
|
|
535
|
+
let params = try bridgeParams(values)
|
|
536
|
+
let result = try client.authoredWriteTransaction(
|
|
537
|
+
tableName: tableName,
|
|
538
|
+
operation: authoredOperation,
|
|
539
|
+
columnNames: columnNames
|
|
540
|
+
) { transaction in
|
|
541
|
+
try transaction.execute(sql, params: params)
|
|
542
|
+
}
|
|
291
543
|
resolve(["rowsAffected": result.rowsAffected])
|
|
292
544
|
} catch {
|
|
293
545
|
rejectWithError(reject, error)
|
|
@@ -296,7 +548,7 @@ public class SynchroModuleImpl: NSObject {
|
|
|
296
548
|
|
|
297
549
|
@objc
|
|
298
550
|
public func executeBatch(
|
|
299
|
-
_
|
|
551
|
+
_ statements: [[String: Any]],
|
|
300
552
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
301
553
|
reject: @escaping RCTPromiseRejectBlock
|
|
302
554
|
) {
|
|
@@ -305,14 +557,17 @@ public class SynchroModuleImpl: NSObject {
|
|
|
305
557
|
return
|
|
306
558
|
}
|
|
307
559
|
do {
|
|
308
|
-
let
|
|
309
|
-
let array = try JSONSerialization.jsonObject(with: data) as! [[String: Any]]
|
|
310
|
-
let statements: [Synchro.SQLStatement] = array.map { item in
|
|
560
|
+
let nativeStatements: [Synchro.SQLStatement] = try statements.map { item in
|
|
311
561
|
let sql = item["sql"] as! String
|
|
312
|
-
let params
|
|
562
|
+
let params: [(any DatabaseValueConvertible)?]?
|
|
563
|
+
if let bridgeValues = item["params"] as? [Any] {
|
|
564
|
+
params = try bridgeParams(bridgeValues)
|
|
565
|
+
} else {
|
|
566
|
+
params = nil
|
|
567
|
+
}
|
|
313
568
|
return Synchro.SQLStatement(sql: sql, params: params)
|
|
314
569
|
}
|
|
315
|
-
let total = try client.executeBatch(
|
|
570
|
+
let total = try client.executeBatch(nativeStatements)
|
|
316
571
|
resolve(["totalRowsAffected": total])
|
|
317
572
|
} catch {
|
|
318
573
|
rejectWithError(reject, error)
|
|
@@ -337,99 +592,164 @@ public class SynchroModuleImpl: NSObject {
|
|
|
337
592
|
beginTransaction(isWrite: false, resolve: resolve, reject: reject)
|
|
338
593
|
}
|
|
339
594
|
|
|
595
|
+
private func runTransactionLoop(
|
|
596
|
+
session: TransactionSession,
|
|
597
|
+
txID: String,
|
|
598
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
599
|
+
query: (String, [(any DatabaseValueConvertible)?]) throws -> [Row],
|
|
600
|
+
queryOne: (String, [(any DatabaseValueConvertible)?]) throws -> Row?,
|
|
601
|
+
execute: ((String, [(any DatabaseValueConvertible)?]) throws -> Int)?
|
|
602
|
+
) throws {
|
|
603
|
+
resolve(txID)
|
|
604
|
+
|
|
605
|
+
while true {
|
|
606
|
+
guard let op = try session.nextOperation(timeout: 5) else {
|
|
607
|
+
break
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
switch op {
|
|
611
|
+
case .query(let sql, let params, let completion):
|
|
612
|
+
do {
|
|
613
|
+
let rows = try query(sql, try bridgeParams(params))
|
|
614
|
+
if let abortError = session.currentAbortError() {
|
|
615
|
+
throw abortError
|
|
616
|
+
}
|
|
617
|
+
completion(.success(rowsToBridgeRows(rows)))
|
|
618
|
+
} catch {
|
|
619
|
+
completion(.failure(error))
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
case .queryOne(let sql, let params, let completion):
|
|
623
|
+
do {
|
|
624
|
+
let row = try queryOne(sql, try bridgeParams(params))
|
|
625
|
+
if let abortError = session.currentAbortError() {
|
|
626
|
+
throw abortError
|
|
627
|
+
}
|
|
628
|
+
completion(.success(row.map(rowToBridgeRow)))
|
|
629
|
+
} catch {
|
|
630
|
+
completion(.failure(error))
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
case .execute(let sql, let params, let completion):
|
|
634
|
+
do {
|
|
635
|
+
guard let execute else {
|
|
636
|
+
throw TransactionReadOnlyError()
|
|
637
|
+
}
|
|
638
|
+
let rowsAffected = try execute(sql, try bridgeParams(params))
|
|
639
|
+
if let abortError = session.currentAbortError() {
|
|
640
|
+
throw abortError
|
|
641
|
+
}
|
|
642
|
+
completion(.success(["rowsAffected": rowsAffected]))
|
|
643
|
+
} catch {
|
|
644
|
+
completion(.failure(error))
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
case .commit(let completion):
|
|
648
|
+
guard session.close() else {
|
|
649
|
+
let error = session.currentAbortError()
|
|
650
|
+
?? TransactionAbortedError(message: "Transaction already completed")
|
|
651
|
+
completion(.failure(error))
|
|
652
|
+
throw error
|
|
653
|
+
}
|
|
654
|
+
session.setFinalCompletion(completion)
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
case .rollback(let completion):
|
|
658
|
+
guard session.close() else {
|
|
659
|
+
let error = session.currentAbortError()
|
|
660
|
+
?? TransactionAbortedError(message: "Transaction already completed")
|
|
661
|
+
completion(.failure(error))
|
|
662
|
+
throw error
|
|
663
|
+
}
|
|
664
|
+
session.setFinalCompletion(completion)
|
|
665
|
+
throw TransactionRollbackError()
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
340
670
|
private func beginTransaction(
|
|
341
671
|
isWrite: Bool,
|
|
342
672
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
343
673
|
reject: @escaping RCTPromiseRejectBlock
|
|
344
674
|
) {
|
|
345
|
-
|
|
675
|
+
sessionsLock.lock()
|
|
676
|
+
guard acceptingTransactions, let client = client else {
|
|
677
|
+
sessionsLock.unlock()
|
|
346
678
|
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
347
679
|
return
|
|
348
680
|
}
|
|
349
681
|
|
|
350
682
|
let txID = UUID().uuidString
|
|
351
683
|
let session = TransactionSession(isWrite: isWrite)
|
|
352
|
-
|
|
353
|
-
sessionsLock.lock()
|
|
354
684
|
sessions[txID] = session
|
|
355
685
|
sessionsLock.unlock()
|
|
356
686
|
|
|
357
|
-
DispatchQueue.global(qos: .userInitiated).async {
|
|
687
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
688
|
+
defer {
|
|
689
|
+
session.close()
|
|
690
|
+
self.sessionsLock.lock()
|
|
691
|
+
self.sessions.removeValue(forKey: txID)
|
|
692
|
+
self.sessionsLock.unlock()
|
|
693
|
+
session.markFinished()
|
|
694
|
+
}
|
|
358
695
|
do {
|
|
359
|
-
let
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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))
|
|
696
|
+
let finalResult: Result<Void, Error>
|
|
697
|
+
if isWrite {
|
|
698
|
+
try client.writeTransaction { transaction in
|
|
699
|
+
try self.runTransactionLoop(
|
|
700
|
+
session: session,
|
|
701
|
+
txID: txID,
|
|
702
|
+
resolve: resolve,
|
|
703
|
+
query: { sql, params in
|
|
704
|
+
try transaction.query(sql, params: params)
|
|
705
|
+
},
|
|
706
|
+
queryOne: { sql, params in
|
|
707
|
+
try transaction.queryOne(sql, params: params)
|
|
708
|
+
},
|
|
709
|
+
execute: { sql, params in
|
|
710
|
+
try transaction.execute(sql, params: params).rowsAffected
|
|
402
711
|
}
|
|
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
|
-
}
|
|
712
|
+
)
|
|
414
713
|
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
if isWrite {
|
|
418
|
-
try client.writeTransaction { db in try txBlock(db) }
|
|
419
714
|
} else {
|
|
420
|
-
try client.readTransaction { db in
|
|
715
|
+
try client.readTransaction { db in
|
|
716
|
+
try self.runTransactionLoop(
|
|
717
|
+
session: session,
|
|
718
|
+
txID: txID,
|
|
719
|
+
resolve: resolve,
|
|
720
|
+
query: { sql, params in
|
|
721
|
+
let statement = try db.makeStatement(sql: sql)
|
|
722
|
+
guard statement.isReadonly else {
|
|
723
|
+
throw TransactionReadOnlyError()
|
|
724
|
+
}
|
|
725
|
+
return try Row.fetchAll(
|
|
726
|
+
statement,
|
|
727
|
+
arguments: StatementArguments(params)
|
|
728
|
+
)
|
|
729
|
+
},
|
|
730
|
+
queryOne: { sql, params in
|
|
731
|
+
let statement = try db.makeStatement(sql: sql)
|
|
732
|
+
guard statement.isReadonly else {
|
|
733
|
+
throw TransactionReadOnlyError()
|
|
734
|
+
}
|
|
735
|
+
return try Row.fetchOne(
|
|
736
|
+
statement,
|
|
737
|
+
arguments: StatementArguments(params)
|
|
738
|
+
)
|
|
739
|
+
},
|
|
740
|
+
execute: nil
|
|
741
|
+
)
|
|
742
|
+
}
|
|
421
743
|
}
|
|
744
|
+
finalResult = .success(())
|
|
745
|
+
session.completeFinal(finalResult)
|
|
422
746
|
} catch is TransactionTimeoutError {
|
|
423
|
-
|
|
747
|
+
session.completeFinal(.failure(TransactionTimeoutError()))
|
|
424
748
|
} catch is TransactionRollbackError {
|
|
425
|
-
|
|
749
|
+
session.completeFinal(.success(()))
|
|
426
750
|
} catch {
|
|
427
|
-
|
|
751
|
+
session.completeFinal(.failure(error))
|
|
428
752
|
}
|
|
429
|
-
|
|
430
|
-
self?.sessionsLock.lock()
|
|
431
|
-
self?.sessions.removeValue(forKey: txID)
|
|
432
|
-
self?.sessionsLock.unlock()
|
|
433
753
|
}
|
|
434
754
|
}
|
|
435
755
|
|
|
@@ -437,7 +757,7 @@ public class SynchroModuleImpl: NSObject {
|
|
|
437
757
|
public func txQuery(
|
|
438
758
|
_ txID: String,
|
|
439
759
|
sql: String,
|
|
440
|
-
|
|
760
|
+
params: [Any],
|
|
441
761
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
442
762
|
reject: @escaping RCTPromiseRejectBlock
|
|
443
763
|
) {
|
|
@@ -445,17 +765,15 @@ public class SynchroModuleImpl: NSObject {
|
|
|
445
765
|
reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
|
|
446
766
|
return
|
|
447
767
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
case .success(let json): resolve(json)
|
|
453
|
-
case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
|
|
454
|
-
}
|
|
768
|
+
let op = TransactionOp.query(sql: sql, params: params) { result in
|
|
769
|
+
switch result {
|
|
770
|
+
case .success(let rows): resolve(rows)
|
|
771
|
+
case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
|
|
455
772
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
773
|
+
}
|
|
774
|
+
guard session.enqueue(op) else {
|
|
775
|
+
reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
|
|
776
|
+
return
|
|
459
777
|
}
|
|
460
778
|
}
|
|
461
779
|
|
|
@@ -463,7 +781,7 @@ public class SynchroModuleImpl: NSObject {
|
|
|
463
781
|
public func txQueryOne(
|
|
464
782
|
_ txID: String,
|
|
465
783
|
sql: String,
|
|
466
|
-
|
|
784
|
+
params: [Any],
|
|
467
785
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
468
786
|
reject: @escaping RCTPromiseRejectBlock
|
|
469
787
|
) {
|
|
@@ -471,17 +789,15 @@ public class SynchroModuleImpl: NSObject {
|
|
|
471
789
|
reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
|
|
472
790
|
return
|
|
473
791
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
case .success(let json): resolve(json)
|
|
479
|
-
case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
|
|
480
|
-
}
|
|
792
|
+
let op = TransactionOp.queryOne(sql: sql, params: params) { result in
|
|
793
|
+
switch result {
|
|
794
|
+
case .success(let row): resolve(row ?? NSNull())
|
|
795
|
+
case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
|
|
481
796
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
797
|
+
}
|
|
798
|
+
guard session.enqueue(op) else {
|
|
799
|
+
reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
|
|
800
|
+
return
|
|
485
801
|
}
|
|
486
802
|
}
|
|
487
803
|
|
|
@@ -489,7 +805,7 @@ public class SynchroModuleImpl: NSObject {
|
|
|
489
805
|
public func txExecute(
|
|
490
806
|
_ txID: String,
|
|
491
807
|
sql: String,
|
|
492
|
-
|
|
808
|
+
params: [Any],
|
|
493
809
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
494
810
|
reject: @escaping RCTPromiseRejectBlock
|
|
495
811
|
) {
|
|
@@ -497,17 +813,19 @@ public class SynchroModuleImpl: NSObject {
|
|
|
497
813
|
reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
|
|
498
814
|
return
|
|
499
815
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
816
|
+
guard session.isWrite else {
|
|
817
|
+
reject("DATABASE_ERROR", "Read transactions cannot execute SQL", nil)
|
|
818
|
+
return
|
|
819
|
+
}
|
|
820
|
+
let op = TransactionOp.execute(sql: sql, params: params) { result in
|
|
821
|
+
switch result {
|
|
822
|
+
case .success(let dict): resolve(dict)
|
|
823
|
+
case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
|
|
507
824
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
825
|
+
}
|
|
826
|
+
guard session.enqueue(op) else {
|
|
827
|
+
reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
|
|
828
|
+
return
|
|
511
829
|
}
|
|
512
830
|
}
|
|
513
831
|
|
|
@@ -521,13 +839,16 @@ public class SynchroModuleImpl: NSObject {
|
|
|
521
839
|
reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
|
|
522
840
|
return
|
|
523
841
|
}
|
|
524
|
-
|
|
842
|
+
let op = TransactionOp.commit { result in
|
|
525
843
|
switch result {
|
|
526
844
|
case .success: resolve(nil)
|
|
527
845
|
case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
|
|
528
846
|
}
|
|
529
847
|
}
|
|
530
|
-
session.
|
|
848
|
+
guard session.enqueue(op) else {
|
|
849
|
+
reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
|
|
850
|
+
return
|
|
851
|
+
}
|
|
531
852
|
}
|
|
532
853
|
|
|
533
854
|
@objc
|
|
@@ -540,13 +861,16 @@ public class SynchroModuleImpl: NSObject {
|
|
|
540
861
|
resolve(nil)
|
|
541
862
|
return
|
|
542
863
|
}
|
|
543
|
-
|
|
864
|
+
let op = TransactionOp.rollback { result in
|
|
544
865
|
switch result {
|
|
545
866
|
case .success: resolve(nil)
|
|
546
867
|
case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
|
|
547
868
|
}
|
|
548
869
|
}
|
|
549
|
-
session.
|
|
870
|
+
guard session.enqueue(op) else {
|
|
871
|
+
resolve(nil)
|
|
872
|
+
return
|
|
873
|
+
}
|
|
550
874
|
}
|
|
551
875
|
|
|
552
876
|
private func getSession(_ txID: String) -> TransactionSession? {
|
|
@@ -557,40 +881,76 @@ public class SynchroModuleImpl: NSObject {
|
|
|
557
881
|
|
|
558
882
|
private func wireClientEvents(_ client: SynchroClient) {
|
|
559
883
|
statusSubscription?.cancel()
|
|
884
|
+
syncEventSubscription?.cancel()
|
|
560
885
|
conflictSubscription?.cancel()
|
|
561
|
-
|
|
886
|
+
statusDetailsLock.lock()
|
|
887
|
+
cachedBackoff = nil
|
|
888
|
+
cachedFailure = nil
|
|
889
|
+
statusDetailsLock.unlock()
|
|
562
890
|
|
|
563
891
|
statusSubscription = client.onStatusChange { [weak self] status in
|
|
892
|
+
guard status != .backoff, status != .error else { return }
|
|
564
893
|
self?.emit("onStatusChange", self?.statusPayload(status) ?? [:])
|
|
565
894
|
}
|
|
566
895
|
|
|
896
|
+
syncEventSubscription = client.onSyncEvent { [weak self] event in
|
|
897
|
+
guard let self else { return }
|
|
898
|
+
self.recordStatusDetails(event)
|
|
899
|
+
self.emit("onSyncEvent", self.syncEventPayload(event))
|
|
900
|
+
switch event {
|
|
901
|
+
case .backoff, .failure:
|
|
902
|
+
self.emit(
|
|
903
|
+
"onStatusChange",
|
|
904
|
+
self.statusPayload(client.getSyncStatus())
|
|
905
|
+
)
|
|
906
|
+
default:
|
|
907
|
+
break
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
567
911
|
conflictSubscription = client.onConflict { [weak self] event in
|
|
568
912
|
self?.emit("onConflict", self?.conflictPayload(event) ?? [:])
|
|
569
913
|
}
|
|
914
|
+
}
|
|
570
915
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
916
|
+
private func setClient(_ client: SynchroClient?, acceptingTransactions: Bool) {
|
|
917
|
+
sessionsLock.lock()
|
|
918
|
+
self.client = client
|
|
919
|
+
self.acceptingTransactions = acceptingTransactions
|
|
920
|
+
sessionsLock.unlock()
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private func withLifecycleLock(_ operation: () async throws -> Void) async throws {
|
|
924
|
+
await lifecycleMutex.lock()
|
|
925
|
+
do {
|
|
926
|
+
try await operation()
|
|
927
|
+
await lifecycleMutex.unlock()
|
|
928
|
+
} catch {
|
|
929
|
+
await lifecycleMutex.unlock()
|
|
930
|
+
throw error
|
|
579
931
|
}
|
|
580
932
|
}
|
|
581
933
|
|
|
582
|
-
private func
|
|
934
|
+
private func detachRuntimeState() -> [TransactionSession] {
|
|
583
935
|
statusSubscription?.cancel()
|
|
936
|
+
syncEventSubscription?.cancel()
|
|
584
937
|
conflictSubscription?.cancel()
|
|
585
|
-
snapshotSubscription?.cancel()
|
|
586
938
|
statusSubscription = nil
|
|
939
|
+
syncEventSubscription = nil
|
|
587
940
|
conflictSubscription = nil
|
|
588
|
-
|
|
941
|
+
transportObservations?.cancelPauseBarrier()
|
|
942
|
+
transportObservations = nil
|
|
943
|
+
statusDetailsLock.lock()
|
|
944
|
+
cachedBackoff = nil
|
|
945
|
+
cachedFailure = nil
|
|
946
|
+
statusDetailsLock.unlock()
|
|
589
947
|
|
|
590
948
|
observers.values.forEach { $0.cancel() }
|
|
591
949
|
observers.removeAll()
|
|
592
950
|
|
|
593
951
|
sessionsLock.lock()
|
|
952
|
+
acceptingTransactions = false
|
|
953
|
+
let activeSessions = Array(sessions.values)
|
|
594
954
|
sessions.removeAll()
|
|
595
955
|
sessionsLock.unlock()
|
|
596
956
|
|
|
@@ -605,33 +965,155 @@ public class SynchroModuleImpl: NSObject {
|
|
|
605
965
|
userInfo: [NSLocalizedDescriptionKey: "client closed"]
|
|
606
966
|
))
|
|
607
967
|
}
|
|
968
|
+
return activeSessions
|
|
969
|
+
}
|
|
608
970
|
|
|
609
|
-
|
|
610
|
-
let
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
971
|
+
private func clearRuntimeState() async {
|
|
972
|
+
let activeSessions = detachRuntimeState()
|
|
973
|
+
activeSessions.forEach {
|
|
974
|
+
$0.abort(TransactionAbortedError(message: "Client closed during transaction"))
|
|
975
|
+
}
|
|
976
|
+
for session in activeSessions {
|
|
977
|
+
await session.waitUntilFinished()
|
|
615
978
|
}
|
|
616
979
|
}
|
|
617
980
|
|
|
618
981
|
private func statusPayload(_ status: SyncStatus) -> [String: Any] {
|
|
982
|
+
var payload: [String: Any] = [
|
|
983
|
+
"status": status.rawValue,
|
|
984
|
+
"retryAt": NSNull(),
|
|
985
|
+
"operation": NSNull(),
|
|
986
|
+
"failure": NSNull(),
|
|
987
|
+
]
|
|
988
|
+
switch status {
|
|
989
|
+
case .uninitialized, .localReady, .connecting, .schemaApplying, .ready, .pushing, .pulling, .rebuilding, .stopped:
|
|
990
|
+
break
|
|
991
|
+
case .backoff:
|
|
992
|
+
statusDetailsLock.lock()
|
|
993
|
+
let backoff = cachedBackoff
|
|
994
|
+
statusDetailsLock.unlock()
|
|
995
|
+
if let backoff {
|
|
996
|
+
payload["retryAt"] = iso8601String(backoff.retryAt)
|
|
997
|
+
payload["operation"] = backoff.operation.rawValue
|
|
998
|
+
}
|
|
999
|
+
case .error:
|
|
1000
|
+
statusDetailsLock.lock()
|
|
1001
|
+
let cachedFailure = self.cachedFailure
|
|
1002
|
+
statusDetailsLock.unlock()
|
|
1003
|
+
let persistedFailure: SyncFailure?
|
|
1004
|
+
if let client = self.client {
|
|
1005
|
+
persistedFailure = try? client.getBlockingFailure()
|
|
1006
|
+
} else {
|
|
1007
|
+
persistedFailure = nil
|
|
1008
|
+
}
|
|
1009
|
+
if let failure = cachedFailure ?? persistedFailure {
|
|
1010
|
+
payload["failure"] = failurePayload(failure)
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return payload
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
private func recordStatusDetails(_ event: SyncEvent) {
|
|
1017
|
+
statusDetailsLock.lock()
|
|
1018
|
+
defer { statusDetailsLock.unlock() }
|
|
1019
|
+
switch event {
|
|
1020
|
+
case .backoff(let backoff):
|
|
1021
|
+
cachedBackoff = backoff
|
|
1022
|
+
cachedFailure = nil
|
|
1023
|
+
case .failure(let failure):
|
|
1024
|
+
cachedFailure = failure
|
|
1025
|
+
cachedBackoff = nil
|
|
1026
|
+
default:
|
|
1027
|
+
break
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
private func schemaPayload(_ schema: SchemaRef) -> [String: Any] {
|
|
1032
|
+
["version": schema.version, "hash": schema.hash]
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
private func failurePayload(_ failure: SyncFailure) -> [String: Any] {
|
|
1036
|
+
[
|
|
1037
|
+
"operation": failure.operation.rawValue,
|
|
1038
|
+
"code": failure.code.rawValue,
|
|
1039
|
+
"retryable": failure.retryable,
|
|
1040
|
+
"message": failure.message,
|
|
1041
|
+
"recoveryAction": failure.recoveryAction.rawValue,
|
|
1042
|
+
"metadata": failure.metadata,
|
|
1043
|
+
]
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
private func iso8601String(_ date: Date) -> String {
|
|
619
1047
|
let formatter = ISO8601DateFormatter()
|
|
620
1048
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
1049
|
+
return formatter.string(from: date)
|
|
1050
|
+
}
|
|
621
1051
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1052
|
+
private func syncEventPayload(_ event: SyncEvent) -> [String: Any] {
|
|
1053
|
+
var payload: [String: Any] = [
|
|
1054
|
+
"type": "",
|
|
1055
|
+
"from": NSNull(),
|
|
1056
|
+
"to": NSNull(),
|
|
1057
|
+
"operation": NSNull(),
|
|
1058
|
+
"attempt": NSNull(),
|
|
1059
|
+
"retryAt": NSNull(),
|
|
1060
|
+
"source": NSNull(),
|
|
1061
|
+
"target": NSNull(),
|
|
1062
|
+
"action": NSNull(),
|
|
1063
|
+
"mutationID": NSNull(),
|
|
1064
|
+
"tableID": NSNull(),
|
|
1065
|
+
"mutationStatus": NSNull(),
|
|
1066
|
+
"rejectionCode": NSNull(),
|
|
1067
|
+
"scopeID": NSNull(),
|
|
1068
|
+
"rebuildID": NSNull(),
|
|
1069
|
+
"failure": NSNull(),
|
|
1070
|
+
]
|
|
1071
|
+
|
|
1072
|
+
switch event {
|
|
1073
|
+
case .stateChanged(let state):
|
|
1074
|
+
payload["type"] = "state_changed"
|
|
1075
|
+
payload["from"] = state.from.rawValue
|
|
1076
|
+
payload["to"] = state.to.rawValue
|
|
1077
|
+
case .backoff(let backoff):
|
|
1078
|
+
payload["type"] = "backoff"
|
|
1079
|
+
payload["operation"] = backoff.operation.rawValue
|
|
1080
|
+
payload["attempt"] = backoff.attempt
|
|
1081
|
+
payload["retryAt"] = iso8601String(backoff.retryAt)
|
|
1082
|
+
case .schemaApplying(let schema):
|
|
1083
|
+
payload["type"] = "schema_applying"
|
|
1084
|
+
payload["source"] = schemaPayload(schema.source)
|
|
1085
|
+
payload["target"] = schemaPayload(schema.target)
|
|
1086
|
+
payload["action"] = schema.action.rawValue
|
|
1087
|
+
case .schemaApplied(let schema):
|
|
1088
|
+
payload["type"] = "schema_applied"
|
|
1089
|
+
payload["source"] = schemaPayload(schema.source)
|
|
1090
|
+
payload["target"] = schemaPayload(schema.target)
|
|
1091
|
+
payload["action"] = schema.action.rawValue
|
|
1092
|
+
case .mutationAccepted(let mutation):
|
|
1093
|
+
payload["type"] = "mutation_accepted"
|
|
1094
|
+
payload["mutationID"] = mutation.mutationID
|
|
1095
|
+
payload["tableID"] = mutation.tableID
|
|
1096
|
+
payload["mutationStatus"] = mutation.status.rawValue
|
|
1097
|
+
payload["rejectionCode"] = mutation.rejectionCode?.rawValue ?? NSNull()
|
|
1098
|
+
case .mutationRejected(let mutation):
|
|
1099
|
+
payload["type"] = "mutation_rejected"
|
|
1100
|
+
payload["mutationID"] = mutation.mutationID
|
|
1101
|
+
payload["tableID"] = mutation.tableID
|
|
1102
|
+
payload["mutationStatus"] = mutation.status.rawValue
|
|
1103
|
+
payload["rejectionCode"] = mutation.rejectionCode?.rawValue ?? NSNull()
|
|
1104
|
+
case .rebuildRequested(let rebuild):
|
|
1105
|
+
payload["type"] = "rebuild_requested"
|
|
1106
|
+
payload["scopeID"] = rebuild.scopeID
|
|
1107
|
+
payload["rebuildID"] = rebuild.rebuildID
|
|
1108
|
+
case .rebuildCompleted(let rebuild):
|
|
1109
|
+
payload["type"] = "rebuild_completed"
|
|
1110
|
+
payload["scopeID"] = rebuild.scopeID
|
|
1111
|
+
payload["rebuildID"] = rebuild.rebuildID
|
|
1112
|
+
case .failure(let failure):
|
|
1113
|
+
payload["type"] = "failure"
|
|
1114
|
+
payload["failure"] = failurePayload(failure)
|
|
634
1115
|
}
|
|
1116
|
+
return payload
|
|
635
1117
|
}
|
|
636
1118
|
|
|
637
1119
|
private func conflictPayload(_ event: ConflictEvent) -> [String: Any] {
|
|
@@ -643,6 +1125,61 @@ public class SynchroModuleImpl: NSObject {
|
|
|
643
1125
|
]
|
|
644
1126
|
}
|
|
645
1127
|
|
|
1128
|
+
private func encodeRejectedMutations(_ results: [RejectedMutation]) -> String {
|
|
1129
|
+
let payload = results.map { result in
|
|
1130
|
+
[
|
|
1131
|
+
"mutationID": result.mutationID,
|
|
1132
|
+
"table": result.table,
|
|
1133
|
+
"pk": anyCodableMapToJSONObject(result.pk),
|
|
1134
|
+
"status": result.status.rawValue,
|
|
1135
|
+
"code": result.code.rawValue,
|
|
1136
|
+
"message": result.message,
|
|
1137
|
+
"serverRow": anyCodableMapToJSONObject(result.serverRow),
|
|
1138
|
+
"serverVersion": result.serverVersion ?? NSNull()
|
|
1139
|
+
] as [String: Any]
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
guard JSONSerialization.isValidJSONObject(payload),
|
|
1143
|
+
let data = try? JSONSerialization.data(withJSONObject: payload),
|
|
1144
|
+
let json = String(data: data, encoding: .utf8) else {
|
|
1145
|
+
return "[]"
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return json
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
private func anyCodableMapToJSONObject(_ value: [String: AnyCodable]?) -> Any {
|
|
1152
|
+
guard let value else { return NSNull() }
|
|
1153
|
+
return value.mapValues { anyCodableToJSONObject($0.value) }
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
private func anyCodableToJSONObject(_ value: Any?) -> Any {
|
|
1157
|
+
switch value {
|
|
1158
|
+
case nil:
|
|
1159
|
+
return NSNull()
|
|
1160
|
+
case is NSNull:
|
|
1161
|
+
return NSNull()
|
|
1162
|
+
case let value as String:
|
|
1163
|
+
return value
|
|
1164
|
+
case let value as NSNumber:
|
|
1165
|
+
return value
|
|
1166
|
+
case let value as Bool:
|
|
1167
|
+
return value
|
|
1168
|
+
case let value as AnyCodable:
|
|
1169
|
+
return anyCodableToJSONObject(value.value)
|
|
1170
|
+
case let value as [AnyCodable]:
|
|
1171
|
+
return value.map { anyCodableToJSONObject($0.value) }
|
|
1172
|
+
case let value as [String: AnyCodable]:
|
|
1173
|
+
return value.mapValues { anyCodableToJSONObject($0.value) }
|
|
1174
|
+
case let value as [Any]:
|
|
1175
|
+
return value.map { anyCodableToJSONObject($0) }
|
|
1176
|
+
case let value as [String: Any]:
|
|
1177
|
+
return value.mapValues { anyCodableToJSONObject($0) }
|
|
1178
|
+
default:
|
|
1179
|
+
return String(describing: value)
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
646
1183
|
private func encodeAnyCodableMap(_ value: [String: AnyCodable]?) -> String? {
|
|
647
1184
|
guard let value else { return nil }
|
|
648
1185
|
let encoder = JSONEncoder()
|
|
@@ -738,7 +1275,7 @@ public class SynchroModuleImpl: NSObject {
|
|
|
738
1275
|
public func addQueryObserver(
|
|
739
1276
|
_ observerID: String,
|
|
740
1277
|
sql: String,
|
|
741
|
-
|
|
1278
|
+
params: [Any],
|
|
742
1279
|
tables: [String],
|
|
743
1280
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
744
1281
|
reject: @escaping RCTPromiseRejectBlock
|
|
@@ -748,10 +1285,10 @@ public class SynchroModuleImpl: NSObject {
|
|
|
748
1285
|
return
|
|
749
1286
|
}
|
|
750
1287
|
do {
|
|
751
|
-
let
|
|
752
|
-
let cancellable = client.watch(sql, params:
|
|
753
|
-
if let
|
|
754
|
-
self?.emit("onQueryResult", ["observerID": observerID, "
|
|
1288
|
+
let nativeParams = try bridgeParams(params)
|
|
1289
|
+
let cancellable = client.watch(sql, params: nativeParams, tables: tables) { [weak self] rows in
|
|
1290
|
+
if let payload = self?.rowsToBridgeRows(rows) {
|
|
1291
|
+
self?.emit("onQueryResult", ["observerID": observerID, "rows": payload])
|
|
755
1292
|
}
|
|
756
1293
|
}
|
|
757
1294
|
observers[observerID] = cancellable
|
|
@@ -773,35 +1310,65 @@ public class SynchroModuleImpl: NSObject {
|
|
|
773
1310
|
resolve(nil)
|
|
774
1311
|
}
|
|
775
1312
|
|
|
776
|
-
// MARK: - WAL / Sync
|
|
777
|
-
|
|
778
1313
|
@objc
|
|
779
|
-
public func
|
|
780
|
-
_
|
|
781
|
-
resolve: @escaping RCTPromiseResolveBlock,
|
|
1314
|
+
public func start(
|
|
1315
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
782
1316
|
reject: @escaping RCTPromiseRejectBlock
|
|
783
1317
|
) {
|
|
784
1318
|
guard let client = client else {
|
|
785
1319
|
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
786
1320
|
return
|
|
787
1321
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
rejectWithError(reject, error)
|
|
1322
|
+
Task {
|
|
1323
|
+
do {
|
|
1324
|
+
try await client.start()
|
|
1325
|
+
DispatchQueue.main.async {
|
|
1326
|
+
resolve(nil)
|
|
1327
|
+
}
|
|
1328
|
+
} catch {
|
|
1329
|
+
DispatchQueue.main.async {
|
|
1330
|
+
self.rejectWithError(reject, error)
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
800
1333
|
}
|
|
801
1334
|
}
|
|
802
1335
|
|
|
803
1336
|
@objc
|
|
804
|
-
public func
|
|
1337
|
+
public func stop(
|
|
1338
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1339
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1340
|
+
) {
|
|
1341
|
+
guard let client = client else {
|
|
1342
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
1343
|
+
return
|
|
1344
|
+
}
|
|
1345
|
+
Task {
|
|
1346
|
+
await client.stop()
|
|
1347
|
+
DispatchQueue.main.async {
|
|
1348
|
+
resolve(nil)
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
@objc
|
|
1354
|
+
public func enterBackground(
|
|
1355
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1356
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1357
|
+
) {
|
|
1358
|
+
guard let client = client else {
|
|
1359
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
1360
|
+
return
|
|
1361
|
+
}
|
|
1362
|
+
Task {
|
|
1363
|
+
await client.enterBackground()
|
|
1364
|
+
DispatchQueue.main.async {
|
|
1365
|
+
resolve(nil)
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
@objc
|
|
1371
|
+
public func enterForeground(
|
|
805
1372
|
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
806
1373
|
reject: @escaping RCTPromiseRejectBlock
|
|
807
1374
|
) {
|
|
@@ -811,7 +1378,7 @@ public class SynchroModuleImpl: NSObject {
|
|
|
811
1378
|
}
|
|
812
1379
|
Task {
|
|
813
1380
|
do {
|
|
814
|
-
try await client.
|
|
1381
|
+
try await client.enterForeground()
|
|
815
1382
|
DispatchQueue.main.async {
|
|
816
1383
|
resolve(nil)
|
|
817
1384
|
}
|
|
@@ -824,7 +1391,7 @@ public class SynchroModuleImpl: NSObject {
|
|
|
824
1391
|
}
|
|
825
1392
|
|
|
826
1393
|
@objc
|
|
827
|
-
public func
|
|
1394
|
+
public func retryAfterError(
|
|
828
1395
|
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
829
1396
|
reject: @escaping RCTPromiseRejectBlock
|
|
830
1397
|
) {
|
|
@@ -832,9 +1399,40 @@ public class SynchroModuleImpl: NSObject {
|
|
|
832
1399
|
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
833
1400
|
return
|
|
834
1401
|
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1402
|
+
Task {
|
|
1403
|
+
do {
|
|
1404
|
+
try await client.retryAfterError()
|
|
1405
|
+
DispatchQueue.main.async {
|
|
1406
|
+
resolve(nil)
|
|
1407
|
+
}
|
|
1408
|
+
} catch {
|
|
1409
|
+
DispatchQueue.main.async {
|
|
1410
|
+
self.rejectWithError(reject, error)
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
@objc
|
|
1417
|
+
public func resetSchemaAndStart(
|
|
1418
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1419
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1420
|
+
) {
|
|
1421
|
+
guard let client = client else {
|
|
1422
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
1423
|
+
return
|
|
1424
|
+
}
|
|
1425
|
+
Task {
|
|
1426
|
+
do {
|
|
1427
|
+
try await client.resetSchemaAndStart()
|
|
1428
|
+
DispatchQueue.main.async {
|
|
1429
|
+
resolve(nil)
|
|
1430
|
+
}
|
|
1431
|
+
} catch {
|
|
1432
|
+
DispatchQueue.main.async {
|
|
1433
|
+
self.rejectWithError(reject, error)
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
838
1436
|
}
|
|
839
1437
|
}
|
|
840
1438
|
|
|
@@ -877,56 +1475,552 @@ public class SynchroModuleImpl: NSObject {
|
|
|
877
1475
|
}
|
|
878
1476
|
}
|
|
879
1477
|
|
|
1478
|
+
@objc
|
|
1479
|
+
public func getSyncStatus(
|
|
1480
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1481
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1482
|
+
) {
|
|
1483
|
+
guard let client = client else {
|
|
1484
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
1485
|
+
return
|
|
1486
|
+
}
|
|
1487
|
+
do {
|
|
1488
|
+
resolve(try encodeBridgeJSON(statusPayload(client.getSyncStatus())))
|
|
1489
|
+
} catch {
|
|
1490
|
+
rejectWithError(reject, error)
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
@objc
|
|
1495
|
+
public func inspectPendingMutations(
|
|
1496
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1497
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1498
|
+
) {
|
|
1499
|
+
guard let client = client else {
|
|
1500
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
1501
|
+
return
|
|
1502
|
+
}
|
|
1503
|
+
do {
|
|
1504
|
+
let payload = try client.inspectPendingMutations().map(pendingMutationPayload)
|
|
1505
|
+
resolve(try encodeBridgeJSON(payload))
|
|
1506
|
+
} catch {
|
|
1507
|
+
rejectWithError(reject, error)
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
@objc
|
|
1512
|
+
public func inspectRetainedMutations(
|
|
1513
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1514
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1515
|
+
) {
|
|
1516
|
+
guard let client = client else {
|
|
1517
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
1518
|
+
return
|
|
1519
|
+
}
|
|
1520
|
+
do {
|
|
1521
|
+
let payload = try client.inspectRetainedMutations().map(pendingMutationPayload)
|
|
1522
|
+
resolve(try encodeBridgeJSON(payload))
|
|
1523
|
+
} catch {
|
|
1524
|
+
rejectWithError(reject, error)
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
@objc
|
|
1529
|
+
public func inspectRejectedMutations(
|
|
1530
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1531
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1532
|
+
) {
|
|
1533
|
+
guard let client = client else {
|
|
1534
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
1535
|
+
return
|
|
1536
|
+
}
|
|
1537
|
+
do {
|
|
1538
|
+
let payload = try client.inspectRejectedMutations().map(rejectedMutationPayload)
|
|
1539
|
+
resolve(try encodeBridgeJSON(payload))
|
|
1540
|
+
} catch {
|
|
1541
|
+
rejectWithError(reject, error)
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
@objc
|
|
1546
|
+
public func inspectClientState(
|
|
1547
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1548
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1549
|
+
) {
|
|
1550
|
+
guard let client else {
|
|
1551
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
1552
|
+
return
|
|
1553
|
+
}
|
|
1554
|
+
do {
|
|
1555
|
+
let inspection = SynchroInspection(client: client)
|
|
1556
|
+
let capture = try inspection.captureState(maximumRecords: 512)
|
|
1557
|
+
let schema: Any = capture.schema.map { value in
|
|
1558
|
+
["version": value.version, "hash": value.hash]
|
|
1559
|
+
} ?? NSNull()
|
|
1560
|
+
let scopeStates = capture.scopeStates.map { value in
|
|
1561
|
+
[
|
|
1562
|
+
"scope_id": value.scopeID,
|
|
1563
|
+
"cursor": value.cursor ?? NSNull(),
|
|
1564
|
+
"checksum": value.checksum ?? NSNull(),
|
|
1565
|
+
"local_checksum": value.localChecksum,
|
|
1566
|
+
"generation": value.generation,
|
|
1567
|
+
] as [String: Any]
|
|
1568
|
+
}
|
|
1569
|
+
let scopeRows = capture.scopeRows.map { value in
|
|
1570
|
+
[
|
|
1571
|
+
"scope_id": value.scopeID,
|
|
1572
|
+
"table_name": value.tableName,
|
|
1573
|
+
"record_id": value.recordID,
|
|
1574
|
+
"checksum": value.checksum,
|
|
1575
|
+
"generation": value.generation,
|
|
1576
|
+
] as [String: Any]
|
|
1577
|
+
}
|
|
1578
|
+
let attempts = capture.rebuildAttempts.map { value in
|
|
1579
|
+
[
|
|
1580
|
+
"scope_id": value.scopeID,
|
|
1581
|
+
"rebuild_id": value.rebuildID,
|
|
1582
|
+
"client_generation": value.clientGeneration,
|
|
1583
|
+
"schema_version": value.schemaVersion,
|
|
1584
|
+
"schema_hash": value.schemaHash,
|
|
1585
|
+
"generation": value.generation,
|
|
1586
|
+
"cursor": value.cursor ?? NSNull(),
|
|
1587
|
+
"page_limit": value.pageLimit,
|
|
1588
|
+
] as [String: Any]
|
|
1589
|
+
}
|
|
1590
|
+
resolve(try encodeBridgeJSON([
|
|
1591
|
+
"schema": schema,
|
|
1592
|
+
"scope_states": scopeStates,
|
|
1593
|
+
"scope_rows": scopeRows,
|
|
1594
|
+
"rebuild_attempts": attempts,
|
|
1595
|
+
"application_row_count": capture.applicationRowCount,
|
|
1596
|
+
"mutation_ledger_count": capture.mutationLedgerCount,
|
|
1597
|
+
"mutation_outcome_count": capture.mutationOutcomeCount,
|
|
1598
|
+
"sealed_batch_count": capture.sealedBatchCount,
|
|
1599
|
+
"rejected_mutation_count": capture.rejectedMutationCount,
|
|
1600
|
+
"scope_state_count": capture.scopeStateCount,
|
|
1601
|
+
"scope_row_count": capture.scopeRowCount,
|
|
1602
|
+
"provenance_count": capture.provenanceCount,
|
|
1603
|
+
"row_metadata_count": capture.rowMetadataCount,
|
|
1604
|
+
"rebuild_attempt_count": capture.rebuildAttemptCount,
|
|
1605
|
+
"rebuild_receipt_count": capture.rebuildReceiptCount,
|
|
1606
|
+
"scope_states_truncated": capture.scopeStatesTruncated,
|
|
1607
|
+
"scope_rows_truncated": capture.scopeRowsTruncated,
|
|
1608
|
+
"rebuild_attempts_truncated": capture.rebuildAttemptsTruncated,
|
|
1609
|
+
"rebuild_receipts_truncated": capture.rebuildReceiptsTruncated,
|
|
1610
|
+
"row_metadata_truncated": capture.rowMetadataTruncated,
|
|
1611
|
+
"capture_overflowed": capture.overflowed,
|
|
1612
|
+
"provenance_maintenance_work_cursor": String(
|
|
1613
|
+
capture.provenanceMaintenanceWorkCursor
|
|
1614
|
+
),
|
|
1615
|
+
]))
|
|
1616
|
+
} catch {
|
|
1617
|
+
rejectWithError(reject, error)
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
@objc
|
|
1622
|
+
public func inspectDurableState(
|
|
1623
|
+
_ tableName: String,
|
|
1624
|
+
recordID: String,
|
|
1625
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
1626
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1627
|
+
) {
|
|
1628
|
+
guard let client else {
|
|
1629
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
1630
|
+
return
|
|
1631
|
+
}
|
|
1632
|
+
do {
|
|
1633
|
+
let inspection = SynchroInspection(client: client)
|
|
1634
|
+
let metadata: Any = try inspection.rowMetadata(
|
|
1635
|
+
tableName: tableName,
|
|
1636
|
+
recordID: recordID
|
|
1637
|
+
).map { value in
|
|
1638
|
+
[
|
|
1639
|
+
"table_name": value.tableName,
|
|
1640
|
+
"record_id": value.recordID,
|
|
1641
|
+
"server_version": value.serverVersion,
|
|
1642
|
+
"row_checksum": value.rowChecksum ?? NSNull(),
|
|
1643
|
+
] as [String: Any]
|
|
1644
|
+
} ?? NSNull()
|
|
1645
|
+
let receipts = try inspection.rebuildReceipts().map { value in
|
|
1646
|
+
[
|
|
1647
|
+
"rebuild_id_fingerprint": value.rebuildIDFingerprint,
|
|
1648
|
+
"page_count": value.pageCount,
|
|
1649
|
+
"returned_record_count": value.returnedRecordCount,
|
|
1650
|
+
"request_chain_expected": value.requestChainExpected,
|
|
1651
|
+
"request_chain_observed": value.requestChainObserved,
|
|
1652
|
+
"record_identities_hex": value.recordIdentitiesHex,
|
|
1653
|
+
"received_row_checksums": value.receivedRowChecksums,
|
|
1654
|
+
"computed_row_checksums": value.computedRowChecksums,
|
|
1655
|
+
"computed_scope_checksum": value.computedScopeChecksum ?? NSNull(),
|
|
1656
|
+
"final_scope_checksum": value.finalScopeChecksum ?? NSNull(),
|
|
1657
|
+
"stored_scope_checksum": value.storedScopeChecksum ?? NSNull(),
|
|
1658
|
+
"local_scope_checksum": value.localScopeChecksum ?? NSNull(),
|
|
1659
|
+
] as [String: Any]
|
|
1660
|
+
}
|
|
1661
|
+
resolve(try encodeBridgeJSON([
|
|
1662
|
+
"row_metadata": metadata,
|
|
1663
|
+
"rebuild_receipts": receipts,
|
|
1664
|
+
]))
|
|
1665
|
+
} catch {
|
|
1666
|
+
rejectWithError(reject, error)
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
@objc
|
|
1671
|
+
public func inspectTransportObservations(
|
|
1672
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1673
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1674
|
+
) {
|
|
1675
|
+
guard let transportObservations else {
|
|
1676
|
+
reject("NOT_CONNECTED", "Transport observation is not configured", nil)
|
|
1677
|
+
return
|
|
1678
|
+
}
|
|
1679
|
+
do {
|
|
1680
|
+
let data = try JSONEncoder().encode(transportObservations.snapshot())
|
|
1681
|
+
guard let value = String(data: data, encoding: .utf8) else {
|
|
1682
|
+
throw SynchroError.invalidResponse(message: "transport observation encoding failed")
|
|
1683
|
+
}
|
|
1684
|
+
resolve(value)
|
|
1685
|
+
} catch {
|
|
1686
|
+
rejectWithError(reject, error)
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
@objc
|
|
1691
|
+
public func armTransportPause(
|
|
1692
|
+
_ operationClass: String,
|
|
1693
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
1694
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1695
|
+
) {
|
|
1696
|
+
guard let collector = transportObservations,
|
|
1697
|
+
let operation = TransportOperationClass(rawValue: operationClass) else {
|
|
1698
|
+
reject("INVALID_CONFIG", "Transport pause operation is invalid", nil)
|
|
1699
|
+
return
|
|
1700
|
+
}
|
|
1701
|
+
do {
|
|
1702
|
+
try collector.armPause(for: operation)
|
|
1703
|
+
resolve(nil)
|
|
1704
|
+
} catch {
|
|
1705
|
+
rejectWithError(reject, error)
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
@objc
|
|
1710
|
+
public func awaitTransportPause(
|
|
1711
|
+
_ operationClass: String,
|
|
1712
|
+
timeoutMs: Double,
|
|
1713
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
1714
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1715
|
+
) {
|
|
1716
|
+
guard let collector = transportObservations,
|
|
1717
|
+
let operation = TransportOperationClass(rawValue: operationClass),
|
|
1718
|
+
timeoutMs.isFinite, timeoutMs >= 1, timeoutMs <= 60_000 else {
|
|
1719
|
+
reject("INVALID_CONFIG", "Transport pause wait is invalid", nil)
|
|
1720
|
+
return
|
|
1721
|
+
}
|
|
1722
|
+
Task {
|
|
1723
|
+
do {
|
|
1724
|
+
try await collector.awaitPause(for: operation, timeout: timeoutMs / 1_000)
|
|
1725
|
+
DispatchQueue.main.async { resolve(nil) }
|
|
1726
|
+
} catch {
|
|
1727
|
+
DispatchQueue.main.async { self.rejectWithError(reject, error) }
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
@objc
|
|
1733
|
+
public func resumeTransportPause(
|
|
1734
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1735
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1736
|
+
) {
|
|
1737
|
+
guard let collector = transportObservations else {
|
|
1738
|
+
reject("NOT_CONNECTED", "Transport observation is not configured", nil)
|
|
1739
|
+
return
|
|
1740
|
+
}
|
|
1741
|
+
do {
|
|
1742
|
+
try collector.resumePause()
|
|
1743
|
+
resolve(nil)
|
|
1744
|
+
} catch {
|
|
1745
|
+
rejectWithError(reject, error)
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
@objc
|
|
1750
|
+
public func getProcessIdentity(
|
|
1751
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1752
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1753
|
+
) {
|
|
1754
|
+
resolve("ios-app:\(getpid())")
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
@objc
|
|
1758
|
+
public func clearRejectedMutations(
|
|
1759
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
1760
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
1761
|
+
) {
|
|
1762
|
+
guard let client = client else {
|
|
1763
|
+
reject("NOT_CONNECTED", "Client not initialized", nil)
|
|
1764
|
+
return
|
|
1765
|
+
}
|
|
1766
|
+
do {
|
|
1767
|
+
try client.clearRejectedMutations()
|
|
1768
|
+
resolve(nil)
|
|
1769
|
+
} catch {
|
|
1770
|
+
rejectWithError(reject, error)
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
880
1774
|
// MARK: - Helpers
|
|
881
1775
|
|
|
882
|
-
private func
|
|
1776
|
+
private func encodeBridgeJSON(_ value: Any) throws -> String {
|
|
1777
|
+
guard JSONSerialization.isValidJSONObject(value) else {
|
|
1778
|
+
throw SynchroError.invalidResponse(message: "bridge JSON value is invalid")
|
|
1779
|
+
}
|
|
1780
|
+
let data = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
|
|
1781
|
+
guard let json = String(data: data, encoding: .utf8) else {
|
|
1782
|
+
throw SynchroError.invalidResponse(message: "bridge JSON encoding failed")
|
|
1783
|
+
}
|
|
1784
|
+
return json
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
private func pendingMutationPayload(_ mutation: PendingMutationInspection) -> [String: Any] {
|
|
1788
|
+
[
|
|
1789
|
+
"mutationID": mutation.mutationID,
|
|
1790
|
+
"localOrder": mutation.localOrder,
|
|
1791
|
+
"tableID": mutation.tableID,
|
|
1792
|
+
"tableName": mutation.tableName,
|
|
1793
|
+
"recordID": mutation.recordID,
|
|
1794
|
+
"primaryKeyFieldID": mutation.primaryKeyFieldID,
|
|
1795
|
+
"primaryKeyLogicalType": mutation.primaryKeyLogicalType,
|
|
1796
|
+
"operation": mutation.operation.rawValue,
|
|
1797
|
+
"authoredSchema": [
|
|
1798
|
+
"version": mutation.authoredSchema.version,
|
|
1799
|
+
"hash": mutation.authoredSchema.hash
|
|
1800
|
+
],
|
|
1801
|
+
"baseVersion": mutation.baseVersion ?? NSNull(),
|
|
1802
|
+
"clientVersion": mutation.clientVersion,
|
|
1803
|
+
"status": mutation.status.rawValue,
|
|
1804
|
+
"sourceKind": mutation.sourceKind,
|
|
1805
|
+
"dependsOnMutationID": mutation.dependsOnMutationID ?? NSNull(),
|
|
1806
|
+
"normalizedMutationID": mutation.normalizedMutationID ?? NSNull(),
|
|
1807
|
+
"sealedBatchID": mutation.sealedBatchID ?? NSNull(),
|
|
1808
|
+
"sealedOrdinal": mutation.sealedOrdinal ?? NSNull(),
|
|
1809
|
+
"authoredFields": mutation.authoredFields.map { field in
|
|
1810
|
+
[
|
|
1811
|
+
"fieldID": field.fieldID,
|
|
1812
|
+
"logicalType": field.logicalType,
|
|
1813
|
+
"value": anyCodableToJSONObject(field.value.value)
|
|
1814
|
+
] as [String: Any]
|
|
1815
|
+
}
|
|
1816
|
+
]
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
private func rejectedMutationPayload(_ mutation: RejectedMutationInspection) -> [String: Any] {
|
|
1820
|
+
[
|
|
1821
|
+
"mutationID": mutation.mutationID,
|
|
1822
|
+
"tableName": mutation.tableName,
|
|
1823
|
+
"recordID": mutation.recordID,
|
|
1824
|
+
"status": mutation.status.rawValue,
|
|
1825
|
+
"code": mutation.code.rawValue,
|
|
1826
|
+
"message": mutation.message ?? NSNull(),
|
|
1827
|
+
"serverRowJSON": mutation.serverRowJSON ?? NSNull(),
|
|
1828
|
+
"serverVersion": mutation.serverVersion ?? NSNull(),
|
|
1829
|
+
"mutationJSON": mutation.mutationJSON,
|
|
1830
|
+
"rejectionJSON": mutation.rejectionJSON,
|
|
1831
|
+
"createdAt": mutation.createdAt,
|
|
1832
|
+
"updatedAt": mutation.updatedAt
|
|
1833
|
+
]
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
private enum BridgeParamError: LocalizedError {
|
|
1837
|
+
case invalidNumber(index: Int)
|
|
1838
|
+
case invalidTag(index: Int, reason: String)
|
|
1839
|
+
case unsupported(index: Int, type: String)
|
|
1840
|
+
|
|
1841
|
+
var errorDescription: String? {
|
|
1842
|
+
switch self {
|
|
1843
|
+
case .invalidNumber(let index):
|
|
1844
|
+
return "Invalid SQL bind number at index \(index)"
|
|
1845
|
+
case .invalidTag(let index, let reason):
|
|
1846
|
+
return "Invalid SQL bind tag at index \(index): \(reason)"
|
|
1847
|
+
case .unsupported(let index, let type):
|
|
1848
|
+
return "Unsupported SQL bind value at index \(index): \(type)"
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
private static let maxSafeInteger: Int64 = 9_007_199_254_740_991
|
|
1854
|
+
private static let minSafeInteger: Int64 = -9_007_199_254_740_991
|
|
1855
|
+
|
|
1856
|
+
private func canonicalBase64URL(_ data: Data) -> String {
|
|
1857
|
+
data.base64EncodedString()
|
|
1858
|
+
.replacingOccurrences(of: "+", with: "-")
|
|
1859
|
+
.replacingOccurrences(of: "/", with: "_")
|
|
1860
|
+
.replacingOccurrences(of: "=", with: "")
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
private func decodeCanonicalBase64URL(_ value: String, index: Int) throws -> Data {
|
|
1864
|
+
guard value.utf8.allSatisfy({ byte in
|
|
1865
|
+
(byte >= 65 && byte <= 90) ||
|
|
1866
|
+
(byte >= 97 && byte <= 122) ||
|
|
1867
|
+
(byte >= 48 && byte <= 57) ||
|
|
1868
|
+
byte == 45 || byte == 95
|
|
1869
|
+
}) else {
|
|
1870
|
+
throw BridgeParamError.invalidTag(index: index, reason: "bytes must use base64url")
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
let remainder = value.utf8.count % 4
|
|
1874
|
+
guard remainder != 1 else {
|
|
1875
|
+
throw BridgeParamError.invalidTag(index: index, reason: "bytes have invalid base64url length")
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
let translated = value
|
|
1879
|
+
.replacingOccurrences(of: "-", with: "+")
|
|
1880
|
+
.replacingOccurrences(of: "_", with: "/")
|
|
1881
|
+
let padded = translated + String(repeating: "=", count: (4 - remainder) % 4)
|
|
1882
|
+
guard let data = Data(base64Encoded: padded), canonicalBase64URL(data) == value else {
|
|
1883
|
+
throw BridgeParamError.invalidTag(index: index, reason: "bytes are not canonical base64url")
|
|
1884
|
+
}
|
|
1885
|
+
return data
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
private func decodeCanonicalInt64(_ value: String, index: Int) throws -> Int64 {
|
|
1889
|
+
guard value == "0" || value.range(of: "^-?[1-9][0-9]*$", options: .regularExpression) != nil,
|
|
1890
|
+
let parsed = Int64(value), String(parsed) == value else {
|
|
1891
|
+
throw BridgeParamError.invalidTag(index: index, reason: "int64 is not canonical or is out of range")
|
|
1892
|
+
}
|
|
1893
|
+
return parsed
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
private func bridgeTagToParam(_ value: Any, index: Int) throws -> (any DatabaseValueConvertible)? {
|
|
1897
|
+
guard let tag = value as? NSDictionary,
|
|
1898
|
+
tag.count == 2,
|
|
1899
|
+
let type = tag["type"] as? String else {
|
|
1900
|
+
throw BridgeParamError.invalidTag(index: index, reason: "tag must contain exactly type and payload fields")
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
switch type {
|
|
1904
|
+
case "bytes":
|
|
1905
|
+
guard let base64 = tag["base64"] as? String, tag["value"] == nil else {
|
|
1906
|
+
throw BridgeParamError.invalidTag(index: index, reason: "bytes tag must contain base64")
|
|
1907
|
+
}
|
|
1908
|
+
return try decodeCanonicalBase64URL(base64, index: index)
|
|
1909
|
+
case "int64":
|
|
1910
|
+
guard let value = tag["value"] as? String, tag["base64"] == nil else {
|
|
1911
|
+
throw BridgeParamError.invalidTag(index: index, reason: "int64 tag must contain value")
|
|
1912
|
+
}
|
|
1913
|
+
return try decodeCanonicalInt64(value, index: index)
|
|
1914
|
+
default:
|
|
1915
|
+
throw BridgeParamError.invalidTag(index: index, reason: "unknown tag type")
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
private func bridgeValueToParam(_ value: Any, index: Int) throws -> (any DatabaseValueConvertible)? {
|
|
883
1920
|
switch value {
|
|
884
1921
|
case is NSNull:
|
|
885
1922
|
return nil
|
|
1923
|
+
case let boolVal as Bool:
|
|
1924
|
+
return boolVal ? 1 : 0
|
|
886
1925
|
case let intVal as Int:
|
|
1926
|
+
guard Int64(intVal) >= Self.minSafeInteger && Int64(intVal) <= Self.maxSafeInteger else {
|
|
1927
|
+
throw BridgeParamError.invalidNumber(index: index)
|
|
1928
|
+
}
|
|
887
1929
|
return intVal
|
|
1930
|
+
case let int64Val as Int64:
|
|
1931
|
+
guard int64Val >= Self.minSafeInteger && int64Val <= Self.maxSafeInteger else {
|
|
1932
|
+
throw BridgeParamError.invalidNumber(index: index)
|
|
1933
|
+
}
|
|
1934
|
+
return int64Val
|
|
888
1935
|
case let doubleVal as Double:
|
|
1936
|
+
guard doubleVal.isFinite else {
|
|
1937
|
+
throw BridgeParamError.invalidNumber(index: index)
|
|
1938
|
+
}
|
|
1939
|
+
if doubleVal.rounded(.towardZero) == doubleVal,
|
|
1940
|
+
(doubleVal < Double(Self.minSafeInteger) || doubleVal > Double(Self.maxSafeInteger)) {
|
|
1941
|
+
throw BridgeParamError.invalidNumber(index: index)
|
|
1942
|
+
}
|
|
889
1943
|
return doubleVal
|
|
890
1944
|
case let stringVal as String:
|
|
891
1945
|
return stringVal
|
|
892
|
-
case let
|
|
893
|
-
return
|
|
1946
|
+
case let dictionary as NSDictionary:
|
|
1947
|
+
return try bridgeTagToParam(dictionary, index: index)
|
|
1948
|
+
case let numberVal as NSNumber:
|
|
1949
|
+
if CFGetTypeID(numberVal) == CFBooleanGetTypeID() {
|
|
1950
|
+
return numberVal.boolValue ? 1 : 0
|
|
1951
|
+
}
|
|
1952
|
+
let doubleValue = numberVal.doubleValue
|
|
1953
|
+
guard doubleValue.isFinite else {
|
|
1954
|
+
throw BridgeParamError.invalidNumber(index: index)
|
|
1955
|
+
}
|
|
1956
|
+
if doubleValue.isFinite,
|
|
1957
|
+
doubleValue.rounded(.towardZero) == doubleValue,
|
|
1958
|
+
doubleValue >= Double(Self.minSafeInteger),
|
|
1959
|
+
doubleValue <= Double(Self.maxSafeInteger) {
|
|
1960
|
+
return Int64(doubleValue)
|
|
1961
|
+
}
|
|
1962
|
+
if doubleValue.rounded(.towardZero) == doubleValue {
|
|
1963
|
+
throw BridgeParamError.invalidNumber(index: index)
|
|
1964
|
+
}
|
|
1965
|
+
return doubleValue
|
|
894
1966
|
default:
|
|
895
|
-
|
|
1967
|
+
throw BridgeParamError.unsupported(index: index, type: String(describing: type(of: value)))
|
|
896
1968
|
}
|
|
897
1969
|
}
|
|
898
1970
|
|
|
899
|
-
private func
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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] ?? []
|
|
1971
|
+
private func bridgeParams(_ values: [Any]) throws -> [(any DatabaseValueConvertible)?] {
|
|
1972
|
+
try values.enumerated().map { index, value in
|
|
1973
|
+
try bridgeValueToParam(value, index: index)
|
|
1974
|
+
}
|
|
908
1975
|
}
|
|
909
1976
|
|
|
910
1977
|
private func parseColumns(_ json: String) throws -> [ColumnDef] {
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1978
|
+
struct Column: Decodable {
|
|
1979
|
+
let name: String
|
|
1980
|
+
let type: String
|
|
1981
|
+
let nullable: Bool?
|
|
1982
|
+
let defaultValue: String?
|
|
1983
|
+
let primaryKey: Bool?
|
|
1984
|
+
}
|
|
1985
|
+
let columns: [Column]
|
|
1986
|
+
do {
|
|
1987
|
+
columns = try JSONDecoder().decode([Column].self, from: Data(json.utf8))
|
|
1988
|
+
} catch is DecodingError {
|
|
1989
|
+
throw NSError(
|
|
1990
|
+
domain: "SynchroModule",
|
|
1991
|
+
code: 0,
|
|
1992
|
+
userInfo: [NSLocalizedDescriptionKey: "Invalid schema columns"]
|
|
1993
|
+
)
|
|
1994
|
+
}
|
|
1995
|
+
return columns.map { item in
|
|
914
1996
|
ColumnDef(
|
|
915
|
-
name: item
|
|
916
|
-
type: item
|
|
917
|
-
nullable: item
|
|
918
|
-
defaultValue: item
|
|
919
|
-
primaryKey: item
|
|
1997
|
+
name: item.name,
|
|
1998
|
+
type: item.type,
|
|
1999
|
+
nullable: item.nullable ?? true,
|
|
2000
|
+
defaultValue: item.defaultValue,
|
|
2001
|
+
primaryKey: item.primaryKey ?? false
|
|
920
2002
|
)
|
|
921
2003
|
}
|
|
922
2004
|
}
|
|
923
2005
|
|
|
924
2006
|
private func parseTableOptions(_ json: String) throws -> Synchro.TableOptions {
|
|
925
|
-
|
|
926
|
-
|
|
2007
|
+
struct Options: Decodable {
|
|
2008
|
+
let ifNotExists: Bool?
|
|
2009
|
+
let withoutRowid: Bool?
|
|
2010
|
+
}
|
|
2011
|
+
let options: Options
|
|
2012
|
+
do {
|
|
2013
|
+
options = try JSONDecoder().decode(Options.self, from: Data(json.utf8))
|
|
2014
|
+
} catch is DecodingError {
|
|
2015
|
+
throw NSError(
|
|
2016
|
+
domain: "SynchroModule",
|
|
2017
|
+
code: 0,
|
|
2018
|
+
userInfo: [NSLocalizedDescriptionKey: "Invalid table options"]
|
|
2019
|
+
)
|
|
2020
|
+
}
|
|
927
2021
|
return Synchro.TableOptions(
|
|
928
|
-
ifNotExists:
|
|
929
|
-
withoutRowid:
|
|
2022
|
+
ifNotExists: options.ifNotExists ?? true,
|
|
2023
|
+
withoutRowid: options.withoutRowid ?? false
|
|
930
2024
|
)
|
|
931
2025
|
}
|
|
932
2026
|
|
|
@@ -935,36 +2029,29 @@ public class SynchroModuleImpl: NSObject {
|
|
|
935
2029
|
case .null:
|
|
936
2030
|
return NSNull()
|
|
937
2031
|
case .int64(let v):
|
|
938
|
-
|
|
2032
|
+
if v >= Self.minSafeInteger && v <= Self.maxSafeInteger {
|
|
2033
|
+
return NSNumber(value: v)
|
|
2034
|
+
}
|
|
2035
|
+
return ["type": "int64", "value": String(v)]
|
|
939
2036
|
case .double(let v):
|
|
940
2037
|
return NSNumber(value: v)
|
|
941
2038
|
case .string(let v):
|
|
942
2039
|
return v
|
|
943
2040
|
case .blob(let v):
|
|
944
|
-
return v
|
|
2041
|
+
return ["type": "bytes", "base64": canonicalBase64URL(v)]
|
|
945
2042
|
}
|
|
946
2043
|
}
|
|
947
2044
|
|
|
948
|
-
private func
|
|
2045
|
+
private func rowToBridgeRow(_ row: Row) -> [String: Any] {
|
|
949
2046
|
var dict: [String: Any] = [:]
|
|
950
2047
|
for column in row.columnNames {
|
|
951
2048
|
let dbValue: DatabaseValue = row[column]
|
|
952
2049
|
dict[column] = databaseValueToFoundation(dbValue)
|
|
953
2050
|
}
|
|
954
|
-
|
|
955
|
-
return String(data: data, encoding: .utf8)!
|
|
2051
|
+
return dict
|
|
956
2052
|
}
|
|
957
2053
|
|
|
958
|
-
private func
|
|
959
|
-
|
|
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)!
|
|
2054
|
+
private func rowsToBridgeRows(_ rows: [Row]) -> [[String: Any]] {
|
|
2055
|
+
rows.map { rowToBridgeRow($0) }
|
|
969
2056
|
}
|
|
970
2057
|
}
|