@trainstar/synchro-react-native 0.4.0-rc.2 → 0.4.0-rc.4

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.
@@ -1,5 +1,6 @@
1
1
  import Foundation
2
2
  import Darwin
3
+ import os
3
4
  import React
4
5
  import GRDB
5
6
  @_spi(Inspection) import Synchro
@@ -12,166 +13,220 @@ import GRDB
12
13
 
13
14
  // MARK: - Transaction Session
14
15
 
15
- class TransactionSession {
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>] = []
23
- private var beginCompletion: ((Result<String, Error>) -> Void)?
16
+ final class TransactionSession: Sendable {
17
+ private struct State: Sendable {
18
+ var operations: [TransactionOp] = []
19
+ var closed = false
20
+ var abortError: Error?
21
+ var finalSettlement: TransactionFinalSettlement?
22
+ var finished = false
23
+ var finishWaiters: [CheckedContinuation<Void, Never>] = []
24
+ var beginSettlement: ReactPromiseSettlement?
25
+ }
26
+
27
+ private struct BeginAction: Sendable {
28
+ let settlement: ReactPromiseSettlement?
29
+ let error: Error?
30
+ }
31
+
32
+ private enum NextAction: Sendable {
33
+ case operation(TransactionOp)
34
+ case closed
35
+ case aborted(Error)
36
+ case wait
37
+ }
38
+
39
+ private let state: OSAllocatedUnfairLock<State>
40
+ private let wake = DispatchSemaphore(value: 0)
24
41
  let isWrite: Bool
25
42
 
26
- init(isWrite: Bool, beginCompletion: @escaping (Result<String, Error>) -> Void) {
43
+ init(isWrite: Bool, beginSettlement: ReactPromiseSettlement) {
27
44
  self.isWrite = isWrite
28
- self.beginCompletion = beginCompletion
45
+ state = OSAllocatedUnfairLock(initialState: State(beginSettlement: beginSettlement))
29
46
  }
30
47
 
31
48
  /// Settles begin when the native transaction callback starts. A close that
32
49
  /// aborted the session before the callback started rejects begin instead.
33
50
  func acceptBegin(_ txID: String) throws {
34
- condition.lock()
35
- let completion = beginCompletion
36
- beginCompletion = nil
37
- let error = abortError
38
- condition.unlock()
39
- if let error {
40
- completion?(.failure(error))
51
+ let action = state.withLock { state -> BeginAction in
52
+ let settlement = state.beginSettlement
53
+ state.beginSettlement = nil
54
+ return BeginAction(settlement: settlement, error: state.abortError)
55
+ }
56
+ if let error = action.error {
57
+ if let settlement = action.settlement {
58
+ SynchroModuleImpl.rejectWithError(settlement, error)
59
+ }
41
60
  throw error
42
61
  }
43
- completion?(.success(txID))
62
+ action.settlement?.resolve(.value(.string(txID)))
44
63
  }
45
64
 
46
65
  /// Rejects begin when the transaction fails before its callback starts.
47
66
  func rejectBegin(_ error: Error) {
48
- condition.lock()
49
- let completion = beginCompletion
50
- beginCompletion = nil
51
- condition.unlock()
52
- completion?(.failure(error))
67
+ let settlement = state.withLock { state in
68
+ defer { state.beginSettlement = nil }
69
+ return state.beginSettlement
70
+ }
71
+ if let settlement {
72
+ SynchroModuleImpl.rejectWithError(settlement, error)
73
+ }
53
74
  }
54
75
 
55
76
  func enqueue(_ op: TransactionOp) -> Bool {
56
- condition.lock()
57
- defer { condition.unlock() }
58
- guard !closed else {
59
- return false
77
+ let accepted = state.withLock { state -> Bool in
78
+ guard !state.closed else { return false }
79
+ state.operations.append(op)
80
+ return true
60
81
  }
61
- operations.append(op)
62
- condition.signal()
63
- return true
82
+ if accepted {
83
+ wake.signal()
84
+ }
85
+ return accepted
64
86
  }
65
87
 
66
88
  func nextOperation(timeout: TimeInterval) throws -> TransactionOp? {
67
- condition.lock()
68
- defer { condition.unlock() }
69
-
70
89
  let deadline = Date().addingTimeInterval(timeout)
71
- while operations.isEmpty && !closed {
72
- if !condition.wait(until: deadline) {
73
- closed = true
74
- throw TransactionTimeoutError()
90
+ while true {
91
+ let action = state.withLock { state -> NextAction in
92
+ if let error = state.abortError {
93
+ return .aborted(error)
94
+ }
95
+ if !state.operations.isEmpty {
96
+ return .operation(state.operations.removeFirst())
97
+ }
98
+ return state.closed ? .closed : .wait
75
99
  }
76
- }
77
100
 
78
- if let abortError {
79
- throw abortError
80
- }
81
- guard !operations.isEmpty else {
82
- return nil
101
+ switch action {
102
+ case .operation(let operation):
103
+ return operation
104
+ case .closed:
105
+ return nil
106
+ case .aborted(let error):
107
+ throw error
108
+ case .wait:
109
+ let remaining = deadline.timeIntervalSinceNow
110
+ let timedOut = remaining <= 0
111
+ || wake.wait(timeout: .now() + remaining) == .timedOut
112
+ guard timedOut else { continue }
113
+ let didTimeout = state.withLock { state -> Bool in
114
+ guard state.operations.isEmpty, !state.closed else { return false }
115
+ state.closed = true
116
+ return true
117
+ }
118
+ if didTimeout {
119
+ throw TransactionTimeoutError()
120
+ }
121
+ }
83
122
  }
84
- return operations.removeFirst()
85
123
  }
86
124
 
87
125
  @discardableResult
88
126
  func close() -> Bool {
89
- condition.lock()
90
- guard !closed else {
91
- condition.unlock()
92
- return false
93
- }
94
- closed = true
95
- let pending = operations
96
- operations.removeAll()
97
- condition.broadcast()
98
- condition.unlock()
127
+ let pending = state.withLock { state -> [TransactionOp]? in
128
+ guard !state.closed else { return nil }
129
+ state.closed = true
130
+ let pending = state.operations
131
+ state.operations.removeAll()
132
+ return pending
133
+ }
134
+ guard let pending else { return false }
135
+ wake.signal()
99
136
  let error = TransactionAbortedError(message: "Transaction already completed")
100
137
  pending.forEach { $0.fail(error) }
101
138
  return true
102
139
  }
103
140
 
104
141
  func abort(_ error: Error) {
105
- condition.lock()
106
- guard !closed else {
107
- condition.unlock()
108
- return
109
- }
110
- closed = true
111
- abortError = error
112
- let pending = operations
113
- operations.removeAll()
114
- condition.broadcast()
115
- condition.unlock()
142
+ let pending = state.withLock { state -> [TransactionOp]? in
143
+ guard !state.closed else { return nil }
144
+ state.closed = true
145
+ state.abortError = error
146
+ let pending = state.operations
147
+ state.operations.removeAll()
148
+ return pending
149
+ }
150
+ guard let pending else { return }
151
+ wake.signal()
116
152
  pending.forEach { $0.fail(error) }
117
153
  }
118
154
 
119
155
  func currentAbortError() -> Error? {
120
- condition.lock()
121
- defer { condition.unlock() }
122
- return abortError
156
+ state.withLock { $0.abortError }
123
157
  }
124
158
 
125
- func setFinalCompletion(_ completion: @escaping (Result<Void, Error>) -> Void) {
126
- condition.lock()
127
- finalCompletion = completion
128
- condition.unlock()
159
+ func setFinalSettlement(_ settlement: TransactionFinalSettlement) {
160
+ state.withLock { $0.finalSettlement = settlement }
129
161
  }
130
162
 
131
163
  func completeFinal(_ result: Result<Void, Error>) {
132
- condition.lock()
133
- let completion = finalCompletion
134
- condition.unlock()
135
- completion?(result)
164
+ let settlement = state.withLock { $0.finalSettlement }
165
+ settlement?.complete(result)
136
166
  }
137
167
 
138
168
  func markFinished() {
139
- condition.lock()
140
- finished = true
141
- let waiters = finishWaiters
142
- finishWaiters.removeAll()
143
- condition.unlock()
169
+ let waiters = state.withLock { state -> [CheckedContinuation<Void, Never>] in
170
+ state.finished = true
171
+ let waiters = state.finishWaiters
172
+ state.finishWaiters.removeAll()
173
+ return waiters
174
+ }
144
175
  waiters.forEach { $0.resume() }
145
176
  }
146
177
 
147
178
  func waitUntilFinished() async {
148
179
  await withCheckedContinuation { continuation in
149
- condition.lock()
150
- if finished {
151
- condition.unlock()
180
+ let resumeNow = state.withLock { state -> Bool in
181
+ if state.finished {
182
+ return true
183
+ }
184
+ state.finishWaiters.append(continuation)
185
+ return false
186
+ }
187
+ if resumeNow {
152
188
  continuation.resume()
153
- } else {
154
- finishWaiters.append(continuation)
155
- condition.unlock()
156
189
  }
157
190
  }
158
191
  }
159
192
  }
160
193
 
161
- enum TransactionOp {
162
- case query(sql: String, params: [Any], completion: (Result<[[String: Any]], Error>) -> Void)
163
- case queryOne(sql: String, params: [Any], completion: (Result<[String: Any]?, Error>) -> Void)
164
- case execute(sql: String, params: [Any], completion: (Result<[String: Any], Error>) -> Void)
165
- case commit(completion: (Result<Void, Error>) -> Void)
166
- case rollback(completion: (Result<Void, Error>) -> Void)
194
+ enum TransactionFinalSettlement: Sendable {
195
+ case commit(ReactPromiseSettlement)
196
+ case rollback(ReactPromiseSettlement)
197
+
198
+ func complete(_ result: Result<Void, Error>) {
199
+ switch (self, result) {
200
+ case (.commit(let settlement), .success), (.rollback(let settlement), .success):
201
+ settlement.resolve(.void)
202
+ case (.commit(let settlement), .failure(let error as SynchroError)):
203
+ SynchroModuleImpl.rejectWithError(settlement, error)
204
+ case (.commit(let settlement), .failure(let error)),
205
+ (.rollback(let settlement), .failure(let error)):
206
+ settlement.reject("DATABASE_ERROR", error.localizedDescription, error)
207
+ }
208
+ }
209
+ }
210
+
211
+ enum TransactionOp: Sendable {
212
+ case query(sql: String, params: Result<[BridgeValue], Error>, settlement: ReactPromiseSettlement)
213
+ case queryOne(sql: String, params: Result<[BridgeValue], Error>, settlement: ReactPromiseSettlement)
214
+ case execute(sql: String, params: Result<[BridgeValue], Error>, settlement: ReactPromiseSettlement)
215
+ case commit(settlement: ReactPromiseSettlement)
216
+ case rollback(settlement: ReactPromiseSettlement)
167
217
 
168
218
  func fail(_ error: Error) {
219
+ settlement.reject("DATABASE_ERROR", error.localizedDescription, error)
220
+ }
221
+
222
+ private var settlement: ReactPromiseSettlement {
169
223
  switch self {
170
- case .query(_, _, let completion): completion(.failure(error))
171
- case .queryOne(_, _, let completion): completion(.failure(error))
172
- case .execute(_, _, let completion): completion(.failure(error))
173
- case .commit(let completion): completion(.failure(error))
174
- case .rollback(let completion): completion(.failure(error))
224
+ case .query(_, _, let settlement),
225
+ .queryOne(_, _, let settlement),
226
+ .execute(_, _, let settlement),
227
+ .commit(let settlement),
228
+ .rollback(let settlement):
229
+ return settlement
175
230
  }
176
231
  }
177
232
  }
@@ -179,6 +234,7 @@ enum TransactionOp {
179
234
  private struct TransactionTimeoutError: Error {}
180
235
  private struct TransactionRollbackError: Error {}
181
236
  private struct DatabaseAlreadyExistsError: Error {}
237
+ private struct InvalidAuthoredWriteOperationError: Error {}
182
238
  private struct TransactionAbortedError: LocalizedError {
183
239
  let message: String
184
240
 
@@ -215,50 +271,84 @@ private actor LifecycleMutex {
215
271
 
216
272
  @objc(SynchroModuleImpl)
217
273
  public class SynchroModuleImpl: NSObject {
218
- @objc public weak var eventDelegate: SynchroEventEmitting?
274
+ private struct RuntimeState: Sendable {
275
+ var client: SynchroClient?
276
+ var sessions: [String: TransactionSession] = [:]
277
+ var acceptingTransactions = false
278
+ var observers: [String: any Synchro.Cancellable] = [:]
279
+ var statusSubscription: (any Synchro.Cancellable)?
280
+ var syncEventSubscription: (any Synchro.Cancellable)?
281
+ var conflictSubscription: (any Synchro.Cancellable)?
282
+ var cachedBackoff: SyncBackoffEvent?
283
+ var cachedFailure: SyncFailure?
284
+ var transportObservations: TransportObservationCollector?
285
+ }
219
286
 
220
- private(set) var client: SynchroClient?
221
- private let lifecycleMutex = LifecycleMutex()
222
- var sessions: [String: TransactionSession] = [:]
223
- let sessionsLock = NSLock()
224
- private var acceptingTransactions = false
225
- private var observers: [String: any Synchro.Cancellable] = [:]
226
- private var statusSubscription: (any Synchro.Cancellable)?
227
- private var syncEventSubscription: (any Synchro.Cancellable)?
228
- private var conflictSubscription: (any Synchro.Cancellable)?
229
- private let statusDetailsLock = NSLock()
230
- private var cachedBackoff: SyncBackoffEvent?
231
- private var cachedFailure: SyncFailure?
232
- private var transportObservations: TransportObservationCollector?
287
+ private struct DetachedState: Sendable {
288
+ let sessions: [TransactionSession]
289
+ let observers: [any Synchro.Cancellable]
290
+ let subscriptions: [any Synchro.Cancellable]
291
+ let transportObservations: TransportObservationCollector?
292
+ }
233
293
 
234
- private var pendingAuthContinuations: [String: CheckedContinuation<String, Error>] = [:]
235
- private let authLock = NSLock()
294
+ private let eventTarget = ReactEventTarget()
295
+ private let runtime = OSAllocatedUnfairLock(initialState: RuntimeState())
296
+ private let authRequests = AuthRequestStore()
236
297
 
237
- private func emit(_ name: String, _ body: [String: Any]) {
238
- DispatchQueue.main.async { [weak self] in
239
- self?.eventDelegate?.emitEvent(name, body: body as NSDictionary)
240
- }
298
+ @objc public var eventDelegate: SynchroEventEmitting? {
299
+ get { eventTarget.currentDelegate }
300
+ set { eventTarget.setDelegate(newValue) }
301
+ }
302
+
303
+ var client: SynchroClient? {
304
+ runtime.withLock { $0.client }
305
+ }
306
+
307
+ var activeTransactionIDs: [String] {
308
+ runtime.withLock { Array($0.sessions.keys) }
241
309
  }
310
+ private let lifecycleMutex = LifecycleMutex()
311
+ private let databaseBridgeQueue = DispatchQueue(label: "com.trainstar.synchro.react-native.database")
242
312
 
243
313
  @objc public func rejectWithError(_ reject: @escaping RCTPromiseRejectBlock, _ error: Error) {
314
+ Self.rejectWithError(reject, error)
315
+ }
316
+
317
+ fileprivate static func rejectWithError(_ settlement: ReactPromiseSettlement, _ error: Error) {
318
+ let rejection = rejection(for: error)
319
+ settlement.reject(rejection.code, rejection.message, rejection.error)
320
+ }
321
+
322
+ private static func rejectWithError(_ reject: RCTPromiseRejectBlock, _ error: Error) {
323
+ let rejection = rejection(for: error)
324
+ reject(rejection.code, rejection.message, rejection.error)
325
+ }
326
+
327
+ private static func rejection(for error: Error) -> (code: String?, message: String?, error: Error?) {
244
328
  if let synchroError = error as? SynchroError {
245
329
  let (code, userInfo) = mapSynchroError(synchroError)
246
330
  let base: [String: Any] = ["message": synchroError.localizedDescription]
247
- reject(code, synchroError.localizedDescription, NSError(
248
- domain: "SynchroModule",
249
- code: 0,
250
- userInfo: base.merging(userInfo) { _, new in new }
251
- ))
331
+ return (
332
+ code,
333
+ synchroError.localizedDescription,
334
+ NSError(
335
+ domain: "SynchroModule",
336
+ code: 0,
337
+ userInfo: base.merging(userInfo) { _, new in new }
338
+ )
339
+ )
252
340
  } else if error is TransactionTimeoutError {
253
- reject("TRANSACTION_TIMEOUT", "Transaction timed out due to inactivity", nil)
341
+ return ("TRANSACTION_TIMEOUT", "Transaction timed out due to inactivity", nil)
254
342
  } else if error is TransactionAbortedError {
255
- reject("NOT_CONNECTED", "Client closed during transaction", error)
343
+ return ("NOT_CONNECTED", "Client closed during transaction", error)
344
+ } else if error is InvalidAuthoredWriteOperationError {
345
+ return ("INVALID_CONFIG", "Invalid authored write operation", nil)
256
346
  } else {
257
- reject("UNKNOWN", error.localizedDescription, error)
347
+ return ("UNKNOWN", error.localizedDescription, error)
258
348
  }
259
349
  }
260
350
 
261
- private func mapSynchroError(_ error: SynchroError) -> (String, [String: Any]) {
351
+ private static func mapSynchroError(_ error: SynchroError) -> (String, [String: Any]) {
262
352
  switch error {
263
353
  case .notConnected:
264
354
  return ("NOT_CONNECTED", [:])
@@ -316,16 +406,17 @@ public class SynchroModuleImpl: NSObject {
316
406
  resolve: @escaping RCTPromiseResolveBlock,
317
407
  reject: @escaping RCTPromiseRejectBlock
318
408
  ) {
409
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
319
410
  guard let dbPath = config["dbPath"] as? String,
320
411
  let serverURL = config["serverURL"] as? String,
321
412
  let clientID = config["clientID"] as? String,
322
413
  let platform = config["platform"] as? String,
323
- let appVersion = config["appVersion"] as? String,
324
- let url = URL(string: serverURL) else {
325
- reject("INVALID_CONFIG", "Missing required config fields", nil)
414
+ let appVersion = config["appVersion"] as? String,
415
+ let url = URL(string: serverURL) else {
416
+ settlement.reject("INVALID_CONFIG", "Missing required config fields", nil)
326
417
  return
327
418
  }
328
- let resolvedDbPath = resolveDatabasePath(dbPath)
419
+ let resolvedDbPath = Self.resolveDatabasePath(dbPath)
329
420
 
330
421
  let syncInterval = config["syncInterval"] as? Double ?? 30
331
422
  let pushDebounce = config["pushDebounce"] as? Double ?? 0.5
@@ -336,7 +427,7 @@ public class SynchroModuleImpl: NSObject {
336
427
  let configuredTransportCapacity = config["transportObservationCapacity"] as? Int ?? 0
337
428
  let requireNewDatabase = config["requireNewDatabase"] as? Bool ?? false
338
429
  if configuredTransportCapacity < 0 || configuredTransportCapacity > 512 {
339
- reject("INVALID_CONFIG", "Transport observation capacity is invalid", nil)
430
+ settlement.reject("INVALID_CONFIG", "Transport observation capacity is invalid", nil)
340
431
  return
341
432
  }
342
433
  let transportCapacity = configuredTransportCapacity == 0 ? nil : configuredTransportCapacity
@@ -362,16 +453,24 @@ public class SynchroModuleImpl: NSObject {
362
453
  }
363
454
 
364
455
  let transportObservations = transportCapacity.map(TransportObservationCollector.init(capacity:))
456
+ let authRequests = authRequests
457
+ let eventTarget = eventTarget
365
458
  let synchroConfig = SynchroConfig(
366
459
  dbPath: resolvedDbPath,
367
460
  serverURL: url,
368
- authProvider: { [weak self] in
461
+ authProvider: {
369
462
  try await withCheckedThrowingContinuation { continuation in
370
463
  let requestID = UUID().uuidString
371
- self?.authLock.lock()
372
- self?.pendingAuthContinuations[requestID] = continuation
373
- self?.authLock.unlock()
374
- self?.emit("onAuthRequest", ["requestID": requestID])
464
+ guard authRequests.insert(continuation, for: requestID) else {
465
+ continuation.resume(throwing: TransactionAbortedError(
466
+ message: "Client closed during authentication"
467
+ ))
468
+ return
469
+ }
470
+ eventTarget.emit(
471
+ "onAuthRequest",
472
+ body: .object(["requestID": .string(requestID)])
473
+ )
375
474
  }
376
475
  },
377
476
  clientID: clientID,
@@ -385,33 +484,43 @@ public class SynchroModuleImpl: NSObject {
385
484
  seedDatabasePath: resolvedSeedPath,
386
485
  transportObservationCollector: transportObservations
387
486
  )
388
- Task { [weak self] in
389
- guard let self else { return }
487
+ let lifecycleMutex = lifecycleMutex
488
+ let runtime = runtime
489
+ let databaseBridgeQueue = databaseBridgeQueue
490
+ Task {
390
491
  do {
391
- try await self.withLifecycleLock {
392
- await self.clearRuntimeState()
393
- try await self.client?.close()
492
+ try await Self.withLifecycleLock(lifecycleMutex) {
493
+ await Self.clearRuntimeState(
494
+ runtime: runtime,
495
+ authRequests: authRequests,
496
+ databaseBridgeQueue: databaseBridgeQueue
497
+ )
498
+ try await runtime.withLock { $0.client }?.close()
394
499
  if requireNewDatabase && FileManager.default.fileExists(atPath: resolvedDbPath) {
395
500
  throw DatabaseAlreadyExistsError()
396
501
  }
397
502
  let client = try SynchroClient(config: synchroConfig)
398
- self.setClient(client, acceptingTransactions: true)
399
- self.transportObservations = transportObservations
400
- self.wireClientEvents(client)
503
+ authRequests.activate()
504
+ runtime.withLock {
505
+ $0.client = client
506
+ $0.acceptingTransactions = true
507
+ $0.transportObservations = transportObservations
508
+ }
509
+ Self.wireClientEvents(client, runtime: runtime, eventTarget: eventTarget)
401
510
  }
402
511
  DispatchQueue.main.async {
403
- resolve(nil)
512
+ settlement.resolve(.void)
404
513
  }
405
514
  } catch is DatabaseAlreadyExistsError {
406
515
  DispatchQueue.main.async {
407
- reject("INVALID_CONFIG", "Database already exists", nil)
516
+ settlement.reject("INVALID_CONFIG", "Database already exists", nil)
408
517
  }
409
518
  } catch {
410
519
  DispatchQueue.main.async {
411
520
  if resolvedSeedPath != nil {
412
- reject("INVALID_SEED", "Seed database failed validation", error)
521
+ settlement.reject("INVALID_SEED", "Seed database failed validation", error)
413
522
  } else {
414
- self.rejectWithError(reject, error)
523
+ Self.rejectWithError(settlement, error)
415
524
  }
416
525
  }
417
526
  }
@@ -420,18 +529,15 @@ public class SynchroModuleImpl: NSObject {
420
529
 
421
530
  @objc
422
531
  public func resolveAuthRequest(_ requestID: String, token: String) {
423
- authLock.lock()
424
- let continuation = pendingAuthContinuations.removeValue(forKey: requestID)
425
- authLock.unlock()
426
- continuation?.resume(returning: token)
532
+ authRequests.resolve(requestID, token: token)
427
533
  }
428
534
 
429
535
  @objc
430
536
  public func rejectAuthRequest(_ requestID: String, error: String) {
431
- authLock.lock()
432
- let continuation = pendingAuthContinuations.removeValue(forKey: requestID)
433
- authLock.unlock()
434
- continuation?.resume(throwing: NSError(domain: "Auth", code: 0, userInfo: [NSLocalizedDescriptionKey: error]))
537
+ authRequests.reject(
538
+ requestID,
539
+ error: NSError(domain: "Auth", code: 0, userInfo: [NSLocalizedDescriptionKey: error])
540
+ )
435
541
  }
436
542
 
437
543
  @objc
@@ -439,20 +545,31 @@ public class SynchroModuleImpl: NSObject {
439
545
  _ resolve: @escaping RCTPromiseResolveBlock,
440
546
  reject: @escaping RCTPromiseRejectBlock
441
547
  ) {
442
- Task { [weak self] in
443
- guard let self else { return }
548
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
549
+ let lifecycleMutex = lifecycleMutex
550
+ let runtime = runtime
551
+ let authRequests = authRequests
552
+ let databaseBridgeQueue = databaseBridgeQueue
553
+ Task {
444
554
  do {
445
- try await self.withLifecycleLock {
446
- await self.clearRuntimeState()
447
- try await self.client?.close()
448
- self.setClient(nil, acceptingTransactions: false)
555
+ try await Self.withLifecycleLock(lifecycleMutex) {
556
+ await Self.clearRuntimeState(
557
+ runtime: runtime,
558
+ authRequests: authRequests,
559
+ databaseBridgeQueue: databaseBridgeQueue
560
+ )
561
+ try await runtime.withLock { $0.client }?.close()
562
+ runtime.withLock {
563
+ $0.client = nil
564
+ $0.acceptingTransactions = false
565
+ }
449
566
  }
450
567
  DispatchQueue.main.async {
451
- resolve(nil)
568
+ settlement.resolve(.void)
452
569
  }
453
570
  } catch {
454
571
  DispatchQueue.main.async {
455
- self.rejectWithError(reject, error)
572
+ Self.rejectWithError(settlement, error)
456
573
  }
457
574
  }
458
575
  }
@@ -463,14 +580,14 @@ public class SynchroModuleImpl: NSObject {
463
580
  _ resolve: @escaping RCTPromiseResolveBlock,
464
581
  reject: @escaping RCTPromiseRejectBlock
465
582
  ) {
466
- guard let client = client else {
583
+ guard let client = runtime.withLock({ $0.client }) else {
467
584
  reject("NOT_CONNECTED", "Client not initialized", nil)
468
585
  return
469
586
  }
470
587
  resolve(client.path)
471
588
  }
472
589
 
473
- private func resolveDatabasePath(_ dbPath: String) -> String {
590
+ private static func resolveDatabasePath(_ dbPath: String) -> String {
474
591
  if (dbPath as NSString).isAbsolutePath {
475
592
  return dbPath
476
593
  }
@@ -487,16 +604,14 @@ public class SynchroModuleImpl: NSObject {
487
604
  resolve: @escaping RCTPromiseResolveBlock,
488
605
  reject: @escaping RCTPromiseRejectBlock
489
606
  ) {
490
- guard let client = client else {
491
- reject("NOT_CONNECTED", "Client not initialized", nil)
492
- return
493
- }
494
- do {
495
- let rows = try client.query(sql, params: try bridgeParams(params))
496
- resolve(rowsToBridgeRows(rows))
497
- } catch {
498
- rejectWithError(reject, error)
499
- }
607
+ let capturedParams = Result { try Self.captureSQLParams(params) }
608
+ submitDatabaseOperation(
609
+ resolve: resolve,
610
+ reject: reject,
611
+ operation: { client in
612
+ .value(try Self.rowsToBridgeValue(client.query(sql, params: Self.bridgeParams(capturedParams.get()))))
613
+ }
614
+ )
500
615
  }
501
616
 
502
617
  @objc
@@ -506,20 +621,17 @@ public class SynchroModuleImpl: NSObject {
506
621
  resolve: @escaping RCTPromiseResolveBlock,
507
622
  reject: @escaping RCTPromiseRejectBlock
508
623
  ) {
509
- guard let client = client else {
510
- reject("NOT_CONNECTED", "Client not initialized", nil)
511
- return
512
- }
513
- do {
514
- let row = try client.queryOne(sql, params: try bridgeParams(params))
515
- if let row = row {
516
- resolve(rowToBridgeRow(row))
517
- } else {
518
- resolve(NSNull())
624
+ let capturedParams = Result { try Self.captureSQLParams(params) }
625
+ submitDatabaseOperation(
626
+ resolve: resolve,
627
+ reject: reject,
628
+ operation: { client in
629
+ if let row = try client.queryOne(sql, params: Self.bridgeParams(capturedParams.get())) {
630
+ return .value(Self.rowToBridgeValue(row))
631
+ }
632
+ return .value(.null)
519
633
  }
520
- } catch {
521
- rejectWithError(reject, error)
522
- }
634
+ )
523
635
  }
524
636
 
525
637
  @objc
@@ -529,16 +641,15 @@ public class SynchroModuleImpl: NSObject {
529
641
  resolve: @escaping RCTPromiseResolveBlock,
530
642
  reject: @escaping RCTPromiseRejectBlock
531
643
  ) {
532
- guard let client = client else {
533
- reject("NOT_CONNECTED", "Client not initialized", nil)
534
- return
535
- }
536
- do {
537
- let result = try client.execute(sql, params: try bridgeParams(params))
538
- resolve(["rowsAffected": result.rowsAffected])
539
- } catch {
540
- rejectWithError(reject, error)
541
- }
644
+ let capturedParams = Result { try Self.captureSQLParams(params) }
645
+ submitDatabaseOperation(
646
+ resolve: resolve,
647
+ reject: reject,
648
+ operation: { client in
649
+ let result = try client.execute(sql, params: Self.bridgeParams(capturedParams.get()))
650
+ return .value(.object(["rowsAffected": .integer(Int64(result.rowsAffected))]))
651
+ }
652
+ )
542
653
  }
543
654
 
544
655
  @objc
@@ -551,27 +662,25 @@ public class SynchroModuleImpl: NSObject {
551
662
  resolve: @escaping RCTPromiseResolveBlock,
552
663
  reject: @escaping RCTPromiseRejectBlock
553
664
  ) {
554
- guard let client = client else {
555
- reject("NOT_CONNECTED", "Client not initialized", nil)
556
- return
557
- }
558
- guard let authoredOperation = Synchro.Operation(rawValue: operation) else {
559
- reject("INVALID_CONFIG", "Invalid authored write operation", nil)
560
- return
561
- }
562
- do {
563
- let params = try bridgeParams(values)
564
- let result = try client.authoredWriteTransaction(
565
- tableName: tableName,
566
- operation: authoredOperation,
567
- columnNames: columnNames
568
- ) { transaction in
569
- try transaction.execute(sql, params: params)
665
+ let capturedValues = Result { try Self.captureSQLParams(values) }
666
+ submitDatabaseOperation(
667
+ resolve: resolve,
668
+ reject: reject,
669
+ operation: { client in
670
+ guard let authoredOperation = Synchro.Operation(rawValue: operation) else {
671
+ throw InvalidAuthoredWriteOperationError()
672
+ }
673
+ let params = try Self.bridgeParams(capturedValues.get())
674
+ let result = try client.authoredWriteTransaction(
675
+ tableName: tableName,
676
+ operation: authoredOperation,
677
+ columnNames: columnNames
678
+ ) { transaction in
679
+ try transaction.execute(sql, params: params)
680
+ }
681
+ return .value(.object(["rowsAffected": .integer(Int64(result.rowsAffected))]))
570
682
  }
571
- resolve(["rowsAffected": result.rowsAffected])
572
- } catch {
573
- rejectWithError(reject, error)
574
- }
683
+ )
575
684
  }
576
685
 
577
686
  @objc
@@ -580,16 +689,15 @@ public class SynchroModuleImpl: NSObject {
580
689
  resolve: @escaping RCTPromiseResolveBlock,
581
690
  reject: @escaping RCTPromiseRejectBlock
582
691
  ) {
583
- guard let client = client else {
584
- reject("NOT_CONNECTED", "Client not initialized", nil)
585
- return
586
- }
587
- do {
588
- let total = try client.executeBatch(try bridgeStatements(statements))
589
- resolve(["totalRowsAffected": total])
590
- } catch {
591
- rejectWithError(reject, error)
592
- }
692
+ let capturedStatements = Result { try Self.captureBridgeStatements(statements) }
693
+ submitDatabaseOperation(
694
+ resolve: resolve,
695
+ reject: reject,
696
+ operation: { client in
697
+ let total = try client.executeBatch(try Self.bridgeStatements(capturedStatements.get()))
698
+ return .value(.object(["totalRowsAffected": .integer(Int64(total))]))
699
+ }
700
+ )
593
701
  }
594
702
 
595
703
  // MARK: - Transactions
@@ -618,7 +726,7 @@ public class SynchroModuleImpl: NSObject {
618
726
  beginTransaction(isWrite: false, resolve: resolve, reject: reject)
619
727
  }
620
728
 
621
- private func runTransactionLoop(
729
+ private static func runTransactionLoop(
622
730
  session: TransactionSession,
623
731
  txID: String,
624
732
  query: (String, [(any DatabaseValueConvertible)?]) throws -> [Row],
@@ -633,60 +741,60 @@ public class SynchroModuleImpl: NSObject {
633
741
  }
634
742
 
635
743
  switch op {
636
- case .query(let sql, let params, let completion):
744
+ case .query(let sql, let params, let settlement):
637
745
  do {
638
- let rows = try query(sql, try bridgeParams(params))
746
+ let rows = try query(sql, try bridgeParams(params.get()))
639
747
  if let abortError = session.currentAbortError() {
640
748
  throw abortError
641
749
  }
642
- completion(.success(rowsToBridgeRows(rows)))
750
+ settlement.resolve(.value(rowsToBridgeValue(rows)))
643
751
  } catch {
644
- completion(.failure(error))
752
+ settlement.reject("DATABASE_ERROR", error.localizedDescription, error)
645
753
  }
646
754
 
647
- case .queryOne(let sql, let params, let completion):
755
+ case .queryOne(let sql, let params, let settlement):
648
756
  do {
649
- let row = try queryOne(sql, try bridgeParams(params))
757
+ let row = try queryOne(sql, try bridgeParams(params.get()))
650
758
  if let abortError = session.currentAbortError() {
651
759
  throw abortError
652
760
  }
653
- completion(.success(row.map(rowToBridgeRow)))
761
+ settlement.resolve(.value(row.map(rowToBridgeValue) ?? .null))
654
762
  } catch {
655
- completion(.failure(error))
763
+ settlement.reject("DATABASE_ERROR", error.localizedDescription, error)
656
764
  }
657
765
 
658
- case .execute(let sql, let params, let completion):
766
+ case .execute(let sql, let params, let settlement):
659
767
  do {
660
768
  guard let execute else {
661
769
  throw TransactionReadOnlyError()
662
770
  }
663
- let rowsAffected = try execute(sql, try bridgeParams(params))
771
+ let rowsAffected = try execute(sql, try bridgeParams(params.get()))
664
772
  if let abortError = session.currentAbortError() {
665
773
  throw abortError
666
774
  }
667
- completion(.success(["rowsAffected": rowsAffected]))
775
+ settlement.resolve(.value(.object(["rowsAffected": .integer(Int64(rowsAffected))])))
668
776
  } catch {
669
- completion(.failure(error))
777
+ settlement.reject("DATABASE_ERROR", error.localizedDescription, error)
670
778
  }
671
779
 
672
- case .commit(let completion):
780
+ case .commit(let settlement):
673
781
  guard session.close() else {
674
782
  let error = session.currentAbortError()
675
783
  ?? TransactionAbortedError(message: "Transaction already completed")
676
- completion(.failure(error))
784
+ settlement.reject("DATABASE_ERROR", error.localizedDescription, error)
677
785
  throw error
678
786
  }
679
- session.setFinalCompletion(completion)
787
+ session.setFinalSettlement(.commit(settlement))
680
788
  return
681
789
 
682
- case .rollback(let completion):
790
+ case .rollback(let settlement):
683
791
  guard session.close() else {
684
792
  let error = session.currentAbortError()
685
793
  ?? TransactionAbortedError(message: "Transaction already completed")
686
- completion(.failure(error))
794
+ settlement.reject("DATABASE_ERROR", error.localizedDescription, error)
687
795
  throw error
688
796
  }
689
- session.setFinalCompletion(completion)
797
+ session.setFinalSettlement(.rollback(settlement))
690
798
  throw TransactionRollbackError()
691
799
  }
692
800
  }
@@ -698,36 +806,31 @@ public class SynchroModuleImpl: NSObject {
698
806
  resolve: @escaping RCTPromiseResolveBlock,
699
807
  reject: @escaping RCTPromiseRejectBlock
700
808
  ) {
701
- sessionsLock.lock()
702
- guard acceptingTransactions, let client = client else {
703
- sessionsLock.unlock()
704
- reject("NOT_CONNECTED", "Client not initialized", nil)
705
- return
706
- }
707
-
809
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
708
810
  let txID = UUID().uuidString
709
- let session = TransactionSession(isWrite: isWrite) { result in
710
- switch result {
711
- case .success(let acceptedID): resolve(acceptedID)
712
- case .failure(let error): self.rejectWithError(reject, error)
713
- }
811
+ let session = TransactionSession(isWrite: isWrite, beginSettlement: settlement)
812
+ let admittedClient = runtime.withLock { state -> SynchroClient? in
813
+ guard state.acceptingTransactions, let client = state.client else { return nil }
814
+ state.sessions[txID] = session
815
+ return client
816
+ }
817
+ guard let client = admittedClient else {
818
+ settlement.reject("NOT_CONNECTED", "Client not initialized", nil)
819
+ return
714
820
  }
715
- sessions[txID] = session
716
- sessionsLock.unlock()
717
821
 
822
+ let runtime = runtime
718
823
  DispatchQueue.global(qos: .userInitiated).async {
719
824
  defer {
720
825
  session.close()
721
- self.sessionsLock.lock()
722
- self.sessions.removeValue(forKey: txID)
723
- self.sessionsLock.unlock()
826
+ _ = runtime.withLock { $0.sessions.removeValue(forKey: txID) }
724
827
  session.markFinished()
725
828
  }
726
829
  do {
727
830
  let finalResult: Result<Void, Error>
728
831
  if isWrite {
729
832
  let body: (ApplicationTransaction) throws -> Void = { transaction in
730
- try self.runTransactionLoop(
833
+ try Self.runTransactionLoop(
731
834
  session: session,
732
835
  txID: txID,
733
836
  query: { sql, params in
@@ -748,7 +851,7 @@ public class SynchroModuleImpl: NSObject {
748
851
  }
749
852
  } else {
750
853
  try client.readTransaction { db in
751
- try self.runTransactionLoop(
854
+ try Self.runTransactionLoop(
752
855
  session: session,
753
856
  txID: txID,
754
857
  query: { sql, params in
@@ -797,18 +900,15 @@ public class SynchroModuleImpl: NSObject {
797
900
  resolve: @escaping RCTPromiseResolveBlock,
798
901
  reject: @escaping RCTPromiseRejectBlock
799
902
  ) {
903
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
800
904
  guard let session = getSession(txID) else {
801
- reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
905
+ settlement.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
802
906
  return
803
907
  }
804
- let op = TransactionOp.query(sql: sql, params: params) { result in
805
- switch result {
806
- case .success(let rows): resolve(rows)
807
- case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
808
- }
809
- }
908
+ let capturedParams = Result { try Self.captureSQLParams(params) }
909
+ let op = TransactionOp.query(sql: sql, params: capturedParams, settlement: settlement)
810
910
  guard session.enqueue(op) else {
811
- reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
911
+ settlement.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
812
912
  return
813
913
  }
814
914
  }
@@ -821,18 +921,15 @@ public class SynchroModuleImpl: NSObject {
821
921
  resolve: @escaping RCTPromiseResolveBlock,
822
922
  reject: @escaping RCTPromiseRejectBlock
823
923
  ) {
924
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
824
925
  guard let session = getSession(txID) else {
825
- reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
926
+ settlement.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
826
927
  return
827
928
  }
828
- let op = TransactionOp.queryOne(sql: sql, params: params) { result in
829
- switch result {
830
- case .success(let row): resolve(row ?? NSNull())
831
- case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
832
- }
833
- }
929
+ let capturedParams = Result { try Self.captureSQLParams(params) }
930
+ let op = TransactionOp.queryOne(sql: sql, params: capturedParams, settlement: settlement)
834
931
  guard session.enqueue(op) else {
835
- reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
932
+ settlement.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
836
933
  return
837
934
  }
838
935
  }
@@ -845,22 +942,19 @@ public class SynchroModuleImpl: NSObject {
845
942
  resolve: @escaping RCTPromiseResolveBlock,
846
943
  reject: @escaping RCTPromiseRejectBlock
847
944
  ) {
945
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
848
946
  guard let session = getSession(txID) else {
849
- reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
947
+ settlement.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
850
948
  return
851
949
  }
852
950
  guard session.isWrite else {
853
- reject("DATABASE_ERROR", "Read transactions cannot execute SQL", nil)
951
+ settlement.reject("DATABASE_ERROR", "Read transactions cannot execute SQL", nil)
854
952
  return
855
953
  }
856
- let op = TransactionOp.execute(sql: sql, params: params) { result in
857
- switch result {
858
- case .success(let dict): resolve(dict)
859
- case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
860
- }
861
- }
954
+ let capturedParams = Result { try Self.captureSQLParams(params) }
955
+ let op = TransactionOp.execute(sql: sql, params: capturedParams, settlement: settlement)
862
956
  guard session.enqueue(op) else {
863
- reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
957
+ settlement.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
864
958
  return
865
959
  }
866
960
  }
@@ -871,19 +965,14 @@ public class SynchroModuleImpl: NSObject {
871
965
  resolve: @escaping RCTPromiseResolveBlock,
872
966
  reject: @escaping RCTPromiseRejectBlock
873
967
  ) {
968
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
874
969
  guard let session = getSession(txID) else {
875
- reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
970
+ settlement.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
876
971
  return
877
972
  }
878
- let op = TransactionOp.commit { result in
879
- switch result {
880
- case .success: resolve(nil)
881
- case .failure(let error as SynchroError): self.rejectWithError(reject, error)
882
- case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
883
- }
884
- }
973
+ let op = TransactionOp.commit(settlement: settlement)
885
974
  guard session.enqueue(op) else {
886
- reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
975
+ settlement.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired", nil)
887
976
  return
888
977
  }
889
978
  }
@@ -894,128 +983,161 @@ public class SynchroModuleImpl: NSObject {
894
983
  resolve: @escaping RCTPromiseResolveBlock,
895
984
  reject: @escaping RCTPromiseRejectBlock
896
985
  ) {
986
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
897
987
  guard let session = getSession(txID) else {
898
- resolve(nil)
988
+ settlement.resolve(.void)
899
989
  return
900
990
  }
901
- let op = TransactionOp.rollback { result in
902
- switch result {
903
- case .success: resolve(nil)
904
- case .failure(let error): reject("DATABASE_ERROR", error.localizedDescription, error)
905
- }
906
- }
991
+ let op = TransactionOp.rollback(settlement: settlement)
907
992
  guard session.enqueue(op) else {
908
- resolve(nil)
993
+ settlement.resolve(.void)
909
994
  return
910
995
  }
911
996
  }
912
997
 
913
998
  private func getSession(_ txID: String) -> TransactionSession? {
914
- sessionsLock.lock()
915
- defer { sessionsLock.unlock() }
916
- return sessions[txID]
999
+ runtime.withLock { $0.sessions[txID] }
917
1000
  }
918
1001
 
919
- private func wireClientEvents(_ client: SynchroClient) {
920
- statusSubscription?.cancel()
921
- syncEventSubscription?.cancel()
922
- conflictSubscription?.cancel()
923
- statusDetailsLock.lock()
924
- cachedBackoff = nil
925
- cachedFailure = nil
926
- statusDetailsLock.unlock()
1002
+ private static func wireClientEvents(
1003
+ _ client: SynchroClient,
1004
+ runtime: OSAllocatedUnfairLock<RuntimeState>,
1005
+ eventTarget: ReactEventTarget
1006
+ ) {
1007
+ runtime.withLock {
1008
+ $0.cachedBackoff = nil
1009
+ $0.cachedFailure = nil
1010
+ }
927
1011
 
928
- statusSubscription = client.onStatusChange { [weak self] status in
1012
+ let statusSubscription = client.onStatusChange { status in
929
1013
  guard status != .backoff, status != .error else { return }
930
- self?.emit("onStatusChange", self?.statusPayload(status) ?? [:])
1014
+ eventTarget.emit(
1015
+ "onStatusChange",
1016
+ body: bridgeValue(statusPayload(status, client: client, runtime: runtime))
1017
+ )
931
1018
  }
932
1019
 
933
- syncEventSubscription = client.onSyncEvent { [weak self] event in
934
- guard let self else { return }
935
- self.recordStatusDetails(event)
936
- self.emit("onSyncEvent", self.syncEventPayload(event))
1020
+ let syncEventSubscription = client.onSyncEvent { event in
1021
+ recordStatusDetails(event, runtime: runtime)
1022
+ eventTarget.emit("onSyncEvent", body: bridgeValue(syncEventPayload(event)))
937
1023
  switch event {
938
1024
  case .backoff, .failure:
939
- self.emit(
1025
+ eventTarget.emit(
940
1026
  "onStatusChange",
941
- self.statusPayload(client.getSyncStatus())
1027
+ body: bridgeValue(statusPayload(client.getSyncStatus(), client: client, runtime: runtime))
942
1028
  )
943
1029
  default:
944
1030
  break
945
1031
  }
946
1032
  }
947
1033
 
948
- conflictSubscription = client.onConflict { [weak self] event in
949
- self?.emit("onConflict", self?.conflictPayload(event) ?? [:])
1034
+ let conflictSubscription = client.onConflict { event in
1035
+ eventTarget.emit("onConflict", body: bridgeValue(conflictPayload(event)))
950
1036
  }
951
- }
952
1037
 
953
- private func setClient(_ client: SynchroClient?, acceptingTransactions: Bool) {
954
- sessionsLock.lock()
955
- self.client = client
956
- self.acceptingTransactions = acceptingTransactions
957
- sessionsLock.unlock()
1038
+ runtime.withLock {
1039
+ $0.statusSubscription = statusSubscription
1040
+ $0.syncEventSubscription = syncEventSubscription
1041
+ $0.conflictSubscription = conflictSubscription
1042
+ }
958
1043
  }
959
1044
 
960
- private func withLifecycleLock(_ operation: () async throws -> Void) async throws {
961
- await lifecycleMutex.lock()
1045
+ private static func withLifecycleLock(
1046
+ _ mutex: LifecycleMutex,
1047
+ operation: @escaping @Sendable () async throws -> Void
1048
+ ) async throws {
1049
+ await mutex.lock()
962
1050
  do {
963
1051
  try await operation()
964
- await lifecycleMutex.unlock()
1052
+ await mutex.unlock()
965
1053
  } catch {
966
- await lifecycleMutex.unlock()
1054
+ await mutex.unlock()
967
1055
  throw error
968
1056
  }
969
1057
  }
970
1058
 
971
- private func detachRuntimeState() -> [TransactionSession] {
972
- statusSubscription?.cancel()
973
- syncEventSubscription?.cancel()
974
- conflictSubscription?.cancel()
975
- statusSubscription = nil
976
- syncEventSubscription = nil
977
- conflictSubscription = nil
978
- transportObservations?.cancelPauseBarrier()
979
- transportObservations = nil
980
- statusDetailsLock.lock()
981
- cachedBackoff = nil
982
- cachedFailure = nil
983
- statusDetailsLock.unlock()
984
-
985
- observers.values.forEach { $0.cancel() }
986
- observers.removeAll()
987
-
988
- sessionsLock.lock()
989
- acceptingTransactions = false
990
- let activeSessions = Array(sessions.values)
991
- sessions.removeAll()
992
- sessionsLock.unlock()
993
-
994
- authLock.lock()
995
- let authContinuations = pendingAuthContinuations
996
- pendingAuthContinuations.removeAll()
997
- authLock.unlock()
998
- authContinuations.values.forEach { continuation in
999
- continuation.resume(throwing: NSError(
1000
- domain: "SynchroModule",
1001
- code: 0,
1002
- userInfo: [NSLocalizedDescriptionKey: "client closed"]
1003
- ))
1059
+ private func submitDatabaseOperation(
1060
+ resolve: @escaping RCTPromiseResolveBlock,
1061
+ reject: @escaping RCTPromiseRejectBlock,
1062
+ operation: @escaping @Sendable (SynchroClient) throws -> BridgePromiseValue
1063
+ ) {
1064
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
1065
+ let queue = databaseBridgeQueue
1066
+ let admissionGate = DispatchSemaphore(value: 0)
1067
+ let admitted = runtime.withLock { state -> Bool in
1068
+ guard state.acceptingTransactions, let client = state.client else { return false }
1069
+ queue.async {
1070
+ admissionGate.wait()
1071
+ do {
1072
+ settlement.resolve(try operation(client))
1073
+ } catch {
1074
+ Self.rejectWithError(settlement, error)
1075
+ }
1076
+ }
1077
+ return true
1078
+ }
1079
+ admissionGate.signal()
1080
+ guard admitted else {
1081
+ settlement.reject("NOT_CONNECTED", "Client not initialized", nil)
1082
+ return
1004
1083
  }
1005
- return activeSessions
1006
1084
  }
1007
1085
 
1008
- private func clearRuntimeState() async {
1009
- let activeSessions = detachRuntimeState()
1010
- activeSessions.forEach {
1086
+ private static func detachRuntimeState(
1087
+ _ runtime: OSAllocatedUnfairLock<RuntimeState>
1088
+ ) -> DetachedState {
1089
+ runtime.withLock { state in
1090
+ state.acceptingTransactions = false
1091
+ let detached = DetachedState(
1092
+ sessions: Array(state.sessions.values),
1093
+ observers: Array(state.observers.values),
1094
+ subscriptions: [
1095
+ state.statusSubscription,
1096
+ state.syncEventSubscription,
1097
+ state.conflictSubscription,
1098
+ ].compactMap { $0 },
1099
+ transportObservations: state.transportObservations
1100
+ )
1101
+ state.sessions.removeAll()
1102
+ state.observers.removeAll()
1103
+ state.statusSubscription = nil
1104
+ state.syncEventSubscription = nil
1105
+ state.conflictSubscription = nil
1106
+ state.cachedBackoff = nil
1107
+ state.cachedFailure = nil
1108
+ state.transportObservations = nil
1109
+ return detached
1110
+ }
1111
+ }
1112
+
1113
+ private static func clearRuntimeState(
1114
+ runtime: OSAllocatedUnfairLock<RuntimeState>,
1115
+ authRequests: AuthRequestStore,
1116
+ databaseBridgeQueue: DispatchQueue
1117
+ ) async {
1118
+ let detached = detachRuntimeState(runtime)
1119
+ detached.transportObservations?.cancelPauseBarrier()
1120
+ detached.observers.forEach { $0.cancel() }
1121
+ detached.subscriptions.forEach { $0.cancel() }
1122
+ authRequests.cancelAll()
1123
+ detached.sessions.forEach {
1011
1124
  $0.abort(TransactionAbortedError(message: "Client closed during transaction"))
1012
1125
  }
1013
- for session in activeSessions {
1126
+ for session in detached.sessions {
1014
1127
  await session.waitUntilFinished()
1015
1128
  }
1129
+ await withCheckedContinuation { continuation in
1130
+ databaseBridgeQueue.async {
1131
+ continuation.resume()
1132
+ }
1133
+ }
1016
1134
  }
1017
1135
 
1018
- private func statusPayload(_ status: SyncStatus) -> [String: Any] {
1136
+ private static func statusPayload(
1137
+ _ status: SyncStatus,
1138
+ client: SynchroClient,
1139
+ runtime: OSAllocatedUnfairLock<RuntimeState>
1140
+ ) -> [String: Any] {
1019
1141
  var payload: [String: Any] = [
1020
1142
  "status": status.rawValue,
1021
1143
  "retryAt": NSNull(),
@@ -1026,23 +1148,15 @@ public class SynchroModuleImpl: NSObject {
1026
1148
  case .uninitialized, .localReady, .connecting, .schemaApplying, .ready, .pushing, .pulling, .rebuilding, .stopped:
1027
1149
  break
1028
1150
  case .backoff:
1029
- statusDetailsLock.lock()
1030
- let backoff = cachedBackoff
1031
- statusDetailsLock.unlock()
1151
+ let backoff = runtime.withLock { $0.cachedBackoff }
1032
1152
  if let backoff {
1033
1153
  payload["retryAt"] = iso8601String(backoff.retryAt)
1034
1154
  payload["operation"] = backoff.operation.rawValue
1035
1155
  }
1036
1156
  case .error:
1037
- statusDetailsLock.lock()
1038
- let cachedFailure = self.cachedFailure
1039
- statusDetailsLock.unlock()
1157
+ let cachedFailure = runtime.withLock { $0.cachedFailure }
1040
1158
  let persistedFailure: SyncFailure?
1041
- if let client = self.client {
1042
- persistedFailure = try? client.getBlockingFailure()
1043
- } else {
1044
- persistedFailure = nil
1045
- }
1159
+ persistedFailure = try? client.getBlockingFailure()
1046
1160
  if let failure = cachedFailure ?? persistedFailure {
1047
1161
  payload["failure"] = failurePayload(failure)
1048
1162
  }
@@ -1050,26 +1164,29 @@ public class SynchroModuleImpl: NSObject {
1050
1164
  return payload
1051
1165
  }
1052
1166
 
1053
- private func recordStatusDetails(_ event: SyncEvent) {
1054
- statusDetailsLock.lock()
1055
- defer { statusDetailsLock.unlock() }
1056
- switch event {
1057
- case .backoff(let backoff):
1058
- cachedBackoff = backoff
1059
- cachedFailure = nil
1060
- case .failure(let failure):
1061
- cachedFailure = failure
1062
- cachedBackoff = nil
1063
- default:
1064
- break
1167
+ private static func recordStatusDetails(
1168
+ _ event: SyncEvent,
1169
+ runtime: OSAllocatedUnfairLock<RuntimeState>
1170
+ ) {
1171
+ runtime.withLock { state in
1172
+ switch event {
1173
+ case .backoff(let backoff):
1174
+ state.cachedBackoff = backoff
1175
+ state.cachedFailure = nil
1176
+ case .failure(let failure):
1177
+ state.cachedFailure = failure
1178
+ state.cachedBackoff = nil
1179
+ default:
1180
+ break
1181
+ }
1065
1182
  }
1066
1183
  }
1067
1184
 
1068
- private func schemaPayload(_ schema: SchemaRef) -> [String: Any] {
1185
+ private static func schemaPayload(_ schema: SchemaRef) -> [String: Any] {
1069
1186
  ["version": schema.version, "hash": schema.hash]
1070
1187
  }
1071
1188
 
1072
- private func failurePayload(_ failure: SyncFailure) -> [String: Any] {
1189
+ private static func failurePayload(_ failure: SyncFailure) -> [String: Any] {
1073
1190
  [
1074
1191
  "operation": failure.operation.rawValue,
1075
1192
  "code": failure.code.rawValue,
@@ -1080,13 +1197,13 @@ public class SynchroModuleImpl: NSObject {
1080
1197
  ]
1081
1198
  }
1082
1199
 
1083
- private func iso8601String(_ date: Date) -> String {
1200
+ private static func iso8601String(_ date: Date) -> String {
1084
1201
  let formatter = ISO8601DateFormatter()
1085
1202
  formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
1086
1203
  return formatter.string(from: date)
1087
1204
  }
1088
1205
 
1089
- private func syncEventPayload(_ event: SyncEvent) -> [String: Any] {
1206
+ private static func syncEventPayload(_ event: SyncEvent) -> [String: Any] {
1090
1207
  var payload: [String: Any] = [
1091
1208
  "type": "",
1092
1209
  "from": NSNull(),
@@ -1153,7 +1270,7 @@ public class SynchroModuleImpl: NSObject {
1153
1270
  return payload
1154
1271
  }
1155
1272
 
1156
- private func conflictPayload(_ event: ConflictEvent) -> [String: Any] {
1273
+ private static func conflictPayload(_ event: ConflictEvent) -> [String: Any] {
1157
1274
  [
1158
1275
  "table": event.table,
1159
1276
  "recordID": event.recordID,
@@ -1162,7 +1279,7 @@ public class SynchroModuleImpl: NSObject {
1162
1279
  ]
1163
1280
  }
1164
1281
 
1165
- private func encodeRejectedMutations(_ results: [RejectedMutation]) -> String {
1282
+ private static func encodeRejectedMutations(_ results: [RejectedMutation]) -> String {
1166
1283
  let payload = results.map { result in
1167
1284
  [
1168
1285
  "mutationID": result.mutationID,
@@ -1185,12 +1302,12 @@ public class SynchroModuleImpl: NSObject {
1185
1302
  return json
1186
1303
  }
1187
1304
 
1188
- private func anyCodableMapToJSONObject(_ value: [String: AnyCodable]?) -> Any {
1305
+ private static func anyCodableMapToJSONObject(_ value: [String: AnyCodable]?) -> Any {
1189
1306
  guard let value else { return NSNull() }
1190
1307
  return value.mapValues { anyCodableToJSONObject($0.value) }
1191
1308
  }
1192
1309
 
1193
- private func anyCodableToJSONObject(_ value: Any?) -> Any {
1310
+ private static func anyCodableToJSONObject(_ value: Any?) -> Any {
1194
1311
  switch value {
1195
1312
  case nil:
1196
1313
  return NSNull()
@@ -1217,7 +1334,7 @@ public class SynchroModuleImpl: NSObject {
1217
1334
  }
1218
1335
  }
1219
1336
 
1220
- private func encodeAnyCodableMap(_ value: [String: AnyCodable]?) -> String? {
1337
+ private static func encodeAnyCodableMap(_ value: [String: AnyCodable]?) -> String? {
1221
1338
  guard let value else { return nil }
1222
1339
  let encoder = JSONEncoder()
1223
1340
  guard let data = try? encoder.encode(value) else { return nil }
@@ -1234,18 +1351,16 @@ public class SynchroModuleImpl: NSObject {
1234
1351
  resolve: @escaping RCTPromiseResolveBlock,
1235
1352
  reject: @escaping RCTPromiseRejectBlock
1236
1353
  ) {
1237
- guard let client = client else {
1238
- reject("NOT_CONNECTED", "Client not initialized", nil)
1239
- return
1240
- }
1241
- do {
1242
- let columns = try parseColumns(columnsJson)
1243
- let options = try optionsJson.flatMap { try parseTableOptions($0) }
1244
- try client.createTable(name, columns: columns, options: options)
1245
- resolve(nil)
1246
- } catch {
1247
- rejectWithError(reject, error)
1248
- }
1354
+ submitDatabaseOperation(
1355
+ resolve: resolve,
1356
+ reject: reject,
1357
+ operation: { client in
1358
+ let columns = try Self.parseColumns(columnsJson)
1359
+ let options = try optionsJson.flatMap { try Self.parseTableOptions($0) }
1360
+ try client.createTable(name, columns: columns, options: options)
1361
+ return .void
1362
+ }
1363
+ )
1249
1364
  }
1250
1365
 
1251
1366
  @objc
@@ -1255,17 +1370,15 @@ public class SynchroModuleImpl: NSObject {
1255
1370
  resolve: @escaping RCTPromiseResolveBlock,
1256
1371
  reject: @escaping RCTPromiseRejectBlock
1257
1372
  ) {
1258
- guard let client = client else {
1259
- reject("NOT_CONNECTED", "Client not initialized", nil)
1260
- return
1261
- }
1262
- do {
1263
- let columns = try parseColumns(columnsJson)
1264
- try client.alterTable(name, addColumns: columns)
1265
- resolve(nil)
1266
- } catch {
1267
- rejectWithError(reject, error)
1268
- }
1373
+ submitDatabaseOperation(
1374
+ resolve: resolve,
1375
+ reject: reject,
1376
+ operation: { client in
1377
+ let columns = try Self.parseColumns(columnsJson)
1378
+ try client.alterTable(name, addColumns: columns)
1379
+ return .void
1380
+ }
1381
+ )
1269
1382
  }
1270
1383
 
1271
1384
  @objc
@@ -1276,16 +1389,14 @@ public class SynchroModuleImpl: NSObject {
1276
1389
  resolve: @escaping RCTPromiseResolveBlock,
1277
1390
  reject: @escaping RCTPromiseRejectBlock
1278
1391
  ) {
1279
- guard let client = client else {
1280
- reject("NOT_CONNECTED", "Client not initialized", nil)
1281
- return
1282
- }
1283
- do {
1284
- try client.createIndex(table, columns: columns, unique: unique)
1285
- resolve(nil)
1286
- } catch {
1287
- rejectWithError(reject, error)
1288
- }
1392
+ submitDatabaseOperation(
1393
+ resolve: resolve,
1394
+ reject: reject,
1395
+ operation: { client in
1396
+ try client.createIndex(table, columns: columns, unique: unique)
1397
+ return .void
1398
+ }
1399
+ )
1289
1400
  }
1290
1401
 
1291
1402
  // MARK: - Observation
@@ -1297,14 +1408,29 @@ public class SynchroModuleImpl: NSObject {
1297
1408
  resolve: @escaping RCTPromiseResolveBlock,
1298
1409
  reject: @escaping RCTPromiseRejectBlock
1299
1410
  ) {
1300
- guard let client = client else {
1411
+ guard let client = runtime.withLock({ state in
1412
+ state.acceptingTransactions ? state.client : nil
1413
+ }) else {
1301
1414
  reject("NOT_CONNECTED", "Client not initialized", nil)
1302
1415
  return
1303
1416
  }
1304
- let cancellable = client.onChange(tables: tables) { [weak self] in
1305
- self?.emit("onChange", ["observerID": observerID])
1417
+ let eventTarget = eventTarget
1418
+ let cancellable = client.onChange(tables: tables) {
1419
+ eventTarget.emit(
1420
+ "onChange",
1421
+ body: .object(["observerID": .string(observerID)])
1422
+ )
1306
1423
  }
1307
- observers[observerID] = cancellable
1424
+ let registration = runtime.withLock { state -> (Bool, (any Synchro.Cancellable)?) in
1425
+ guard state.acceptingTransactions, state.client === client else { return (false, nil) }
1426
+ return (true, state.observers.updateValue(cancellable, forKey: observerID))
1427
+ }
1428
+ guard registration.0 else {
1429
+ cancellable.cancel()
1430
+ reject("NOT_CONNECTED", "Client not initialized", nil)
1431
+ return
1432
+ }
1433
+ registration.1?.cancel()
1308
1434
  resolve(nil)
1309
1435
  }
1310
1436
 
@@ -1317,18 +1443,34 @@ public class SynchroModuleImpl: NSObject {
1317
1443
  resolve: @escaping RCTPromiseResolveBlock,
1318
1444
  reject: @escaping RCTPromiseRejectBlock
1319
1445
  ) {
1320
- guard let client = client else {
1446
+ guard let client = runtime.withLock({ state in
1447
+ state.acceptingTransactions ? state.client : nil
1448
+ }) else {
1321
1449
  reject("NOT_CONNECTED", "Client not initialized", nil)
1322
1450
  return
1323
1451
  }
1324
1452
  do {
1325
- let nativeParams = try bridgeParams(params)
1326
- let cancellable = client.watch(sql, params: nativeParams, tables: tables) { [weak self] rows in
1327
- if let payload = self?.rowsToBridgeRows(rows) {
1328
- self?.emit("onQueryResult", ["observerID": observerID, "rows": payload])
1329
- }
1453
+ let nativeParams = try Self.bridgeParams(Self.captureSQLParams(params))
1454
+ let eventTarget = eventTarget
1455
+ let cancellable = client.watch(sql, params: nativeParams, tables: tables) { rows in
1456
+ eventTarget.emit(
1457
+ "onQueryResult",
1458
+ body: .object([
1459
+ "observerID": .string(observerID),
1460
+ "rows": Self.rowsToBridgeValue(rows),
1461
+ ])
1462
+ )
1463
+ }
1464
+ let registration = runtime.withLock { state -> (Bool, (any Synchro.Cancellable)?) in
1465
+ guard state.acceptingTransactions, state.client === client else { return (false, nil) }
1466
+ return (true, state.observers.updateValue(cancellable, forKey: observerID))
1330
1467
  }
1331
- observers[observerID] = cancellable
1468
+ guard registration.0 else {
1469
+ cancellable.cancel()
1470
+ reject("NOT_CONNECTED", "Client not initialized", nil)
1471
+ return
1472
+ }
1473
+ registration.1?.cancel()
1332
1474
  resolve(nil)
1333
1475
  } catch {
1334
1476
  rejectWithError(reject, error)
@@ -1341,9 +1483,8 @@ public class SynchroModuleImpl: NSObject {
1341
1483
  resolve: @escaping RCTPromiseResolveBlock,
1342
1484
  reject: @escaping RCTPromiseRejectBlock
1343
1485
  ) {
1344
- if let cancellable = observers.removeValue(forKey: observerID) {
1345
- cancellable.cancel()
1346
- }
1486
+ let cancellable = runtime.withLock { $0.observers.removeValue(forKey: observerID) }
1487
+ cancellable?.cancel()
1347
1488
  resolve(nil)
1348
1489
  }
1349
1490
 
@@ -1352,19 +1493,20 @@ public class SynchroModuleImpl: NSObject {
1352
1493
  _ resolve: @escaping RCTPromiseResolveBlock,
1353
1494
  reject: @escaping RCTPromiseRejectBlock
1354
1495
  ) {
1355
- guard let client = client else {
1356
- reject("NOT_CONNECTED", "Client not initialized", nil)
1496
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
1497
+ guard let client = runtime.withLock({ $0.client }) else {
1498
+ settlement.reject("NOT_CONNECTED", "Client not initialized", nil)
1357
1499
  return
1358
1500
  }
1359
1501
  Task {
1360
1502
  do {
1361
1503
  try await client.start()
1362
1504
  DispatchQueue.main.async {
1363
- resolve(nil)
1505
+ settlement.resolve(.void)
1364
1506
  }
1365
1507
  } catch {
1366
1508
  DispatchQueue.main.async {
1367
- self.rejectWithError(reject, error)
1509
+ Self.rejectWithError(settlement, error)
1368
1510
  }
1369
1511
  }
1370
1512
  }
@@ -1375,14 +1517,15 @@ public class SynchroModuleImpl: NSObject {
1375
1517
  _ resolve: @escaping RCTPromiseResolveBlock,
1376
1518
  reject: @escaping RCTPromiseRejectBlock
1377
1519
  ) {
1378
- guard let client = client else {
1379
- reject("NOT_CONNECTED", "Client not initialized", nil)
1520
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
1521
+ guard let client = runtime.withLock({ $0.client }) else {
1522
+ settlement.reject("NOT_CONNECTED", "Client not initialized", nil)
1380
1523
  return
1381
1524
  }
1382
1525
  Task {
1383
1526
  await client.stop()
1384
1527
  DispatchQueue.main.async {
1385
- resolve(nil)
1528
+ settlement.resolve(.void)
1386
1529
  }
1387
1530
  }
1388
1531
  }
@@ -1392,14 +1535,15 @@ public class SynchroModuleImpl: NSObject {
1392
1535
  _ resolve: @escaping RCTPromiseResolveBlock,
1393
1536
  reject: @escaping RCTPromiseRejectBlock
1394
1537
  ) {
1395
- guard let client = client else {
1396
- reject("NOT_CONNECTED", "Client not initialized", nil)
1538
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
1539
+ guard let client = runtime.withLock({ $0.client }) else {
1540
+ settlement.reject("NOT_CONNECTED", "Client not initialized", nil)
1397
1541
  return
1398
1542
  }
1399
1543
  Task {
1400
1544
  await client.enterBackground()
1401
1545
  DispatchQueue.main.async {
1402
- resolve(nil)
1546
+ settlement.resolve(.void)
1403
1547
  }
1404
1548
  }
1405
1549
  }
@@ -1409,19 +1553,20 @@ public class SynchroModuleImpl: NSObject {
1409
1553
  _ resolve: @escaping RCTPromiseResolveBlock,
1410
1554
  reject: @escaping RCTPromiseRejectBlock
1411
1555
  ) {
1412
- guard let client = client else {
1413
- reject("NOT_CONNECTED", "Client not initialized", nil)
1556
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
1557
+ guard let client = runtime.withLock({ $0.client }) else {
1558
+ settlement.reject("NOT_CONNECTED", "Client not initialized", nil)
1414
1559
  return
1415
1560
  }
1416
1561
  Task {
1417
1562
  do {
1418
1563
  try await client.enterForeground()
1419
1564
  DispatchQueue.main.async {
1420
- resolve(nil)
1565
+ settlement.resolve(.void)
1421
1566
  }
1422
1567
  } catch {
1423
1568
  DispatchQueue.main.async {
1424
- self.rejectWithError(reject, error)
1569
+ Self.rejectWithError(settlement, error)
1425
1570
  }
1426
1571
  }
1427
1572
  }
@@ -1432,19 +1577,20 @@ public class SynchroModuleImpl: NSObject {
1432
1577
  _ resolve: @escaping RCTPromiseResolveBlock,
1433
1578
  reject: @escaping RCTPromiseRejectBlock
1434
1579
  ) {
1435
- guard let client = client else {
1436
- reject("NOT_CONNECTED", "Client not initialized", nil)
1580
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
1581
+ guard let client = runtime.withLock({ $0.client }) else {
1582
+ settlement.reject("NOT_CONNECTED", "Client not initialized", nil)
1437
1583
  return
1438
1584
  }
1439
1585
  Task {
1440
1586
  do {
1441
1587
  try await client.retryAfterError()
1442
1588
  DispatchQueue.main.async {
1443
- resolve(nil)
1589
+ settlement.resolve(.void)
1444
1590
  }
1445
1591
  } catch {
1446
1592
  DispatchQueue.main.async {
1447
- self.rejectWithError(reject, error)
1593
+ Self.rejectWithError(settlement, error)
1448
1594
  }
1449
1595
  }
1450
1596
  }
@@ -1455,19 +1601,20 @@ public class SynchroModuleImpl: NSObject {
1455
1601
  _ resolve: @escaping RCTPromiseResolveBlock,
1456
1602
  reject: @escaping RCTPromiseRejectBlock
1457
1603
  ) {
1458
- guard let client = client else {
1459
- reject("NOT_CONNECTED", "Client not initialized", nil)
1604
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
1605
+ guard let client = runtime.withLock({ $0.client }) else {
1606
+ settlement.reject("NOT_CONNECTED", "Client not initialized", nil)
1460
1607
  return
1461
1608
  }
1462
1609
  Task {
1463
1610
  do {
1464
1611
  try await client.resetSchemaAndStart()
1465
1612
  DispatchQueue.main.async {
1466
- resolve(nil)
1613
+ settlement.resolve(.void)
1467
1614
  }
1468
1615
  } catch {
1469
1616
  DispatchQueue.main.async {
1470
- self.rejectWithError(reject, error)
1617
+ Self.rejectWithError(settlement, error)
1471
1618
  }
1472
1619
  }
1473
1620
  }
@@ -1478,19 +1625,20 @@ public class SynchroModuleImpl: NSObject {
1478
1625
  _ resolve: @escaping RCTPromiseResolveBlock,
1479
1626
  reject: @escaping RCTPromiseRejectBlock
1480
1627
  ) {
1481
- guard let client = client else {
1482
- reject("NOT_CONNECTED", "Client not initialized", nil)
1628
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
1629
+ guard let client = runtime.withLock({ $0.client }) else {
1630
+ settlement.reject("NOT_CONNECTED", "Client not initialized", nil)
1483
1631
  return
1484
1632
  }
1485
1633
  Task {
1486
1634
  do {
1487
1635
  try await client.syncNow()
1488
1636
  DispatchQueue.main.async {
1489
- resolve(nil)
1637
+ settlement.resolve(.void)
1490
1638
  }
1491
1639
  } catch {
1492
1640
  DispatchQueue.main.async {
1493
- self.rejectWithError(reject, error)
1641
+ Self.rejectWithError(settlement, error)
1494
1642
  }
1495
1643
  }
1496
1644
  }
@@ -1501,15 +1649,13 @@ public class SynchroModuleImpl: NSObject {
1501
1649
  _ resolve: @escaping RCTPromiseResolveBlock,
1502
1650
  reject: @escaping RCTPromiseRejectBlock
1503
1651
  ) {
1504
- guard let client = client else {
1505
- reject("NOT_CONNECTED", "Client not initialized", nil)
1506
- return
1507
- }
1508
- do {
1509
- resolve(try client.pendingChangeCount())
1510
- } catch {
1511
- rejectWithError(reject, error)
1512
- }
1652
+ submitDatabaseOperation(
1653
+ resolve: resolve,
1654
+ reject: reject,
1655
+ operation: { client in
1656
+ .value(.integer(Int64(try client.pendingChangeCount())))
1657
+ }
1658
+ )
1513
1659
  }
1514
1660
 
1515
1661
  @objc
@@ -1517,15 +1663,18 @@ public class SynchroModuleImpl: NSObject {
1517
1663
  _ resolve: @escaping RCTPromiseResolveBlock,
1518
1664
  reject: @escaping RCTPromiseRejectBlock
1519
1665
  ) {
1520
- guard let client = client else {
1521
- reject("NOT_CONNECTED", "Client not initialized", nil)
1522
- return
1523
- }
1524
- do {
1525
- resolve(try encodeBridgeJSON(statusPayload(client.getSyncStatus())))
1526
- } catch {
1527
- rejectWithError(reject, error)
1528
- }
1666
+ let runtime = runtime
1667
+ submitDatabaseOperation(
1668
+ resolve: resolve,
1669
+ reject: reject,
1670
+ operation: { client in
1671
+ .value(.string(try Self.encodeBridgeJSON(Self.statusPayload(
1672
+ client.getSyncStatus(),
1673
+ client: client,
1674
+ runtime: runtime
1675
+ ))))
1676
+ }
1677
+ )
1529
1678
  }
1530
1679
 
1531
1680
  @objc
@@ -1533,16 +1682,14 @@ public class SynchroModuleImpl: NSObject {
1533
1682
  _ resolve: @escaping RCTPromiseResolveBlock,
1534
1683
  reject: @escaping RCTPromiseRejectBlock
1535
1684
  ) {
1536
- guard let client = client else {
1537
- reject("NOT_CONNECTED", "Client not initialized", nil)
1538
- return
1539
- }
1540
- do {
1541
- let payload = try client.inspectPendingMutations().map(pendingMutationPayload)
1542
- resolve(try encodeBridgeJSON(payload))
1543
- } catch {
1544
- rejectWithError(reject, error)
1545
- }
1685
+ submitDatabaseOperation(
1686
+ resolve: resolve,
1687
+ reject: reject,
1688
+ operation: { client in
1689
+ let payload = try client.inspectPendingMutations().map(Self.pendingMutationPayload)
1690
+ return .value(.string(try Self.encodeBridgeJSON(payload)))
1691
+ }
1692
+ )
1546
1693
  }
1547
1694
 
1548
1695
  @objc
@@ -1550,16 +1697,14 @@ public class SynchroModuleImpl: NSObject {
1550
1697
  _ resolve: @escaping RCTPromiseResolveBlock,
1551
1698
  reject: @escaping RCTPromiseRejectBlock
1552
1699
  ) {
1553
- guard let client = client else {
1554
- reject("NOT_CONNECTED", "Client not initialized", nil)
1555
- return
1556
- }
1557
- do {
1558
- let payload = try client.inspectRetainedMutations().map(pendingMutationPayload)
1559
- resolve(try encodeBridgeJSON(payload))
1560
- } catch {
1561
- rejectWithError(reject, error)
1562
- }
1700
+ submitDatabaseOperation(
1701
+ resolve: resolve,
1702
+ reject: reject,
1703
+ operation: { client in
1704
+ let payload = try client.inspectRetainedMutations().map(Self.pendingMutationPayload)
1705
+ return .value(.string(try Self.encodeBridgeJSON(payload)))
1706
+ }
1707
+ )
1563
1708
  }
1564
1709
 
1565
1710
  @objc
@@ -1567,16 +1712,14 @@ public class SynchroModuleImpl: NSObject {
1567
1712
  _ resolve: @escaping RCTPromiseResolveBlock,
1568
1713
  reject: @escaping RCTPromiseRejectBlock
1569
1714
  ) {
1570
- guard let client = client else {
1571
- reject("NOT_CONNECTED", "Client not initialized", nil)
1572
- return
1573
- }
1574
- do {
1575
- let payload = try client.inspectRetainedMutationRecords().map(retainedMutationPayload)
1576
- resolve(try encodeBridgeJSON(payload))
1577
- } catch {
1578
- rejectWithError(reject, error)
1579
- }
1715
+ submitDatabaseOperation(
1716
+ resolve: resolve,
1717
+ reject: reject,
1718
+ operation: { client in
1719
+ let payload = try client.inspectRetainedMutationRecords().map(Self.retainedMutationPayload)
1720
+ return .value(.string(try Self.encodeBridgeJSON(payload)))
1721
+ }
1722
+ )
1580
1723
  }
1581
1724
 
1582
1725
  @objc
@@ -1584,16 +1727,14 @@ public class SynchroModuleImpl: NSObject {
1584
1727
  _ resolve: @escaping RCTPromiseResolveBlock,
1585
1728
  reject: @escaping RCTPromiseRejectBlock
1586
1729
  ) {
1587
- guard let client = client else {
1588
- reject("NOT_CONNECTED", "Client not initialized", nil)
1589
- return
1590
- }
1591
- do {
1592
- let payload = try client.inspectRejectedMutations().map(rejectedMutationPayload)
1593
- resolve(try encodeBridgeJSON(payload))
1594
- } catch {
1595
- rejectWithError(reject, error)
1596
- }
1730
+ submitDatabaseOperation(
1731
+ resolve: resolve,
1732
+ reject: reject,
1733
+ operation: { client in
1734
+ let payload = try client.inspectRejectedMutations().map(Self.rejectedMutationPayload)
1735
+ return .value(.string(try Self.encodeBridgeJSON(payload)))
1736
+ }
1737
+ )
1597
1738
  }
1598
1739
 
1599
1740
  @objc
@@ -1601,16 +1742,14 @@ public class SynchroModuleImpl: NSObject {
1601
1742
  _ resolve: @escaping RCTPromiseResolveBlock,
1602
1743
  reject: @escaping RCTPromiseRejectBlock
1603
1744
  ) {
1604
- guard let client = client else {
1605
- reject("NOT_CONNECTED", "Client not initialized", nil)
1606
- return
1607
- }
1608
- do {
1609
- let payload = try client.inspectRejectedMutationRecords().map(retainedRejectionPayload)
1610
- resolve(try encodeBridgeJSON(payload))
1611
- } catch {
1612
- rejectWithError(reject, error)
1613
- }
1745
+ submitDatabaseOperation(
1746
+ resolve: resolve,
1747
+ reject: reject,
1748
+ operation: { client in
1749
+ let payload = try client.inspectRejectedMutationRecords().map(Self.retainedRejectionPayload)
1750
+ return .value(.string(try Self.encodeBridgeJSON(payload)))
1751
+ }
1752
+ )
1614
1753
  }
1615
1754
 
1616
1755
  /// Reads client state, retained details, and the requested application rows
@@ -1621,38 +1760,37 @@ public class SynchroModuleImpl: NSObject {
1621
1760
  resolve: @escaping RCTPromiseResolveBlock,
1622
1761
  reject: @escaping RCTPromiseRejectBlock
1623
1762
  ) {
1624
- guard let client else {
1625
- reject("NOT_CONNECTED", "Client not initialized", nil)
1626
- return
1627
- }
1628
- do {
1629
- let statements = try bridgeStatements(rowStatements)
1630
- var rows: [Row] = []
1631
- let snapshot = try SynchroInspection(client: client).captureSnapshot(maximumRecords: 512) { _, transaction in
1632
- for statement in statements {
1633
- rows += try transaction.query(statement.sql, params: statement.params)
1763
+ let capturedStatements = Result { try Self.captureBridgeStatements(rowStatements) }
1764
+ submitDatabaseOperation(
1765
+ resolve: resolve,
1766
+ reject: reject,
1767
+ operation: { client in
1768
+ let statements = try Self.bridgeStatements(capturedStatements.get())
1769
+ var rows: [Row] = []
1770
+ let snapshot = try SynchroInspection(client: client).captureSnapshot(maximumRecords: 512) { _, transaction in
1771
+ for statement in statements {
1772
+ rows += try transaction.query(statement.sql, params: statement.params)
1773
+ }
1634
1774
  }
1775
+ let inspection: [String: Any] = [
1776
+ "client_state": Self.clientStatePayload(snapshot.capture),
1777
+ "migration_journal": try snapshot.capture.migrationJournal.map {
1778
+ try JSONSerialization.jsonObject(with: JSONEncoder().encode($0))
1779
+ } ?? NSNull(),
1780
+ "migration_journal_truncated": snapshot.capture.migrationJournalTruncated,
1781
+ "physical_schema": try JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot.capture.physicalSchema)),
1782
+ "physical_schema_truncated": snapshot.capture.physicalSchemaTruncated,
1783
+ "accepted_mutation_outcomes": snapshot.capture.acceptedMutationOutcomes,
1784
+ "accepted_mutation_outcomes_truncated": snapshot.capture.acceptedMutationOutcomesTruncated,
1785
+ "retained_mutations": snapshot.retainedMutations.map { $0.map(Self.retainedMutationPayload) } ?? NSNull(),
1786
+ "rejected_mutations": snapshot.rejectedMutations.map { $0.map(Self.retainedRejectionPayload) } ?? NSNull(),
1787
+ ]
1788
+ return .value(.object([
1789
+ "inspection": .string(try Self.encodeBridgeJSON(inspection)),
1790
+ "applicationRows": Self.rowsToBridgeValue(rows),
1791
+ ]))
1635
1792
  }
1636
- let inspection: [String: Any] = [
1637
- "client_state": clientStatePayload(snapshot.capture),
1638
- "migration_journal": try snapshot.capture.migrationJournal.map {
1639
- try JSONSerialization.jsonObject(with: JSONEncoder().encode($0))
1640
- } ?? NSNull(),
1641
- "migration_journal_truncated": snapshot.capture.migrationJournalTruncated,
1642
- "physical_schema": try JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot.capture.physicalSchema)),
1643
- "physical_schema_truncated": snapshot.capture.physicalSchemaTruncated,
1644
- "accepted_mutation_outcomes": snapshot.capture.acceptedMutationOutcomes,
1645
- "accepted_mutation_outcomes_truncated": snapshot.capture.acceptedMutationOutcomesTruncated,
1646
- "retained_mutations": snapshot.retainedMutations.map { $0.map(retainedMutationPayload) } ?? NSNull(),
1647
- "rejected_mutations": snapshot.rejectedMutations.map { $0.map(retainedRejectionPayload) } ?? NSNull(),
1648
- ]
1649
- resolve([
1650
- "inspection": try encodeBridgeJSON(inspection),
1651
- "applicationRows": rowsToBridgeRows(rows),
1652
- ])
1653
- } catch {
1654
- rejectWithError(reject, error)
1655
- }
1793
+ )
1656
1794
  }
1657
1795
 
1658
1796
  /// Reads one record's row metadata and every rebuild receipt. The inspection snapshot bounds
@@ -1664,46 +1802,44 @@ public class SynchroModuleImpl: NSObject {
1664
1802
  resolve: @escaping RCTPromiseResolveBlock,
1665
1803
  reject: @escaping RCTPromiseRejectBlock
1666
1804
  ) {
1667
- guard let client else {
1668
- reject("NOT_CONNECTED", "Client not initialized", nil)
1669
- return
1670
- }
1671
- do {
1672
- let inspection = SynchroInspection(client: client)
1673
- let metadata: Any = try inspection.rowMetadata(
1674
- tableName: tableName,
1675
- recordID: recordID
1676
- ).map { value in
1677
- [
1678
- "table_name": value.tableName,
1679
- "record_id": value.recordID,
1680
- "server_version": value.serverVersion,
1681
- "row_checksum": value.rowChecksum ?? NSNull(),
1682
- ] as [String: Any]
1683
- } ?? NSNull()
1684
- let receipts = try inspection.rebuildReceipts().map { value in
1685
- [
1686
- "rebuild_id_fingerprint": value.rebuildIDFingerprint,
1687
- "page_count": value.pageCount,
1688
- "returned_record_count": value.returnedRecordCount,
1689
- "request_chain_expected": value.requestChainExpected,
1690
- "request_chain_observed": value.requestChainObserved,
1691
- "record_identities_hex": value.recordIdentitiesHex,
1692
- "received_row_checksums": value.receivedRowChecksums,
1693
- "computed_row_checksums": value.computedRowChecksums,
1694
- "computed_scope_checksum": value.computedScopeChecksum ?? NSNull(),
1695
- "final_scope_checksum": value.finalScopeChecksum ?? NSNull(),
1696
- "stored_scope_checksum": value.storedScopeChecksum ?? NSNull(),
1697
- "local_scope_checksum": value.localScopeChecksum ?? NSNull(),
1698
- ] as [String: Any]
1805
+ submitDatabaseOperation(
1806
+ resolve: resolve,
1807
+ reject: reject,
1808
+ operation: { client in
1809
+ let inspection = SynchroInspection(client: client)
1810
+ let metadata: Any = try inspection.rowMetadata(
1811
+ tableName: tableName,
1812
+ recordID: recordID
1813
+ ).map { value in
1814
+ [
1815
+ "table_name": value.tableName,
1816
+ "record_id": value.recordID,
1817
+ "server_version": value.serverVersion,
1818
+ "row_checksum": value.rowChecksum ?? NSNull(),
1819
+ ] as [String: Any]
1820
+ } ?? NSNull()
1821
+ let receipts = try inspection.rebuildReceipts().map { value in
1822
+ [
1823
+ "rebuild_id_fingerprint": value.rebuildIDFingerprint,
1824
+ "page_count": value.pageCount,
1825
+ "returned_record_count": value.returnedRecordCount,
1826
+ "request_chain_expected": value.requestChainExpected,
1827
+ "request_chain_observed": value.requestChainObserved,
1828
+ "record_identities_hex": value.recordIdentitiesHex,
1829
+ "received_row_checksums": value.receivedRowChecksums,
1830
+ "computed_row_checksums": value.computedRowChecksums,
1831
+ "computed_scope_checksum": value.computedScopeChecksum ?? NSNull(),
1832
+ "final_scope_checksum": value.finalScopeChecksum ?? NSNull(),
1833
+ "stored_scope_checksum": value.storedScopeChecksum ?? NSNull(),
1834
+ "local_scope_checksum": value.localScopeChecksum ?? NSNull(),
1835
+ ] as [String: Any]
1836
+ }
1837
+ return .value(.string(try Self.encodeBridgeJSON([
1838
+ "row_metadata": metadata,
1839
+ "rebuild_receipts": receipts,
1840
+ ])))
1699
1841
  }
1700
- resolve(try encodeBridgeJSON([
1701
- "row_metadata": metadata,
1702
- "rebuild_receipts": receipts,
1703
- ]))
1704
- } catch {
1705
- rejectWithError(reject, error)
1706
- }
1842
+ )
1707
1843
  }
1708
1844
 
1709
1845
  @objc
@@ -1711,7 +1847,7 @@ public class SynchroModuleImpl: NSObject {
1711
1847
  _ resolve: @escaping RCTPromiseResolveBlock,
1712
1848
  reject: @escaping RCTPromiseRejectBlock
1713
1849
  ) {
1714
- guard let transportObservations else {
1850
+ guard let transportObservations = runtime.withLock({ $0.transportObservations }) else {
1715
1851
  reject("NOT_CONNECTED", "Transport observation is not configured", nil)
1716
1852
  return
1717
1853
  }
@@ -1732,7 +1868,7 @@ public class SynchroModuleImpl: NSObject {
1732
1868
  resolve: @escaping RCTPromiseResolveBlock,
1733
1869
  reject: @escaping RCTPromiseRejectBlock
1734
1870
  ) {
1735
- guard let collector = transportObservations else {
1871
+ guard let collector = runtime.withLock({ $0.transportObservations }) else {
1736
1872
  reject("INVALID_CONFIG", "Transport pause operation is invalid", nil)
1737
1873
  return
1738
1874
  }
@@ -1757,9 +1893,10 @@ public class SynchroModuleImpl: NSObject {
1757
1893
  resolve: @escaping RCTPromiseResolveBlock,
1758
1894
  reject: @escaping RCTPromiseRejectBlock
1759
1895
  ) {
1760
- guard let collector = transportObservations,
1896
+ let settlement = ReactPromiseSettlement(resolve: resolve, reject: reject)
1897
+ guard let collector = runtime.withLock({ $0.transportObservations }),
1761
1898
  timeoutMs.isFinite, timeoutMs >= 1, timeoutMs <= 60_000 else {
1762
- reject("INVALID_CONFIG", "Transport pause wait is invalid", nil)
1899
+ settlement.reject("INVALID_CONFIG", "Transport pause wait is invalid", nil)
1763
1900
  return
1764
1901
  }
1765
1902
  Task {
@@ -1771,9 +1908,13 @@ public class SynchroModuleImpl: NSObject {
1771
1908
  } else {
1772
1909
  throw SynchroError.invalidResponse(message: "pause target is invalid")
1773
1910
  }
1774
- DispatchQueue.main.async { resolve(nil) }
1911
+ DispatchQueue.main.async {
1912
+ settlement.resolve(.void)
1913
+ }
1775
1914
  } catch {
1776
- DispatchQueue.main.async { self.rejectWithError(reject, error) }
1915
+ DispatchQueue.main.async {
1916
+ Self.rejectWithError(settlement, error)
1917
+ }
1777
1918
  }
1778
1919
  }
1779
1920
  }
@@ -1783,7 +1924,7 @@ public class SynchroModuleImpl: NSObject {
1783
1924
  _ resolve: @escaping RCTPromiseResolveBlock,
1784
1925
  reject: @escaping RCTPromiseRejectBlock
1785
1926
  ) {
1786
- guard let collector = transportObservations else {
1927
+ guard let collector = runtime.withLock({ $0.transportObservations }) else {
1787
1928
  reject("NOT_CONNECTED", "Transport observation is not configured", nil)
1788
1929
  return
1789
1930
  }
@@ -1808,21 +1949,19 @@ public class SynchroModuleImpl: NSObject {
1808
1949
  _ resolve: @escaping RCTPromiseResolveBlock,
1809
1950
  reject: @escaping RCTPromiseRejectBlock
1810
1951
  ) {
1811
- guard let client = client else {
1812
- reject("NOT_CONNECTED", "Client not initialized", nil)
1813
- return
1814
- }
1815
- do {
1816
- try client.clearRejectedMutations()
1817
- resolve(nil)
1818
- } catch {
1819
- rejectWithError(reject, error)
1820
- }
1952
+ submitDatabaseOperation(
1953
+ resolve: resolve,
1954
+ reject: reject,
1955
+ operation: { client in
1956
+ try client.clearRejectedMutations()
1957
+ return .void
1958
+ }
1959
+ )
1821
1960
  }
1822
1961
 
1823
1962
  // MARK: - Helpers
1824
1963
 
1825
- private func clientStatePayload(_ capture: ClientStateCaptureInspection) -> [String: Any] {
1964
+ private static func clientStatePayload(_ capture: ClientStateCaptureInspection) -> [String: Any] {
1826
1965
  let schema: Any = capture.schema.map { value in
1827
1966
  ["version": value.version, "hash": value.hash]
1828
1967
  } ?? NSNull()
@@ -1884,17 +2023,119 @@ public class SynchroModuleImpl: NSObject {
1884
2023
  ]
1885
2024
  }
1886
2025
 
1887
- private func bridgeStatements(_ statements: [[String: Any]]) throws -> [Synchro.SQLStatement] {
2026
+ private struct CapturedBridgeStatement: Sendable {
2027
+ let sql: String
2028
+ let params: [BridgeValue]?
2029
+ }
2030
+
2031
+ private static func captureSQLParams<Values: Sequence>(
2032
+ _ values: Values
2033
+ ) throws -> [BridgeValue] where Values.Element == Any {
2034
+ try values.enumerated().map { index, value in
2035
+ try captureSQLParam(value, index: index)
2036
+ }
2037
+ }
2038
+
2039
+ private static func captureSQLParam(_ value: Any, index: Int) throws -> BridgeValue {
2040
+ if let scalar = try captureBridgeScalar(value, index: index) {
2041
+ return scalar
2042
+ }
2043
+
2044
+ switch value {
2045
+ case let value as NSDictionary:
2046
+ let validated = try bridgeTagToParam(
2047
+ fieldCount: value.count,
2048
+ stringField: { value[$0] as? String },
2049
+ containsField: { value[$0] != nil },
2050
+ index: index
2051
+ )
2052
+ return .object(validated.tag)
2053
+ case is NSArray:
2054
+ throw BridgeParamError.unsupported(index: index, type: "array")
2055
+ default:
2056
+ throw BridgeParamError.unsupported(index: index, type: String(describing: type(of: value)))
2057
+ }
2058
+ }
2059
+
2060
+ private static func captureBridgeScalar(_ value: Any, index: Int) throws -> BridgeValue? {
2061
+ switch value {
2062
+ case is NSNull:
2063
+ return .null
2064
+ case let value as String:
2065
+ return .string(value)
2066
+ case let value as NSNumber:
2067
+ if CFGetTypeID(value) == CFBooleanGetTypeID() {
2068
+ return .bool(value.boolValue)
2069
+ }
2070
+ let doubleValue = value.doubleValue
2071
+ guard doubleValue.isFinite else {
2072
+ throw BridgeParamError.invalidNumber(index: index)
2073
+ }
2074
+ if doubleValue.rounded(.towardZero) == doubleValue,
2075
+ doubleValue >= Double(Int64.min),
2076
+ doubleValue < Double(Int64.max) {
2077
+ return .integer(Int64(doubleValue))
2078
+ }
2079
+ return .double(doubleValue)
2080
+ default:
2081
+ return nil
2082
+ }
2083
+ }
2084
+
2085
+ private static func captureBridgeValue(_ value: Any, index: Int) throws -> BridgeValue {
2086
+ if let scalar = try captureBridgeScalar(value, index: index) {
2087
+ return scalar
2088
+ }
2089
+
2090
+ switch value {
2091
+ case let value as NSDictionary:
2092
+ var object: [String: BridgeValue] = [:]
2093
+ for (key, fieldValue) in value {
2094
+ guard let key = key as? String else {
2095
+ throw BridgeParamError.unsupported(index: index, type: "non-string object key")
2096
+ }
2097
+ object[key] = try captureBridgeValue(fieldValue, index: index)
2098
+ }
2099
+ return .object(object)
2100
+ case let value as NSArray:
2101
+ return .array(try value.map { try captureBridgeValue($0, index: index) })
2102
+ default:
2103
+ throw BridgeParamError.unsupported(index: index, type: String(describing: type(of: value)))
2104
+ }
2105
+ }
2106
+
2107
+ private static func bridgeValue(_ value: Any) -> BridgeValue {
2108
+ do {
2109
+ return try captureBridgeValue(value, index: 0)
2110
+ } catch {
2111
+ preconditionFailure("Internal bridge value is invalid: \(error.localizedDescription)")
2112
+ }
2113
+ }
2114
+
2115
+ private static func captureBridgeStatements(
2116
+ _ statements: [[String: Any]]
2117
+ ) throws -> [CapturedBridgeStatement] {
1888
2118
  try statements.enumerated().map { index, item in
1889
2119
  guard let sql = item["sql"] as? String else {
1890
2120
  throw SynchroError.invalidResponse(message: "Missing SQL at index \(index)")
1891
2121
  }
1892
- let params = try (item["params"] as? [Any]).map(bridgeParams)
1893
- return Synchro.SQLStatement(sql: sql, params: params)
2122
+ let params = try (item["params"] as? NSArray).map(captureSQLParams)
2123
+ return CapturedBridgeStatement(sql: sql, params: params)
1894
2124
  }
1895
2125
  }
1896
2126
 
1897
- private func encodeBridgeJSON(_ value: Any) throws -> String {
2127
+ private static func bridgeStatements(
2128
+ _ statements: [CapturedBridgeStatement]
2129
+ ) throws -> [Synchro.SQLStatement] {
2130
+ try statements.map { statement in
2131
+ Synchro.SQLStatement(
2132
+ sql: statement.sql,
2133
+ params: try statement.params.map(bridgeParams)
2134
+ )
2135
+ }
2136
+ }
2137
+
2138
+ private static func encodeBridgeJSON(_ value: Any) throws -> String {
1898
2139
  guard JSONSerialization.isValidJSONObject(value) else {
1899
2140
  throw SynchroError.invalidResponse(message: "bridge JSON value is invalid")
1900
2141
  }
@@ -1905,7 +2146,7 @@ public class SynchroModuleImpl: NSObject {
1905
2146
  return json
1906
2147
  }
1907
2148
 
1908
- private func retainedMutationPayload(_ value: RetainedMutationInspection) -> [String: Any] {
2149
+ private static func retainedMutationPayload(_ value: RetainedMutationInspection) -> [String: Any] {
1909
2150
  switch value {
1910
2151
  case .current(let mutation):
1911
2152
  return pendingMutationPayload(mutation).merging(["representation": "current"]) { current, _ in current }
@@ -1925,7 +2166,7 @@ public class SynchroModuleImpl: NSObject {
1925
2166
  }
1926
2167
  }
1927
2168
 
1928
- private func pendingMutationPayload(_ mutation: PendingMutationInspection) -> [String: Any] {
2169
+ private static func pendingMutationPayload(_ mutation: PendingMutationInspection) -> [String: Any] {
1929
2170
  [
1930
2171
  "mutationID": mutation.mutationID,
1931
2172
  "localOrder": mutation.localOrder,
@@ -1957,7 +2198,7 @@ public class SynchroModuleImpl: NSObject {
1957
2198
  ]
1958
2199
  }
1959
2200
 
1960
- private func retainedRejectionPayload(_ value: RetainedRejectionInspection) -> [String: Any] {
2201
+ private static func retainedRejectionPayload(_ value: RetainedRejectionInspection) -> [String: Any] {
1961
2202
  switch value {
1962
2203
  case .current(let rejection):
1963
2204
  return rejectedMutationPayload(rejection).merging(["representation": "current"]) { current, _ in current }
@@ -1978,7 +2219,7 @@ public class SynchroModuleImpl: NSObject {
1978
2219
  }
1979
2220
  }
1980
2221
 
1981
- private func rejectedMutationPayload(_ mutation: RejectedMutationInspection) -> [String: Any] {
2222
+ private static func rejectedMutationPayload(_ mutation: RejectedMutationInspection) -> [String: Any] {
1982
2223
  [
1983
2224
  "mutationID": mutation.mutationID,
1984
2225
  "tableName": mutation.tableName,
@@ -2015,14 +2256,14 @@ public class SynchroModuleImpl: NSObject {
2015
2256
  private static let maxSafeInteger: Int64 = 9_007_199_254_740_991
2016
2257
  private static let minSafeInteger: Int64 = -9_007_199_254_740_991
2017
2258
 
2018
- private func canonicalBase64URL(_ data: Data) -> String {
2259
+ private static func canonicalBase64URL(_ data: Data) -> String {
2019
2260
  data.base64EncodedString()
2020
2261
  .replacingOccurrences(of: "+", with: "-")
2021
2262
  .replacingOccurrences(of: "/", with: "_")
2022
2263
  .replacingOccurrences(of: "=", with: "")
2023
2264
  }
2024
2265
 
2025
- private func decodeCanonicalBase64URL(_ value: String, index: Int) throws -> Data {
2266
+ private static func decodeCanonicalBase64URL(_ value: String, index: Int) throws -> Data {
2026
2267
  guard value.utf8.allSatisfy({ byte in
2027
2268
  (byte >= 65 && byte <= 90) ||
2028
2269
  (byte >= 97 && byte <= 122) ||
@@ -2047,7 +2288,7 @@ public class SynchroModuleImpl: NSObject {
2047
2288
  return data
2048
2289
  }
2049
2290
 
2050
- private func decodeCanonicalInt64(_ value: String, index: Int) throws -> Int64 {
2291
+ private static func decodeCanonicalInt64(_ value: String, index: Int) throws -> Int64 {
2051
2292
  guard value == "0" || value.range(of: "^-?[1-9][0-9]*$", options: .regularExpression) != nil,
2052
2293
  let parsed = Int64(value), String(parsed) == value else {
2053
2294
  throw BridgeParamError.invalidTag(index: index, reason: "int64 is not canonical or is out of range")
@@ -2055,46 +2296,53 @@ public class SynchroModuleImpl: NSObject {
2055
2296
  return parsed
2056
2297
  }
2057
2298
 
2058
- private func bridgeTagToParam(_ value: Any, index: Int) throws -> (any DatabaseValueConvertible)? {
2059
- guard let tag = value as? NSDictionary,
2060
- tag.count == 2,
2061
- let type = tag["type"] as? String else {
2299
+ private static func bridgeTagToParam(
2300
+ fieldCount: Int,
2301
+ stringField: (String) -> String?,
2302
+ containsField: (String) -> Bool,
2303
+ index: Int
2304
+ ) throws -> (tag: [String: BridgeValue], param: (any DatabaseValueConvertible)?) {
2305
+ guard fieldCount == 2, let type = stringField("type") else {
2062
2306
  throw BridgeParamError.invalidTag(index: index, reason: "tag must contain exactly type and payload fields")
2063
2307
  }
2064
2308
 
2065
2309
  switch type {
2066
2310
  case "bytes":
2067
- guard let base64 = tag["base64"] as? String, tag["value"] == nil else {
2311
+ guard let base64 = stringField("base64"), !containsField("value") else {
2068
2312
  throw BridgeParamError.invalidTag(index: index, reason: "bytes tag must contain base64")
2069
2313
  }
2070
- return try decodeCanonicalBase64URL(base64, index: index)
2314
+ return (
2315
+ ["type": .string(type), "base64": .string(base64)],
2316
+ try decodeCanonicalBase64URL(base64, index: index)
2317
+ )
2071
2318
  case "int64":
2072
- guard let value = tag["value"] as? String, tag["base64"] == nil else {
2319
+ guard let value = stringField("value"), !containsField("base64") else {
2073
2320
  throw BridgeParamError.invalidTag(index: index, reason: "int64 tag must contain value")
2074
2321
  }
2075
- return try decodeCanonicalInt64(value, index: index)
2322
+ return (
2323
+ ["type": .string(type), "value": .string(value)],
2324
+ try decodeCanonicalInt64(value, index: index)
2325
+ )
2076
2326
  default:
2077
2327
  throw BridgeParamError.invalidTag(index: index, reason: "unknown tag type")
2078
2328
  }
2079
2329
  }
2080
2330
 
2081
- private func bridgeValueToParam(_ value: Any, index: Int) throws -> (any DatabaseValueConvertible)? {
2331
+ private static func bridgeValueToParam(
2332
+ _ value: BridgeValue,
2333
+ index: Int
2334
+ ) throws -> (any DatabaseValueConvertible)? {
2082
2335
  switch value {
2083
- case is NSNull:
2336
+ case .null:
2084
2337
  return nil
2085
- case let boolVal as Bool:
2338
+ case .bool(let boolVal):
2086
2339
  return boolVal ? 1 : 0
2087
- case let intVal as Int:
2088
- guard Int64(intVal) >= Self.minSafeInteger && Int64(intVal) <= Self.maxSafeInteger else {
2340
+ case .integer(let intVal):
2341
+ guard intVal >= Self.minSafeInteger && intVal <= Self.maxSafeInteger else {
2089
2342
  throw BridgeParamError.invalidNumber(index: index)
2090
2343
  }
2091
2344
  return intVal
2092
- case let int64Val as Int64:
2093
- guard int64Val >= Self.minSafeInteger && int64Val <= Self.maxSafeInteger else {
2094
- throw BridgeParamError.invalidNumber(index: index)
2095
- }
2096
- return int64Val
2097
- case let doubleVal as Double:
2345
+ case .double(let doubleVal):
2098
2346
  guard doubleVal.isFinite else {
2099
2347
  throw BridgeParamError.invalidNumber(index: index)
2100
2348
  }
@@ -2103,40 +2351,32 @@ public class SynchroModuleImpl: NSObject {
2103
2351
  throw BridgeParamError.invalidNumber(index: index)
2104
2352
  }
2105
2353
  return doubleVal
2106
- case let stringVal as String:
2354
+ case .string(let stringVal):
2107
2355
  return stringVal
2108
- case let dictionary as NSDictionary:
2109
- return try bridgeTagToParam(dictionary, index: index)
2110
- case let numberVal as NSNumber:
2111
- if CFGetTypeID(numberVal) == CFBooleanGetTypeID() {
2112
- return numberVal.boolValue ? 1 : 0
2113
- }
2114
- let doubleValue = numberVal.doubleValue
2115
- guard doubleValue.isFinite else {
2116
- throw BridgeParamError.invalidNumber(index: index)
2117
- }
2118
- if doubleValue.isFinite,
2119
- doubleValue.rounded(.towardZero) == doubleValue,
2120
- doubleValue >= Double(Self.minSafeInteger),
2121
- doubleValue <= Double(Self.maxSafeInteger) {
2122
- return Int64(doubleValue)
2123
- }
2124
- if doubleValue.rounded(.towardZero) == doubleValue {
2125
- throw BridgeParamError.invalidNumber(index: index)
2126
- }
2127
- return doubleValue
2128
- default:
2129
- throw BridgeParamError.unsupported(index: index, type: String(describing: type(of: value)))
2130
- }
2131
- }
2132
-
2133
- private func bridgeParams(_ values: [Any]) throws -> [(any DatabaseValueConvertible)?] {
2356
+ case .object(let dictionary):
2357
+ return try bridgeTagToParam(
2358
+ fieldCount: dictionary.count,
2359
+ stringField: { field in
2360
+ guard case .string(let value)? = dictionary[field] else { return nil }
2361
+ return value
2362
+ },
2363
+ containsField: { dictionary[$0] != nil },
2364
+ index: index
2365
+ ).param
2366
+ case .array:
2367
+ throw BridgeParamError.unsupported(index: index, type: "array")
2368
+ }
2369
+ }
2370
+
2371
+ private static func bridgeParams(
2372
+ _ values: [BridgeValue]
2373
+ ) throws -> [(any DatabaseValueConvertible)?] {
2134
2374
  try values.enumerated().map { index, value in
2135
2375
  try bridgeValueToParam(value, index: index)
2136
2376
  }
2137
2377
  }
2138
2378
 
2139
- private func parseColumns(_ json: String) throws -> [ColumnDef] {
2379
+ private static func parseColumns(_ json: String) throws -> [ColumnDef] {
2140
2380
  struct Column: Decodable {
2141
2381
  let name: String
2142
2382
  let type: String
@@ -2165,7 +2405,7 @@ public class SynchroModuleImpl: NSObject {
2165
2405
  }
2166
2406
  }
2167
2407
 
2168
- private func parseTableOptions(_ json: String) throws -> Synchro.TableOptions {
2408
+ private static func parseTableOptions(_ json: String) throws -> Synchro.TableOptions {
2169
2409
  struct Options: Decodable {
2170
2410
  let ifNotExists: Bool?
2171
2411
  let withoutRowid: Bool?
@@ -2186,34 +2426,34 @@ public class SynchroModuleImpl: NSObject {
2186
2426
  )
2187
2427
  }
2188
2428
 
2189
- private func databaseValueToFoundation(_ dbValue: DatabaseValue) -> Any {
2429
+ private static func databaseValueToBridgeValue(_ dbValue: DatabaseValue) -> BridgeValue {
2190
2430
  switch dbValue.storage {
2191
2431
  case .null:
2192
- return NSNull()
2432
+ return .null
2193
2433
  case .int64(let v):
2194
2434
  if v >= Self.minSafeInteger && v <= Self.maxSafeInteger {
2195
- return NSNumber(value: v)
2435
+ return .integer(v)
2196
2436
  }
2197
- return ["type": "int64", "value": String(v)]
2437
+ return .object(["type": .string("int64"), "value": .string(String(v))])
2198
2438
  case .double(let v):
2199
- return NSNumber(value: v)
2439
+ return .double(v)
2200
2440
  case .string(let v):
2201
- return v
2441
+ return .string(v)
2202
2442
  case .blob(let v):
2203
- return ["type": "bytes", "base64": canonicalBase64URL(v)]
2443
+ return .object(["type": .string("bytes"), "base64": .string(canonicalBase64URL(v))])
2204
2444
  }
2205
2445
  }
2206
2446
 
2207
- private func rowToBridgeRow(_ row: Row) -> [String: Any] {
2208
- var dict: [String: Any] = [:]
2447
+ private static func rowToBridgeValue(_ row: Row) -> BridgeValue {
2448
+ var values: [String: BridgeValue] = [:]
2209
2449
  for column in row.columnNames {
2210
2450
  let dbValue: DatabaseValue = row[column]
2211
- dict[column] = databaseValueToFoundation(dbValue)
2451
+ values[column] = databaseValueToBridgeValue(dbValue)
2212
2452
  }
2213
- return dict
2453
+ return .object(values)
2214
2454
  }
2215
2455
 
2216
- private func rowsToBridgeRows(_ rows: [Row]) -> [[String: Any]] {
2217
- rows.map { rowToBridgeRow($0) }
2456
+ private static func rowsToBridgeValue(_ rows: [Row]) -> BridgeValue {
2457
+ .array(rows.map(rowToBridgeValue))
2218
2458
  }
2219
2459
  }