@trainstar/synchro-react-native 0.1.1 → 0.3.1

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