@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,808 @@
1
+ package com.trainstar.synchro.rn
2
+
3
+ import com.facebook.react.bridge.*
4
+ import com.trainstar.synchro.*
5
+ import kotlinx.coroutines.ExperimentalCoroutinesApi
6
+ import kotlinx.coroutines.*
7
+ import kotlinx.coroutines.channels.Channel
8
+ import org.json.JSONArray
9
+ import org.json.JSONObject
10
+ import java.util.UUID
11
+ import java.util.concurrent.ConcurrentHashMap
12
+
13
+ @OptIn(ExperimentalCoroutinesApi::class)
14
+ class SynchroModule(reactContext: ReactApplicationContext) :
15
+ NativeSynchroSpec(reactContext) {
16
+
17
+ private var client: SynchroClient? = null
18
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
19
+ private val sessions = ConcurrentHashMap<String, TransactionSession>()
20
+ private val observers = ConcurrentHashMap<String, Cancellable>()
21
+ private val pendingAuthContinuations = ConcurrentHashMap<String, CancellableContinuation<String>>()
22
+ private val pendingSnapshotContinuations = ConcurrentHashMap<String, CancellableContinuation<Boolean>>()
23
+ private var statusSubscription: Cancellable? = null
24
+ private var conflictSubscription: Cancellable? = null
25
+ private var snapshotSubscription: Cancellable? = null
26
+
27
+ private fun rejectWithError(promise: Promise, error: Throwable) {
28
+ when (error) {
29
+ is SynchroError -> {
30
+ val (code, userInfo) = mapSynchroError(error)
31
+ val writableMap = Arguments.createMap().apply {
32
+ userInfo.forEach { (k, v) -> putString(k, v) }
33
+ }
34
+ promise.reject(code, error.message, error, writableMap)
35
+ }
36
+ else -> promise.reject("UNKNOWN", error.message, error)
37
+ }
38
+ }
39
+
40
+ private fun mapSynchroError(error: SynchroError): Pair<String, Map<String, String>> {
41
+ return when (error) {
42
+ is SynchroError.NotConnected -> "NOT_CONNECTED" to emptyMap()
43
+ is SynchroError.SchemaNotLoaded -> "SCHEMA_NOT_LOADED" to emptyMap()
44
+ is SynchroError.TableNotSynced -> "TABLE_NOT_SYNCED" to mapOf("table" to error.table)
45
+ is SynchroError.UpgradeRequired -> "UPGRADE_REQUIRED" to mapOf(
46
+ "currentVersion" to error.currentVersion,
47
+ "minimumVersion" to error.minimumVersion
48
+ )
49
+ is SynchroError.SchemaMismatch -> "SCHEMA_MISMATCH" to mapOf(
50
+ "serverVersion" to error.serverVersion.toString(),
51
+ "serverHash" to error.serverHash
52
+ )
53
+ is SynchroError.SnapshotRequired -> "SNAPSHOT_REQUIRED" to emptyMap()
54
+ is SynchroError.PushRejected -> "PUSH_REJECTED" to mapOf(
55
+ "results" to JSONArray(error.results.map { r ->
56
+ JSONObject().apply {
57
+ put("recordID", r.id)
58
+ put("table", r.tableName)
59
+ put("status", r.status)
60
+ }
61
+ }).toString()
62
+ )
63
+ is SynchroError.NetworkError -> "NETWORK_ERROR" to mapOf(
64
+ "message" to (error.underlying.message ?: "")
65
+ )
66
+ is SynchroError.ServerError -> "SERVER_ERROR" to mapOf(
67
+ "status" to error.status.toString(),
68
+ "message" to error.serverMessage
69
+ )
70
+ is SynchroError.DatabaseError -> "DATABASE_ERROR" to mapOf(
71
+ "message" to (error.underlying.message ?: "")
72
+ )
73
+ is SynchroError.InvalidResponse -> "INVALID_RESPONSE" to mapOf("message" to error.details)
74
+ is SynchroError.AlreadyStarted -> "ALREADY_STARTED" to emptyMap()
75
+ is SynchroError.NotStarted -> "NOT_STARTED" to emptyMap()
76
+ }
77
+ }
78
+
79
+ // MARK: - Lifecycle
80
+
81
+ @ReactMethod
82
+ override fun initialize(config: ReadableMap, promise: Promise) {
83
+ try {
84
+ val dbPath = config.getString("dbPath") ?: throw IllegalArgumentException("Missing dbPath")
85
+ val serverURL = config.getString("serverURL") ?: throw IllegalArgumentException("Missing serverURL")
86
+ val clientID = config.getString("clientID") ?: throw IllegalArgumentException("Missing clientID")
87
+ val platform = config.getString("platform") ?: "android"
88
+ val appVersion = config.getString("appVersion") ?: throw IllegalArgumentException("Missing appVersion")
89
+ val syncInterval = if (config.hasKey("syncInterval")) config.getDouble("syncInterval") else 30.0
90
+ val pushDebounce = if (config.hasKey("pushDebounce")) config.getDouble("pushDebounce") else 0.5
91
+ val maxRetryAttempts = if (config.hasKey("maxRetryAttempts")) config.getInt("maxRetryAttempts") else 5
92
+ val pullPageSize = if (config.hasKey("pullPageSize")) config.getInt("pullPageSize") else 100
93
+ val pushBatchSize = if (config.hasKey("pushBatchSize")) config.getInt("pushBatchSize") else 100
94
+ val snapshotPageSize = if (config.hasKey("snapshotPageSize")) config.getInt("snapshotPageSize") else 100
95
+
96
+ val synchroConfig = SynchroConfig(
97
+ dbPath = dbPath,
98
+ serverURL = serverURL,
99
+ authProvider = {
100
+ suspendCancellableCoroutine { continuation ->
101
+ val requestID = UUID.randomUUID().toString()
102
+ // Store continuation BEFORE emitting event to avoid race condition
103
+ pendingAuthContinuations[requestID] = continuation
104
+ continuation.invokeOnCancellation {
105
+ pendingAuthContinuations.remove(requestID)
106
+ }
107
+ val params = Arguments.createMap().apply {
108
+ putString("requestID", requestID)
109
+ }
110
+ emitOnAuthRequest(params)
111
+ }
112
+ },
113
+ clientID = clientID,
114
+ platform = platform,
115
+ appVersion = appVersion,
116
+ syncInterval = syncInterval,
117
+ pushDebounce = pushDebounce,
118
+ maxRetryAttempts = maxRetryAttempts,
119
+ pullPageSize = pullPageSize,
120
+ pushBatchSize = pushBatchSize,
121
+ snapshotPageSize = snapshotPageSize
122
+ )
123
+
124
+ client?.close()
125
+ clearRuntimeState()
126
+ client = SynchroClient(synchroConfig, reactApplicationContext)
127
+ wireClientEvents(client!!)
128
+ promise.resolve(null)
129
+ } catch (e: Exception) {
130
+ rejectWithError(promise, e)
131
+ }
132
+ }
133
+
134
+ @ReactMethod
135
+ override fun resolveAuthRequest(requestID: String, token: String) {
136
+ pendingAuthContinuations.remove(requestID)?.resume(token) {}
137
+ }
138
+
139
+ @ReactMethod
140
+ override fun rejectAuthRequest(requestID: String, error: String) {
141
+ pendingAuthContinuations.remove(requestID)?.cancel(
142
+ Exception(error)
143
+ )
144
+ }
145
+
146
+ @ReactMethod
147
+ override fun resolveSnapshotRequest(requestID: String, approved: Boolean) {
148
+ pendingSnapshotContinuations.remove(requestID)?.resume(approved) {}
149
+ }
150
+
151
+ @ReactMethod
152
+ override fun close(promise: Promise) {
153
+ try {
154
+ client?.close()
155
+ clearRuntimeState()
156
+ client = null
157
+ promise.resolve(null)
158
+ } catch (e: Exception) {
159
+ rejectWithError(promise, e)
160
+ }
161
+ }
162
+
163
+ @ReactMethod
164
+ override fun getPath(promise: Promise) {
165
+ val c = client ?: run {
166
+ promise.reject("NOT_CONNECTED", "Client not initialized")
167
+ return
168
+ }
169
+ promise.resolve(c.path)
170
+ }
171
+
172
+ // MARK: - Core SQL
173
+
174
+ @ReactMethod
175
+ override fun query(sql: String, paramsJson: String, promise: Promise) {
176
+ val c = client ?: run {
177
+ promise.reject("NOT_CONNECTED", "Client not initialized")
178
+ return
179
+ }
180
+ try {
181
+ val params = parseParams(paramsJson)
182
+ val rows = c.query(sql, params)
183
+ promise.resolve(rowsToJson(rows))
184
+ } catch (e: Exception) {
185
+ rejectWithError(promise, e)
186
+ }
187
+ }
188
+
189
+ @ReactMethod
190
+ override fun queryOne(sql: String, paramsJson: String, promise: Promise) {
191
+ val c = client ?: run {
192
+ promise.reject("NOT_CONNECTED", "Client not initialized")
193
+ return
194
+ }
195
+ try {
196
+ val params = parseParams(paramsJson)
197
+ val row = c.queryOne(sql, params)
198
+ promise.resolve(row?.let { rowToJson(it) })
199
+ } catch (e: Exception) {
200
+ rejectWithError(promise, e)
201
+ }
202
+ }
203
+
204
+ @ReactMethod
205
+ override fun execute(sql: String, paramsJson: String, promise: Promise) {
206
+ val c = client ?: run {
207
+ promise.reject("NOT_CONNECTED", "Client not initialized")
208
+ return
209
+ }
210
+ try {
211
+ val params = parseParams(paramsJson)
212
+ val result = c.execute(sql, params)
213
+ val map = Arguments.createMap().apply {
214
+ putInt("rowsAffected", result.rowsAffected)
215
+ }
216
+ promise.resolve(map)
217
+ } catch (e: Exception) {
218
+ rejectWithError(promise, e)
219
+ }
220
+ }
221
+
222
+ @ReactMethod
223
+ override fun executeBatch(statementsJson: String, promise: Promise) {
224
+ val c = client ?: run {
225
+ promise.reject("NOT_CONNECTED", "Client not initialized")
226
+ return
227
+ }
228
+ try {
229
+ val array = JSONArray(statementsJson)
230
+ val statements = (0 until array.length()).map { i ->
231
+ val obj = array.getJSONObject(i)
232
+ val sql = obj.getString("sql")
233
+ val params = obj.optJSONArray("params")?.let { parseJsonArray(it) } ?: emptyArray()
234
+ SQLStatement(sql, params)
235
+ }
236
+ val total = c.executeBatch(statements)
237
+ val map = Arguments.createMap().apply {
238
+ putInt("totalRowsAffected", total)
239
+ }
240
+ promise.resolve(map)
241
+ } catch (e: Exception) {
242
+ rejectWithError(promise, e)
243
+ }
244
+ }
245
+
246
+ // MARK: - Transactions
247
+
248
+ private class TransactionSession {
249
+ val operations = Channel<TransactionOp>(Channel.RENDEZVOUS)
250
+ }
251
+
252
+ private sealed class TransactionOp {
253
+ data class Query(val sql: String, val params: Array<Any?>, val deferred: CompletableDeferred<String>) : TransactionOp()
254
+ data class QueryOne(val sql: String, val params: Array<Any?>, val deferred: CompletableDeferred<String?>) : TransactionOp()
255
+ data class Execute(val sql: String, val params: Array<Any?>, val deferred: CompletableDeferred<WritableMap>) : TransactionOp()
256
+ class Commit(val deferred: CompletableDeferred<Unit>) : TransactionOp()
257
+ class Rollback(val deferred: CompletableDeferred<Unit>) : TransactionOp()
258
+ }
259
+
260
+ private class TransactionRollbackException : Exception("rollback")
261
+
262
+ @ReactMethod
263
+ override fun beginWriteTransaction(promise: Promise) {
264
+ beginTransaction(isWrite = true, promise = promise)
265
+ }
266
+
267
+ @ReactMethod
268
+ override fun beginReadTransaction(promise: Promise) {
269
+ beginTransaction(isWrite = false, promise = promise)
270
+ }
271
+
272
+ private fun beginTransaction(isWrite: Boolean, promise: Promise) {
273
+ val c = client ?: run {
274
+ promise.reject("NOT_CONNECTED", "Client not initialized")
275
+ return
276
+ }
277
+
278
+ val txID = UUID.randomUUID().toString()
279
+ val session = TransactionSession()
280
+ sessions[txID] = session
281
+
282
+ scope.launch {
283
+ try {
284
+ val txBlock: (android.database.sqlite.SQLiteDatabase) -> Unit = { db ->
285
+ promise.resolve(txID)
286
+ runBlocking {
287
+ withTimeout(5000) {
288
+ transactionLoop@ for (op in session.operations) {
289
+ when (op) {
290
+ is TransactionOp.Query -> {
291
+ try {
292
+ val rows = queryOnDb(db, op.sql, op.params)
293
+ op.deferred.complete(rowsToJson(rows))
294
+ } catch (e: Exception) {
295
+ op.deferred.completeExceptionally(e)
296
+ }
297
+ }
298
+ is TransactionOp.QueryOne -> {
299
+ try {
300
+ val rows = queryOnDb(db, op.sql, op.params)
301
+ op.deferred.complete(if (rows.isNotEmpty()) rowToJson(rows[0]) else null)
302
+ } catch (e: Exception) {
303
+ op.deferred.completeExceptionally(e)
304
+ }
305
+ }
306
+ is TransactionOp.Execute -> {
307
+ try {
308
+ db.execSQL(op.sql, op.params)
309
+ val cursor = db.rawQuery("SELECT changes() as c", null)
310
+ val count = cursor.use {
311
+ if (it.moveToFirst()) it.getInt(0) else 0
312
+ }
313
+ val map = Arguments.createMap().apply {
314
+ putInt("rowsAffected", count)
315
+ }
316
+ op.deferred.complete(map)
317
+ } catch (e: Exception) {
318
+ op.deferred.completeExceptionally(e)
319
+ }
320
+ }
321
+ is TransactionOp.Commit -> {
322
+ op.deferred.complete(Unit)
323
+ session.operations.close()
324
+ break@transactionLoop
325
+ }
326
+ is TransactionOp.Rollback -> {
327
+ op.deferred.complete(Unit)
328
+ session.operations.close()
329
+ throw TransactionRollbackException()
330
+ }
331
+ }
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ if (isWrite) {
338
+ c.writeTransaction { db -> txBlock(db) }
339
+ } else {
340
+ c.readTransaction { db -> txBlock(db) }
341
+ }
342
+ } catch (_: TimeoutCancellationException) {
343
+ // Auto-rollback on timeout
344
+ } catch (_: TransactionRollbackException) {
345
+ // Intentional rollback
346
+ } catch (_: Exception) {
347
+ // Transaction ended
348
+ }
349
+ sessions.remove(txID)
350
+ }
351
+ }
352
+
353
+ private fun queryOnDb(db: android.database.sqlite.SQLiteDatabase, sql: String, params: Array<Any?>): List<Row> {
354
+ val stringParams = params.map { it?.toString() }.toTypedArray()
355
+ val cursor = db.rawQuery(sql, stringParams)
356
+ val rows = mutableListOf<Row>()
357
+ cursor.use {
358
+ while (it.moveToNext()) {
359
+ val row = mutableMapOf<String, Any?>()
360
+ for (i in 0 until it.columnCount) {
361
+ val name = it.getColumnName(i)
362
+ row[name] = when (it.getType(i)) {
363
+ android.database.Cursor.FIELD_TYPE_NULL -> null
364
+ android.database.Cursor.FIELD_TYPE_INTEGER -> it.getLong(i)
365
+ android.database.Cursor.FIELD_TYPE_FLOAT -> it.getDouble(i)
366
+ android.database.Cursor.FIELD_TYPE_BLOB -> it.getBlob(i)
367
+ else -> it.getString(i)
368
+ }
369
+ }
370
+ rows.add(row)
371
+ }
372
+ }
373
+ return rows
374
+ }
375
+
376
+ @ReactMethod
377
+ override fun txQuery(txID: String, sql: String, paramsJson: String, promise: Promise) {
378
+ val session = sessions[txID] ?: run {
379
+ promise.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired")
380
+ return
381
+ }
382
+ scope.launch {
383
+ try {
384
+ val params = parseParams(paramsJson)
385
+ val deferred = CompletableDeferred<String>()
386
+ session.operations.send(TransactionOp.Query(sql, params, deferred))
387
+ promise.resolve(deferred.await())
388
+ } catch (e: Exception) {
389
+ rejectWithError(promise, e)
390
+ }
391
+ }
392
+ }
393
+
394
+ @ReactMethod
395
+ override fun txQueryOne(txID: String, sql: String, paramsJson: String, promise: Promise) {
396
+ val session = sessions[txID] ?: run {
397
+ promise.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired")
398
+ return
399
+ }
400
+ scope.launch {
401
+ try {
402
+ val params = parseParams(paramsJson)
403
+ val deferred = CompletableDeferred<String?>()
404
+ session.operations.send(TransactionOp.QueryOne(sql, params, deferred))
405
+ promise.resolve(deferred.await())
406
+ } catch (e: Exception) {
407
+ rejectWithError(promise, e)
408
+ }
409
+ }
410
+ }
411
+
412
+ @ReactMethod
413
+ override fun txExecute(txID: String, sql: String, paramsJson: String, promise: Promise) {
414
+ val session = sessions[txID] ?: run {
415
+ promise.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired")
416
+ return
417
+ }
418
+ scope.launch {
419
+ try {
420
+ val params = parseParams(paramsJson)
421
+ val deferred = CompletableDeferred<WritableMap>()
422
+ session.operations.send(TransactionOp.Execute(sql, params, deferred))
423
+ promise.resolve(deferred.await())
424
+ } catch (e: Exception) {
425
+ rejectWithError(promise, e)
426
+ }
427
+ }
428
+ }
429
+
430
+ @ReactMethod
431
+ override fun commitTransaction(txID: String, promise: Promise) {
432
+ val session = sessions[txID] ?: run {
433
+ promise.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired")
434
+ return
435
+ }
436
+ scope.launch {
437
+ try {
438
+ val deferred = CompletableDeferred<Unit>()
439
+ session.operations.send(TransactionOp.Commit(deferred))
440
+ deferred.await()
441
+ promise.resolve(null)
442
+ } catch (e: Exception) {
443
+ rejectWithError(promise, e)
444
+ }
445
+ }
446
+ }
447
+
448
+ @ReactMethod
449
+ override fun rollbackTransaction(txID: String, promise: Promise) {
450
+ val session = sessions[txID] ?: run {
451
+ promise.resolve(null) // Already gone
452
+ return
453
+ }
454
+ scope.launch {
455
+ try {
456
+ val deferred = CompletableDeferred<Unit>()
457
+ session.operations.send(TransactionOp.Rollback(deferred))
458
+ deferred.await()
459
+ promise.resolve(null)
460
+ } catch (e: Exception) {
461
+ promise.resolve(null) // Best-effort
462
+ }
463
+ }
464
+ }
465
+
466
+ // MARK: - Schema
467
+
468
+ @ReactMethod
469
+ override fun createTable(name: String, columnsJson: String, optionsJson: String?, promise: Promise) {
470
+ val c = client ?: run {
471
+ promise.reject("NOT_CONNECTED", "Client not initialized")
472
+ return
473
+ }
474
+ try {
475
+ val columns = parseColumns(columnsJson)
476
+ val options = optionsJson?.let { parseTableOptions(it) }
477
+ c.createTable(name, columns, options)
478
+ promise.resolve(null)
479
+ } catch (e: Exception) {
480
+ rejectWithError(promise, e)
481
+ }
482
+ }
483
+
484
+ @ReactMethod
485
+ override fun alterTable(name: String, columnsJson: String, promise: Promise) {
486
+ val c = client ?: run {
487
+ promise.reject("NOT_CONNECTED", "Client not initialized")
488
+ return
489
+ }
490
+ try {
491
+ val columns = parseColumns(columnsJson)
492
+ c.alterTable(name, columns)
493
+ promise.resolve(null)
494
+ } catch (e: Exception) {
495
+ rejectWithError(promise, e)
496
+ }
497
+ }
498
+
499
+ @ReactMethod
500
+ override fun createIndex(table: String, columns: ReadableArray, unique: Boolean, promise: Promise) {
501
+ val c = client ?: run {
502
+ promise.reject("NOT_CONNECTED", "Client not initialized")
503
+ return
504
+ }
505
+ try {
506
+ val cols = (0 until columns.size()).mapNotNull { columns.getString(it) }
507
+ c.createIndex(table, cols, unique)
508
+ promise.resolve(null)
509
+ } catch (e: Exception) {
510
+ rejectWithError(promise, e)
511
+ }
512
+ }
513
+
514
+ // MARK: - Observation
515
+
516
+ @ReactMethod
517
+ override fun addChangeObserver(observerID: String, tables: ReadableArray, promise: Promise) {
518
+ val c = client ?: run {
519
+ promise.reject("NOT_CONNECTED", "Client not initialized")
520
+ return
521
+ }
522
+ val tableList = (0 until tables.size()).mapNotNull { tables.getString(it) }
523
+ val cancellable = c.onChange(tableList) {
524
+ val params = Arguments.createMap().apply {
525
+ putString("observerID", observerID)
526
+ }
527
+ emitOnChange(params)
528
+ }
529
+ observers[observerID] = cancellable
530
+ promise.resolve(null)
531
+ }
532
+
533
+ @ReactMethod
534
+ override fun addQueryObserver(
535
+ observerID: String,
536
+ sql: String,
537
+ paramsJson: String,
538
+ tables: ReadableArray,
539
+ promise: Promise
540
+ ) {
541
+ val c = client ?: run {
542
+ promise.reject("NOT_CONNECTED", "Client not initialized")
543
+ return
544
+ }
545
+ try {
546
+ val params = parseParams(paramsJson)
547
+ val tableList = (0 until tables.size()).mapNotNull { tables.getString(it) }
548
+ val cancellable = c.watch(sql, params, tableList) { rows ->
549
+ val eventParams = Arguments.createMap().apply {
550
+ putString("observerID", observerID)
551
+ putString("rowsJson", rowsToJson(rows))
552
+ }
553
+ emitOnQueryResult(eventParams)
554
+ }
555
+ observers[observerID] = cancellable
556
+ promise.resolve(null)
557
+ } catch (e: Exception) {
558
+ rejectWithError(promise, e)
559
+ }
560
+ }
561
+
562
+ @ReactMethod
563
+ override fun removeObserver(observerID: String, promise: Promise) {
564
+ observers.remove(observerID)?.cancel()
565
+ promise.resolve(null)
566
+ }
567
+
568
+ // MARK: - WAL / Sync
569
+
570
+ @ReactMethod
571
+ override fun checkpoint(mode: String, promise: Promise) {
572
+ val c = client ?: run {
573
+ promise.reject("NOT_CONNECTED", "Client not initialized")
574
+ return
575
+ }
576
+ try {
577
+ val checkpointMode = when (mode) {
578
+ "full" -> CheckpointMode.FULL
579
+ "restart" -> CheckpointMode.RESTART
580
+ "truncate" -> CheckpointMode.TRUNCATE
581
+ else -> CheckpointMode.PASSIVE
582
+ }
583
+ c.checkpoint(checkpointMode)
584
+ promise.resolve(null)
585
+ } catch (e: Exception) {
586
+ rejectWithError(promise, e)
587
+ }
588
+ }
589
+
590
+ @ReactMethod
591
+ override fun start(promise: Promise) {
592
+ val c = client ?: run {
593
+ promise.reject("NOT_CONNECTED", "Client not initialized")
594
+ return
595
+ }
596
+ scope.launch {
597
+ try {
598
+ c.start()
599
+ promise.resolve(null)
600
+ } catch (e: Exception) {
601
+ rejectWithError(promise, e)
602
+ }
603
+ }
604
+ }
605
+
606
+ @ReactMethod
607
+ override fun stop(promise: Promise) {
608
+ val c = client ?: run {
609
+ promise.reject("NOT_CONNECTED", "Client not initialized")
610
+ return
611
+ }
612
+ try {
613
+ c.stop()
614
+ promise.resolve(null)
615
+ } catch (e: Exception) {
616
+ rejectWithError(promise, e)
617
+ }
618
+ }
619
+
620
+ @ReactMethod
621
+ override fun syncNow(promise: Promise) {
622
+ val c = client ?: run {
623
+ promise.reject("NOT_CONNECTED", "Client not initialized")
624
+ return
625
+ }
626
+ scope.launch {
627
+ try {
628
+ c.syncNow()
629
+ promise.resolve(null)
630
+ } catch (e: Exception) {
631
+ rejectWithError(promise, e)
632
+ }
633
+ }
634
+ }
635
+
636
+ @ReactMethod
637
+ override fun pendingChangeCount(promise: Promise) {
638
+ val c = client ?: run {
639
+ promise.reject("NOT_CONNECTED", "Client not initialized")
640
+ return
641
+ }
642
+ try {
643
+ promise.resolve(c.pendingChangeCount())
644
+ } catch (e: Exception) {
645
+ rejectWithError(promise, e)
646
+ }
647
+ }
648
+
649
+ // Event listener registration (required by RN)
650
+ @ReactMethod
651
+ override fun addListener(eventName: String) { /* no-op */ }
652
+
653
+ @ReactMethod
654
+ override fun removeListeners(count: Double) { /* no-op */ }
655
+
656
+ // MARK: - Helpers
657
+
658
+ private fun wireClientEvents(client: SynchroClient) {
659
+ statusSubscription?.cancel()
660
+ conflictSubscription?.cancel()
661
+ snapshotSubscription?.cancel()
662
+
663
+ statusSubscription = client.onStatusChange { status ->
664
+ val params = Arguments.createMap().apply {
665
+ when (status) {
666
+ is SyncStatus.Idle -> {
667
+ putString("status", "idle")
668
+ putNull("retryAt")
669
+ }
670
+ is SyncStatus.Syncing -> {
671
+ putString("status", "syncing")
672
+ putNull("retryAt")
673
+ }
674
+ is SyncStatus.Stopped -> {
675
+ putString("status", "stopped")
676
+ putNull("retryAt")
677
+ }
678
+ is SyncStatus.Error -> {
679
+ putString("status", "error")
680
+ putString("retryAt", status.retryAt?.toString())
681
+ }
682
+ }
683
+ }
684
+ emitOnStatusChange(params)
685
+ }
686
+
687
+ conflictSubscription = client.onConflict { event ->
688
+ val params = Arguments.createMap().apply {
689
+ putString("table", event.table)
690
+ putString("recordID", event.recordID)
691
+ putString("clientDataJson", event.clientData?.let { anyCodableMapToJson(it) })
692
+ putString("serverDataJson", event.serverData?.let { anyCodableMapToJson(it) })
693
+ }
694
+ emitOnConflict(params)
695
+ }
696
+
697
+ snapshotSubscription = client.onSnapshotRequired {
698
+ suspendCancellableCoroutine { continuation ->
699
+ val requestID = UUID.randomUUID().toString()
700
+ pendingSnapshotContinuations[requestID] = continuation
701
+ continuation.invokeOnCancellation {
702
+ pendingSnapshotContinuations.remove(requestID)
703
+ }
704
+ val params = Arguments.createMap().apply {
705
+ putString("requestID", requestID)
706
+ }
707
+ emitOnSnapshotRequired(params)
708
+ }
709
+ }
710
+ }
711
+
712
+ private fun clearRuntimeState() {
713
+ statusSubscription?.cancel()
714
+ conflictSubscription?.cancel()
715
+ snapshotSubscription?.cancel()
716
+ statusSubscription = null
717
+ conflictSubscription = null
718
+ snapshotSubscription = null
719
+ observers.values.forEach { it.cancel() }
720
+ observers.clear()
721
+ sessions.values.forEach { it.operations.close() }
722
+ sessions.clear()
723
+ pendingAuthContinuations.values.forEach { it.cancel(CancellationException("client closed")) }
724
+ pendingAuthContinuations.clear()
725
+ pendingSnapshotContinuations.values.forEach { it.cancel(CancellationException("client closed")) }
726
+ pendingSnapshotContinuations.clear()
727
+ }
728
+
729
+ private fun anyCodableMapToJson(value: Map<String, AnyCodable>): String {
730
+ val obj = JSONObject()
731
+ value.forEach { (key, anyCodable) ->
732
+ obj.put(key, anyCodableToJsonValue(anyCodable.value))
733
+ }
734
+ return obj.toString()
735
+ }
736
+
737
+ private fun anyCodableToJsonValue(value: Any?): Any? = when (value) {
738
+ null -> JSONObject.NULL
739
+ is Boolean, is Number, is String -> value
740
+ is List<*> -> JSONArray().apply {
741
+ value.forEach { put(anyCodableToJsonValue(it)) }
742
+ }
743
+ is Map<*, *> -> JSONObject().apply {
744
+ value.forEach { (k, v) ->
745
+ put(k.toString(), anyCodableToJsonValue(v))
746
+ }
747
+ }
748
+ is AnyCodable -> anyCodableToJsonValue(value.value)
749
+ else -> value.toString()
750
+ }
751
+
752
+ private fun parseParams(json: String): Array<Any?> {
753
+ val array = JSONArray(json)
754
+ return Array(array.length()) { i ->
755
+ val v = array.get(i)
756
+ if (v == JSONObject.NULL) null else v
757
+ }
758
+ }
759
+
760
+ private fun parseJsonArray(array: JSONArray): Array<Any?> {
761
+ return Array(array.length()) { i ->
762
+ val v = array.get(i)
763
+ if (v == JSONObject.NULL) null else v
764
+ }
765
+ }
766
+
767
+ private fun parseColumns(json: String): List<ColumnDef> {
768
+ val array = JSONArray(json)
769
+ return (0 until array.length()).map { i ->
770
+ val obj = array.getJSONObject(i)
771
+ ColumnDef(
772
+ name = obj.getString("name"),
773
+ type = obj.getString("type"),
774
+ nullable = obj.optBoolean("nullable", true),
775
+ primaryKey = obj.optBoolean("primaryKey", false),
776
+ defaultValue = if (obj.has("defaultValue")) obj.getString("defaultValue") else null
777
+ )
778
+ }
779
+ }
780
+
781
+ private fun parseTableOptions(json: String): TableOptions {
782
+ val obj = JSONObject(json)
783
+ return TableOptions(
784
+ ifNotExists = obj.optBoolean("ifNotExists", true),
785
+ withoutRowid = obj.optBoolean("withoutRowid", false)
786
+ )
787
+ }
788
+
789
+ private fun rowToJson(row: Row): String {
790
+ val obj = JSONObject()
791
+ for ((k, v) in row) {
792
+ obj.put(k, v ?: JSONObject.NULL)
793
+ }
794
+ return obj.toString()
795
+ }
796
+
797
+ private fun rowsToJson(rows: List<Row>): String {
798
+ val array = JSONArray()
799
+ for (row in rows) {
800
+ val obj = JSONObject()
801
+ for ((k, v) in row) {
802
+ obj.put(k, v ?: JSONObject.NULL)
803
+ }
804
+ array.put(obj)
805
+ }
806
+ return array.toString()
807
+ }
808
+ }