@trainstar/synchro-react-native 0.1.0

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