@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,28 +1,67 @@
1
+ // The bridge is the React Native conformance facade. Its transport
2
+ // observation wiring consumes the Kotlin proof API deliberately.
3
+ @file:OptIn(com.trainstar.synchro.inspection.SynchroProofApi::class)
4
+
1
5
  package com.trainstar.synchro.rn
2
6
 
7
+ import android.util.Base64
8
+ import android.os.Process
3
9
  import com.facebook.react.bridge.*
4
10
  import com.trainstar.synchro.*
11
+ import com.trainstar.synchro.inspection.SynchroInspection
12
+ import com.trainstar.synchro.inspection.TransportObservationCollector
13
+ import com.trainstar.synchro.inspection.TransportObservationSnapshot
14
+ import com.trainstar.synchro.inspection.TransportOperationClass
15
+ import com.trainstar.synchro.inspection.withTransportObservation
5
16
  import kotlinx.coroutines.ExperimentalCoroutinesApi
6
17
  import kotlinx.coroutines.*
7
18
  import kotlinx.coroutines.channels.Channel
19
+ import kotlinx.coroutines.sync.Mutex
20
+ import kotlinx.coroutines.sync.withLock
21
+ import kotlinx.serialization.ExperimentalSerializationApi
22
+ import kotlinx.serialization.encodeToString
23
+ import kotlinx.serialization.json.Json
24
+ import kotlinx.serialization.json.JsonArray
25
+ import kotlinx.serialization.json.JsonElement
26
+ import kotlinx.serialization.json.JsonNull
27
+ import kotlinx.serialization.json.JsonObject
28
+ import kotlinx.serialization.json.JsonPrimitive
29
+ import kotlinx.serialization.json.boolean
30
+ import kotlinx.serialization.json.booleanOrNull
31
+ import kotlinx.serialization.json.double
32
+ import kotlinx.serialization.json.doubleOrNull
33
+ import kotlinx.serialization.json.long
34
+ import kotlinx.serialization.json.longOrNull
8
35
  import org.json.JSONArray
9
36
  import org.json.JSONObject
37
+ import java.io.File
38
+ import java.security.MessageDigest
10
39
  import java.util.UUID
11
40
  import java.util.concurrent.ConcurrentHashMap
12
41
 
13
- @OptIn(ExperimentalCoroutinesApi::class)
42
+ @OptIn(ExperimentalCoroutinesApi::class, ExperimentalSerializationApi::class)
14
43
  class SynchroModule(reactContext: ReactApplicationContext) :
15
44
  NativeSynchroSpec(reactContext) {
16
45
 
46
+ private companion object {
47
+ const val MAX_SAFE_INTEGER = 9_007_199_254_740_991L
48
+ val BASE64_URL_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
49
+ val BASE64_URL_PATTERN = Regex("^[A-Za-z0-9_-]*$")
50
+ val INT64_PATTERN = Regex("^(?:0|-?[1-9][0-9]*)$")
51
+ }
52
+
17
53
  private var client: SynchroClient? = null
18
54
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
55
+ private val lifecycleMutex = Mutex()
56
+ private val transactionLock = Any()
19
57
  private val sessions = ConcurrentHashMap<String, TransactionSession>()
58
+ private var acceptingTransactions = false
20
59
  private val observers = ConcurrentHashMap<String, Cancellable>()
21
60
  private val pendingAuthContinuations = ConcurrentHashMap<String, CancellableContinuation<String>>()
22
- private val pendingSnapshotContinuations = ConcurrentHashMap<String, CancellableContinuation<Boolean>>()
23
61
  private var statusSubscription: Cancellable? = null
62
+ private var syncEventSubscription: Cancellable? = null
24
63
  private var conflictSubscription: Cancellable? = null
25
- private var snapshotSubscription: Cancellable? = null
64
+ private var transportObservationCollector: TransportObservationCollector? = null
26
65
 
27
66
  private fun rejectWithError(promise: Promise, error: Throwable) {
28
67
  when (error) {
@@ -33,6 +72,21 @@ class SynchroModule(reactContext: ReactApplicationContext) :
33
72
  }
34
73
  promise.reject(code, error.message, error, writableMap)
35
74
  }
75
+ is TransactionAbortedException -> promise.reject(
76
+ "NOT_CONNECTED",
77
+ "Client closed during transaction",
78
+ error,
79
+ )
80
+ is TransactionExpiredException -> promise.reject(
81
+ "TRANSACTION_TIMEOUT",
82
+ "Transaction timed out due to inactivity",
83
+ error,
84
+ )
85
+ is TimeoutCancellationException -> promise.reject(
86
+ "TRANSACTION_TIMEOUT",
87
+ "Transaction timed out due to inactivity",
88
+ error,
89
+ )
36
90
  else -> promise.reject("UNKNOWN", error.message, error)
37
91
  }
38
92
  }
@@ -50,15 +104,8 @@ class SynchroModule(reactContext: ReactApplicationContext) :
50
104
  "serverVersion" to error.serverVersion.toString(),
51
105
  "serverHash" to error.serverHash
52
106
  )
53
- is SynchroError.SnapshotRequired -> "SNAPSHOT_REQUIRED" to emptyMap()
54
107
  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()
108
+ "results" to rejectedMutationsJson(error.results)
62
109
  )
63
110
  is SynchroError.NetworkError -> "NETWORK_ERROR" to mapOf(
64
111
  "message" to (error.underlying.message ?: "")
@@ -67,10 +114,26 @@ class SynchroModule(reactContext: ReactApplicationContext) :
67
114
  "status" to error.status.toString(),
68
115
  "message" to error.serverMessage
69
116
  )
117
+ is SynchroError.IdempotencyConflict -> "IDEMPOTENCY_CONFLICT" to emptyMap()
118
+ is SynchroError.UnsupportedSchema -> "UNSUPPORTED_SCHEMA" to mapOf(
119
+ "reason" to error.reason.name.lowercase()
120
+ )
70
121
  is SynchroError.DatabaseError -> "DATABASE_ERROR" to mapOf(
71
122
  "message" to (error.underlying.message ?: "")
72
123
  )
73
124
  is SynchroError.InvalidResponse -> "INVALID_RESPONSE" to mapOf("message" to error.details)
125
+ is SynchroError.BlockingFailure -> "SYNC_BLOCKED" to mapOf(
126
+ "failure" to failureJson(error.failure).toString(),
127
+ "failureCode" to error.failure.code.wireName,
128
+ "failureOperation" to error.failure.operation.wireName,
129
+ "failureRetryable" to error.failure.retryable.toString(),
130
+ "failureMessage" to error.failure.message,
131
+ "failureRecoveryAction" to error.failure.recoveryAction.wireName,
132
+ )
133
+ is SynchroError.InvalidStateTransition -> "INVALID_STATE_TRANSITION" to mapOf(
134
+ "from" to error.from.wireName,
135
+ "to" to error.to.wireName,
136
+ )
74
137
  is SynchroError.AlreadyStarted -> "ALREADY_STARTED" to emptyMap()
75
138
  is SynchroError.NotStarted -> "NOT_STARTED" to emptyMap()
76
139
  }
@@ -91,9 +154,29 @@ class SynchroModule(reactContext: ReactApplicationContext) :
91
154
  val maxRetryAttempts = if (config.hasKey("maxRetryAttempts")) config.getInt("maxRetryAttempts") else 5
92
155
  val pullPageSize = if (config.hasKey("pullPageSize")) config.getInt("pullPageSize") else 100
93
156
  val pushBatchSize = if (config.hasKey("pushBatchSize")) config.getInt("pushBatchSize") else 100
94
- val snapshotPageSize = if (config.hasKey("snapshotPageSize")) config.getInt("snapshotPageSize") else 100
157
+ val rawSeedPath = if (config.hasKey("seedDatabasePath")) config.getString("seedDatabasePath") else null
158
+ val transportCapacity = if (config.hasKey("transportObservationCapacity")) {
159
+ config.getInt("transportObservationCapacity")
160
+ } else {
161
+ 0
162
+ }
163
+ val requireNewDatabase = config.getBoolean("requireNewDatabase")
164
+ require(transportCapacity in 0..512) { "Invalid transport observation capacity" }
165
+ val nextTransportObservationCollector = if (transportCapacity == 0) {
166
+ null
167
+ } else {
168
+ TransportObservationCollector(transportCapacity)
169
+ }
95
170
 
96
- val synchroConfig = SynchroConfig(
171
+ val seedDatabasePath = rawSeedPath?.let { seedPath ->
172
+ if (File(seedPath).isAbsolute) {
173
+ seedPath
174
+ } else {
175
+ extractSeedAsset(seedPath)
176
+ }
177
+ }
178
+
179
+ val baseConfig = SynchroConfig(
97
180
  dbPath = dbPath,
98
181
  serverURL = serverURL,
99
182
  authProvider = {
@@ -118,19 +201,86 @@ class SynchroModule(reactContext: ReactApplicationContext) :
118
201
  maxRetryAttempts = maxRetryAttempts,
119
202
  pullPageSize = pullPageSize,
120
203
  pushBatchSize = pushBatchSize,
121
- snapshotPageSize = snapshotPageSize
204
+ seedDatabasePath = seedDatabasePath,
122
205
  )
123
-
124
- client?.close()
125
- clearRuntimeState()
126
- client = SynchroClient(synchroConfig, reactApplicationContext)
127
- wireClientEvents(client!!)
128
- promise.resolve(null)
206
+ val synchroConfig = nextTransportObservationCollector?.let(baseConfig::withTransportObservation)
207
+ ?: baseConfig
208
+
209
+ scope.launch {
210
+ lifecycleMutex.withLock {
211
+ try {
212
+ clearRuntimeState()
213
+ client?.close()
214
+ if (requireNewDatabase && reactApplicationContext.getDatabasePath(dbPath).exists()) {
215
+ promise.reject("INVALID_CONFIG", "Database already exists")
216
+ return@withLock
217
+ }
218
+ val nextClient = SynchroClient(synchroConfig, reactApplicationContext)
219
+ synchronized(transactionLock) {
220
+ client = nextClient
221
+ acceptingTransactions = true
222
+ }
223
+ transportObservationCollector = nextTransportObservationCollector
224
+ wireClientEvents(nextClient)
225
+ promise.resolve(null)
226
+ } catch (e: Exception) {
227
+ nextTransportObservationCollector?.cancelPauseBarrier()
228
+ if (rawSeedPath != null) {
229
+ promise.reject("INVALID_SEED", "Seed database failed validation", e)
230
+ } else {
231
+ rejectWithError(promise, e)
232
+ }
233
+ }
234
+ }
235
+ }
129
236
  } catch (e: Exception) {
130
- rejectWithError(promise, e)
237
+ promise.reject("INVALID_CONFIG", e.message ?: "Invalid configuration", e)
131
238
  }
132
239
  }
133
240
 
241
+ private fun extractSeedAsset(assetPath: String): String {
242
+ val cacheDirectory = File(reactApplicationContext.filesDir, "synchro-seed-assets")
243
+ check(cacheDirectory.exists() || cacheDirectory.mkdirs()) {
244
+ "Cannot create the seed asset cache"
245
+ }
246
+
247
+ val cacheName = UUID.nameUUIDFromBytes(assetPath.toByteArray(Charsets.UTF_8)).toString()
248
+ val destination = File(cacheDirectory, "$cacheName.db")
249
+ val packagedDigest = reactApplicationContext.assets.open(assetPath).use(::sha256)
250
+ val extractedDigest = if (destination.isFile) {
251
+ destination.inputStream().use(::sha256)
252
+ } else {
253
+ null
254
+ }
255
+
256
+ if (extractedDigest == null || !packagedDigest.contentEquals(extractedDigest)) {
257
+ val temporary = File(cacheDirectory, "$cacheName-${UUID.randomUUID()}.tmp")
258
+ try {
259
+ reactApplicationContext.assets.open(assetPath).use { input ->
260
+ temporary.outputStream().use { output -> input.copyTo(output) }
261
+ }
262
+ if (!temporary.renameTo(destination)) {
263
+ temporary.copyTo(destination, overwrite = true)
264
+ }
265
+ } finally {
266
+ temporary.delete()
267
+ }
268
+ }
269
+
270
+ return destination.absolutePath
271
+ }
272
+
273
+ private fun sha256(input: java.io.InputStream): ByteArray {
274
+ val digest = MessageDigest.getInstance("SHA-256")
275
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
276
+ while (true) {
277
+ val count = input.read(buffer)
278
+ if (count < 0) break
279
+ digest.update(buffer, 0, count)
280
+ }
281
+ return digest.digest()
282
+ }
283
+
134
284
  @ReactMethod
135
285
  override fun resolveAuthRequest(requestID: String, token: String) {
136
286
  pendingAuthContinuations.remove(requestID)?.resume(token) {}
@@ -143,20 +293,21 @@ class SynchroModule(reactContext: ReactApplicationContext) :
143
293
  )
144
294
  }
145
295
 
146
- @ReactMethod
147
- override fun resolveSnapshotRequest(requestID: String, approved: Boolean) {
148
- pendingSnapshotContinuations.remove(requestID)?.resume(approved) {}
149
- }
150
-
151
296
  @ReactMethod
152
297
  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)
298
+ scope.launch {
299
+ lifecycleMutex.withLock {
300
+ try {
301
+ clearRuntimeState()
302
+ client?.close()
303
+ synchronized(transactionLock) {
304
+ client = null
305
+ }
306
+ promise.resolve(null)
307
+ } catch (e: Exception) {
308
+ rejectWithError(promise, e)
309
+ }
310
+ }
160
311
  }
161
312
  }
162
313
 
@@ -172,44 +323,71 @@ class SynchroModule(reactContext: ReactApplicationContext) :
172
323
  // MARK: - Core SQL
173
324
 
174
325
  @ReactMethod
175
- override fun query(sql: String, paramsJson: String, promise: Promise) {
326
+ override fun query(sql: String, params: ReadableArray, promise: Promise) {
176
327
  val c = client ?: run {
177
328
  promise.reject("NOT_CONNECTED", "Client not initialized")
178
329
  return
179
330
  }
180
331
  try {
181
- val params = parseParams(paramsJson)
182
- val rows = c.query(sql, params)
183
- promise.resolve(rowsToJson(rows))
332
+ val rows = c.query(sql, parseParams(params))
333
+ promise.resolve(rowsToWritableArray(rows))
184
334
  } catch (e: Exception) {
185
335
  rejectWithError(promise, e)
186
336
  }
187
337
  }
188
338
 
189
339
  @ReactMethod
190
- override fun queryOne(sql: String, paramsJson: String, promise: Promise) {
340
+ override fun queryOne(sql: String, params: ReadableArray, promise: Promise) {
191
341
  val c = client ?: run {
192
342
  promise.reject("NOT_CONNECTED", "Client not initialized")
193
343
  return
194
344
  }
195
345
  try {
196
- val params = parseParams(paramsJson)
197
- val row = c.queryOne(sql, params)
198
- promise.resolve(row?.let { rowToJson(it) })
346
+ val row = c.queryOne(sql, parseParams(params))
347
+ promise.resolve(row?.let { rowToWritableMap(it) })
199
348
  } catch (e: Exception) {
200
349
  rejectWithError(promise, e)
201
350
  }
202
351
  }
203
352
 
204
353
  @ReactMethod
205
- override fun execute(sql: String, paramsJson: String, promise: Promise) {
354
+ override fun execute(sql: String, params: ReadableArray, promise: Promise) {
206
355
  val c = client ?: run {
207
356
  promise.reject("NOT_CONNECTED", "Client not initialized")
208
357
  return
209
358
  }
210
359
  try {
211
- val params = parseParams(paramsJson)
212
- val result = c.execute(sql, params)
360
+ val result = c.execute(sql, parseParams(params))
361
+ val map = Arguments.createMap().apply {
362
+ putInt("rowsAffected", result.rowsAffected)
363
+ }
364
+ promise.resolve(map)
365
+ } catch (e: Exception) {
366
+ rejectWithError(promise, e)
367
+ }
368
+ }
369
+
370
+ @ReactMethod
371
+ override fun executeAuthoredWrite(
372
+ tableName: String,
373
+ operation: String,
374
+ columnNames: ReadableArray,
375
+ sql: String,
376
+ values: ReadableArray,
377
+ promise: Promise,
378
+ ) {
379
+ val c = client ?: run {
380
+ promise.reject("NOT_CONNECTED", "Client not initialized")
381
+ return
382
+ }
383
+ try {
384
+ val authoredColumnNames = (0 until columnNames.size()).map { index ->
385
+ columnNames.getString(index)
386
+ ?: throw IllegalArgumentException("Missing column name at index $index")
387
+ }
388
+ val result = c.authoredWriteTransaction(tableName, operation, authoredColumnNames) { transaction ->
389
+ transaction.execute(sql, parseParams(values))
390
+ }
213
391
  val map = Arguments.createMap().apply {
214
392
  putInt("rowsAffected", result.rowsAffected)
215
393
  }
@@ -220,20 +398,19 @@ class SynchroModule(reactContext: ReactApplicationContext) :
220
398
  }
221
399
 
222
400
  @ReactMethod
223
- override fun executeBatch(statementsJson: String, promise: Promise) {
401
+ override fun executeBatch(statements: ReadableArray, promise: Promise) {
224
402
  val c = client ?: run {
225
403
  promise.reject("NOT_CONNECTED", "Client not initialized")
226
404
  return
227
405
  }
228
406
  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)
407
+ val nativeStatements = (0 until statements.size()).map { i ->
408
+ val item = statements.getMap(i) ?: throw IllegalArgumentException("Invalid SQL statement at index $i")
409
+ val sql = item.getString("sql") ?: throw IllegalArgumentException("Missing SQL at index $i")
410
+ val params = if (item.hasKey("params")) item.getArray("params") else null
411
+ SQLStatement(sql, params?.let { parseParams(it) } ?: emptyArray())
412
+ }
413
+ val total = c.executeBatch(nativeStatements)
237
414
  val map = Arguments.createMap().apply {
238
415
  putInt("totalRowsAffected", total)
239
416
  }
@@ -245,19 +422,58 @@ class SynchroModule(reactContext: ReactApplicationContext) :
245
422
 
246
423
  // MARK: - Transactions
247
424
 
248
- private class TransactionSession {
425
+ private class TransactionSession(val isWrite: Boolean) {
249
426
  val operations = Channel<TransactionOp>(Channel.RENDEZVOUS)
427
+ lateinit var job: Job
428
+ private var abortCause: Throwable? = null
429
+ private var terminal = false
430
+
431
+ @Synchronized
432
+ fun abort(cause: Throwable) {
433
+ if (terminal) return
434
+ terminal = true
435
+ abortCause = cause
436
+ operations.close(cause)
437
+ }
438
+
439
+ @Synchronized
440
+ fun completeNormally(): Boolean {
441
+ if (terminal) return false
442
+ terminal = true
443
+ operations.close()
444
+ return true
445
+ }
446
+
447
+ @Synchronized
448
+ fun <T> complete(deferred: CompletableDeferred<T>, value: T) {
449
+ val cause = abortCause
450
+ if (cause == null) {
451
+ deferred.complete(value)
452
+ } else {
453
+ deferred.completeExceptionally(cause)
454
+ }
455
+ }
456
+
457
+ @Synchronized
458
+ fun <T> completeExceptionally(deferred: CompletableDeferred<T>, error: Throwable) {
459
+ deferred.completeExceptionally(abortCause ?: error)
460
+ }
250
461
  }
251
462
 
252
463
  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()
464
+ data class Query(val sql: String, val params: Array<Any?>, val deferred: CompletableDeferred<WritableArray>) : TransactionOp()
465
+ data class QueryOne(val sql: String, val params: Array<Any?>, val deferred: CompletableDeferred<WritableMap?>) : TransactionOp()
255
466
  data class Execute(val sql: String, val params: Array<Any?>, val deferred: CompletableDeferred<WritableMap>) : TransactionOp()
256
467
  class Commit(val deferred: CompletableDeferred<Unit>) : TransactionOp()
257
468
  class Rollback(val deferred: CompletableDeferred<Unit>) : TransactionOp()
258
469
  }
259
470
 
260
- private class TransactionRollbackException : Exception("rollback")
471
+ private class TransactionRollbackException(
472
+ val completion: CompletableDeferred<Unit>,
473
+ ) : Exception("rollback")
474
+
475
+ private class TransactionAbortedException : Exception("Client closed during transaction")
476
+ private class TransactionExpiredException : Exception("Transaction timed out due to inactivity")
261
477
 
262
478
  @ReactMethod
263
479
  override fun beginWriteTransaction(promise: Promise) {
@@ -270,120 +486,149 @@ class SynchroModule(reactContext: ReactApplicationContext) :
270
486
  }
271
487
 
272
488
  private fun beginTransaction(isWrite: Boolean, promise: Promise) {
273
- val c = client ?: run {
489
+ val c = synchronized(transactionLock) {
490
+ client.takeIf { acceptingTransactions }
491
+ } ?: run {
274
492
  promise.reject("NOT_CONNECTED", "Client not initialized")
275
493
  return
276
494
  }
277
495
 
278
496
  val txID = UUID.randomUUID().toString()
279
- val session = TransactionSession()
280
- sessions[txID] = session
497
+ val session = TransactionSession(isWrite)
281
498
 
282
- scope.launch {
499
+ val job = scope.launch(start = CoroutineStart.LAZY) {
500
+ var finalDeferred: CompletableDeferred<Unit>? = null
283
501
  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
- }
502
+ if (isWrite) {
503
+ c.writeTransaction { transaction ->
504
+ finalDeferred = runTransactionLoop(
505
+ txID = txID,
506
+ session = session,
507
+ promise = promise,
508
+ query = transaction::query,
509
+ queryOne = transaction::queryOne,
510
+ execute = transaction::execute,
511
+ )
512
+ }
513
+ } else {
514
+ c.readTransaction { transaction ->
515
+ finalDeferred = runTransactionLoop(
516
+ txID = txID,
517
+ session = session,
518
+ promise = promise,
519
+ query = transaction::query,
520
+ queryOne = transaction::queryOne,
521
+ execute = { _, _ ->
522
+ throw IllegalStateException("read transactions cannot execute SQL")
523
+ },
524
+ )
334
525
  }
335
526
  }
527
+ finalDeferred?.complete(Unit)
528
+ } catch (e: TimeoutCancellationException) {
529
+ val timeout = TransactionExpiredException()
530
+ session.abort(timeout)
531
+ finalDeferred?.completeExceptionally(timeout)
532
+ } catch (e: TransactionRollbackException) {
533
+ e.completion.complete(Unit)
534
+ } catch (e: Exception) {
535
+ finalDeferred?.completeExceptionally(e)
536
+ } finally {
537
+ session.completeNormally()
538
+ sessions.remove(txID, session)
539
+ }
540
+ }
541
+ session.job = job
542
+ val registered = synchronized(transactionLock) {
543
+ if (acceptingTransactions && client === c) {
544
+ sessions[txID] = session
545
+ true
546
+ } else {
547
+ false
548
+ }
549
+ }
550
+ if (!registered) {
551
+ job.cancel()
552
+ promise.reject("NOT_CONNECTED", "Client not initialized")
553
+ return
554
+ }
555
+ job.start()
556
+ }
336
557
 
337
- if (isWrite) {
338
- c.writeTransaction { db -> txBlock(db) }
339
- } else {
340
- c.readTransaction { db -> txBlock(db) }
558
+ private fun runTransactionLoop(
559
+ txID: String,
560
+ session: TransactionSession,
561
+ promise: Promise,
562
+ query: (String, Array<out Any?>?) -> List<Row>,
563
+ queryOne: (String, Array<out Any?>?) -> Row?,
564
+ execute: (String, Array<out Any?>?) -> ExecResult,
565
+ ): CompletableDeferred<Unit>? {
566
+ promise.resolve(txID)
567
+ var finalDeferred: CompletableDeferred<Unit>? = null
568
+ runBlocking {
569
+ transactionLoop@ while (true) {
570
+ val result = withTimeout(5000) { session.operations.receiveCatching() }
571
+ if (result.isClosed) {
572
+ throw result.exceptionOrNull() ?: TransactionAbortedException()
341
573
  }
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)
574
+ when (val op = result.getOrThrow()) {
575
+ is TransactionOp.Query -> {
576
+ try {
577
+ session.complete(
578
+ op.deferred,
579
+ rowsToWritableArray(query(op.sql, op.params)),
580
+ )
581
+ } catch (e: Exception) {
582
+ session.completeExceptionally(op.deferred, e)
583
+ }
584
+ }
585
+ is TransactionOp.QueryOne -> {
586
+ try {
587
+ session.complete(
588
+ op.deferred,
589
+ queryOne(op.sql, op.params)?.let(::rowToWritableMap),
590
+ )
591
+ } catch (e: Exception) {
592
+ session.completeExceptionally(op.deferred, e)
593
+ }
594
+ }
595
+ is TransactionOp.Execute -> {
596
+ try {
597
+ val result = execute(op.sql, op.params)
598
+ val map = Arguments.createMap().apply {
599
+ putInt("rowsAffected", result.rowsAffected)
600
+ }
601
+ session.complete(op.deferred, map)
602
+ } catch (e: Exception) {
603
+ session.completeExceptionally(op.deferred, e)
604
+ }
605
+ }
606
+ is TransactionOp.Commit -> {
607
+ finalDeferred = op.deferred
608
+ if (!session.completeNormally()) throw TransactionAbortedException()
609
+ break@transactionLoop
610
+ }
611
+ is TransactionOp.Rollback -> {
612
+ finalDeferred = op.deferred
613
+ if (!session.completeNormally()) throw TransactionAbortedException()
614
+ throw TransactionRollbackException(op.deferred)
368
615
  }
369
616
  }
370
- rows.add(row)
371
617
  }
372
618
  }
373
- return rows
619
+ return finalDeferred
374
620
  }
375
621
 
376
622
  @ReactMethod
377
- override fun txQuery(txID: String, sql: String, paramsJson: String, promise: Promise) {
623
+ override fun txQuery(txID: String, sql: String, params: ReadableArray, promise: Promise) {
378
624
  val session = sessions[txID] ?: run {
379
625
  promise.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired")
380
626
  return
381
627
  }
382
628
  scope.launch {
383
629
  try {
384
- val params = parseParams(paramsJson)
385
- val deferred = CompletableDeferred<String>()
386
- session.operations.send(TransactionOp.Query(sql, params, deferred))
630
+ val deferred = CompletableDeferred<WritableArray>()
631
+ session.operations.send(TransactionOp.Query(sql, parseParams(params), deferred))
387
632
  promise.resolve(deferred.await())
388
633
  } catch (e: Exception) {
389
634
  rejectWithError(promise, e)
@@ -392,16 +637,15 @@ class SynchroModule(reactContext: ReactApplicationContext) :
392
637
  }
393
638
 
394
639
  @ReactMethod
395
- override fun txQueryOne(txID: String, sql: String, paramsJson: String, promise: Promise) {
640
+ override fun txQueryOne(txID: String, sql: String, params: ReadableArray, promise: Promise) {
396
641
  val session = sessions[txID] ?: run {
397
642
  promise.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired")
398
643
  return
399
644
  }
400
645
  scope.launch {
401
646
  try {
402
- val params = parseParams(paramsJson)
403
- val deferred = CompletableDeferred<String?>()
404
- session.operations.send(TransactionOp.QueryOne(sql, params, deferred))
647
+ val deferred = CompletableDeferred<WritableMap?>()
648
+ session.operations.send(TransactionOp.QueryOne(sql, parseParams(params), deferred))
405
649
  promise.resolve(deferred.await())
406
650
  } catch (e: Exception) {
407
651
  rejectWithError(promise, e)
@@ -410,16 +654,19 @@ class SynchroModule(reactContext: ReactApplicationContext) :
410
654
  }
411
655
 
412
656
  @ReactMethod
413
- override fun txExecute(txID: String, sql: String, paramsJson: String, promise: Promise) {
657
+ override fun txExecute(txID: String, sql: String, params: ReadableArray, promise: Promise) {
414
658
  val session = sessions[txID] ?: run {
415
659
  promise.reject("TRANSACTION_TIMEOUT", "Transaction not found or expired")
416
660
  return
417
661
  }
662
+ if (!session.isWrite) {
663
+ promise.reject("DATABASE_ERROR", "Read transactions cannot execute SQL")
664
+ return
665
+ }
418
666
  scope.launch {
419
667
  try {
420
- val params = parseParams(paramsJson)
421
668
  val deferred = CompletableDeferred<WritableMap>()
422
- session.operations.send(TransactionOp.Execute(sql, params, deferred))
669
+ session.operations.send(TransactionOp.Execute(sql, parseParams(params), deferred))
423
670
  promise.resolve(deferred.await())
424
671
  } catch (e: Exception) {
425
672
  rejectWithError(promise, e)
@@ -534,7 +781,7 @@ class SynchroModule(reactContext: ReactApplicationContext) :
534
781
  override fun addQueryObserver(
535
782
  observerID: String,
536
783
  sql: String,
537
- paramsJson: String,
784
+ params: ReadableArray,
538
785
  tables: ReadableArray,
539
786
  promise: Promise
540
787
  ) {
@@ -543,12 +790,12 @@ class SynchroModule(reactContext: ReactApplicationContext) :
543
790
  return
544
791
  }
545
792
  try {
546
- val params = parseParams(paramsJson)
793
+ val nativeParams = parseParams(params)
547
794
  val tableList = (0 until tables.size()).mapNotNull { tables.getString(it) }
548
- val cancellable = c.watch(sql, params, tableList) { rows ->
795
+ val cancellable = c.watch(sql, nativeParams, tableList) { rows ->
549
796
  val eventParams = Arguments.createMap().apply {
550
797
  putString("observerID", observerID)
551
- putString("rowsJson", rowsToJson(rows))
798
+ putArray("rows", rowsToWritableArray(rows))
552
799
  }
553
800
  emitOnQueryResult(eventParams)
554
801
  }
@@ -565,37 +812,31 @@ class SynchroModule(reactContext: ReactApplicationContext) :
565
812
  promise.resolve(null)
566
813
  }
567
814
 
568
- // MARK: - WAL / Sync
569
-
570
815
  @ReactMethod
571
- override fun checkpoint(mode: String, promise: Promise) {
816
+ override fun start(promise: Promise) {
572
817
  val c = client ?: run {
573
818
  promise.reject("NOT_CONNECTED", "Client not initialized")
574
819
  return
575
820
  }
576
- try {
577
- val checkpointMode = when (mode) {
578
- "full" -> CheckpointMode.FULL
579
- "restart" -> CheckpointMode.RESTART
580
- "truncate" -> CheckpointMode.TRUNCATE
581
- else -> CheckpointMode.PASSIVE
821
+ scope.launch {
822
+ try {
823
+ c.start()
824
+ promise.resolve(null)
825
+ } catch (e: Exception) {
826
+ rejectWithError(promise, e)
582
827
  }
583
- c.checkpoint(checkpointMode)
584
- promise.resolve(null)
585
- } catch (e: Exception) {
586
- rejectWithError(promise, e)
587
828
  }
588
829
  }
589
830
 
590
831
  @ReactMethod
591
- override fun start(promise: Promise) {
832
+ override fun stop(promise: Promise) {
592
833
  val c = client ?: run {
593
834
  promise.reject("NOT_CONNECTED", "Client not initialized")
594
835
  return
595
836
  }
596
837
  scope.launch {
597
838
  try {
598
- c.start()
839
+ c.stop()
599
840
  promise.resolve(null)
600
841
  } catch (e: Exception) {
601
842
  rejectWithError(promise, e)
@@ -604,19 +845,65 @@ class SynchroModule(reactContext: ReactApplicationContext) :
604
845
  }
605
846
 
606
847
  @ReactMethod
607
- override fun stop(promise: Promise) {
848
+ override fun enterBackground(promise: Promise) {
608
849
  val c = client ?: run {
609
850
  promise.reject("NOT_CONNECTED", "Client not initialized")
610
851
  return
611
852
  }
612
853
  try {
613
- c.stop()
854
+ c.onApplicationBackground()
614
855
  promise.resolve(null)
615
856
  } catch (e: Exception) {
616
857
  rejectWithError(promise, e)
617
858
  }
618
859
  }
619
860
 
861
+ @ReactMethod
862
+ override fun enterForeground(promise: Promise) {
863
+ val c = client ?: run {
864
+ promise.reject("NOT_CONNECTED", "Client not initialized")
865
+ return
866
+ }
867
+ try {
868
+ c.onApplicationForeground()
869
+ promise.resolve(null)
870
+ } catch (e: Exception) {
871
+ rejectWithError(promise, e)
872
+ }
873
+ }
874
+
875
+ @ReactMethod
876
+ override fun retryAfterError(promise: Promise) {
877
+ val c = client ?: run {
878
+ promise.reject("NOT_CONNECTED", "Client not initialized")
879
+ return
880
+ }
881
+ scope.launch {
882
+ try {
883
+ c.retry()
884
+ promise.resolve(null)
885
+ } catch (e: Exception) {
886
+ rejectWithError(promise, e)
887
+ }
888
+ }
889
+ }
890
+
891
+ @ReactMethod
892
+ override fun resetSchemaAndStart(promise: Promise) {
893
+ val c = client ?: run {
894
+ promise.reject("NOT_CONNECTED", "Client not initialized")
895
+ return
896
+ }
897
+ scope.launch {
898
+ try {
899
+ c.resetSchema()
900
+ promise.resolve(null)
901
+ } catch (e: Exception) {
902
+ rejectWithError(promise, e)
903
+ }
904
+ }
905
+ }
906
+
620
907
  @ReactMethod
621
908
  override fun syncNow(promise: Promise) {
622
909
  val c = client ?: run {
@@ -646,6 +933,241 @@ class SynchroModule(reactContext: ReactApplicationContext) :
646
933
  }
647
934
  }
648
935
 
936
+ @ReactMethod
937
+ override fun getSyncStatus(promise: Promise) {
938
+ val c = client ?: run {
939
+ promise.reject("NOT_CONNECTED", "Client not initialized")
940
+ return
941
+ }
942
+ try {
943
+ promise.resolve(syncStatusJson(c.getSyncStatus()).toString())
944
+ } catch (e: Exception) {
945
+ rejectWithError(promise, e)
946
+ }
947
+ }
948
+
949
+ @ReactMethod
950
+ override fun inspectPendingMutations(promise: Promise) {
951
+ val c = client ?: run {
952
+ promise.reject("NOT_CONNECTED", "Client not initialized")
953
+ return
954
+ }
955
+ try {
956
+ promise.resolve(JSONArray(c.inspectPendingMutations().map(::pendingMutationJson)).toString())
957
+ } catch (e: Exception) {
958
+ rejectWithError(promise, e)
959
+ }
960
+ }
961
+
962
+ @ReactMethod
963
+ override fun inspectRetainedMutations(promise: Promise) {
964
+ val c = client ?: run {
965
+ promise.reject("NOT_CONNECTED", "Client not initialized")
966
+ return
967
+ }
968
+ try {
969
+ promise.resolve(JSONArray(c.inspectRetainedMutations().map(::pendingMutationJson)).toString())
970
+ } catch (e: Exception) {
971
+ rejectWithError(promise, e)
972
+ }
973
+ }
974
+
975
+ @ReactMethod
976
+ override fun inspectRejectedMutations(promise: Promise) {
977
+ val c = client ?: run {
978
+ promise.reject("NOT_CONNECTED", "Client not initialized")
979
+ return
980
+ }
981
+ try {
982
+ promise.resolve(JSONArray(c.inspectRejectedMutations().map(::rejectedMutationJson)).toString())
983
+ } catch (e: Exception) {
984
+ rejectWithError(promise, e)
985
+ }
986
+ }
987
+
988
+ @ReactMethod
989
+ override fun inspectClientState(promise: Promise) {
990
+ val c = client ?: run {
991
+ promise.reject("NOT_CONNECTED", "Client not initialized")
992
+ return
993
+ }
994
+ try {
995
+ val inspection = SynchroInspection(c)
996
+ val capture = inspection.captureState(maximumRecords = 512)
997
+ val schema: Any = capture.schema?.let {
998
+ JSONObject().put("version", it.version).put("hash", it.hash)
999
+ } ?: JSONObject.NULL
1000
+ val scopeStates = JSONArray(capture.scopeStates.map { value ->
1001
+ JSONObject().apply {
1002
+ put("scope_id", value.scopeID)
1003
+ put("cursor", value.cursor ?: JSONObject.NULL)
1004
+ put("checksum", value.checksum ?: JSONObject.NULL)
1005
+ put("local_checksum", value.localChecksum)
1006
+ put("generation", value.generation)
1007
+ }
1008
+ })
1009
+ val scopeRows = JSONArray(capture.scopeRows.map { value ->
1010
+ JSONObject().apply {
1011
+ put("scope_id", value.scopeID)
1012
+ put("table_name", value.tableName)
1013
+ put("record_id", value.recordID)
1014
+ put("checksum", value.checksum)
1015
+ put("generation", value.generation)
1016
+ }
1017
+ })
1018
+ val attempts = JSONArray(capture.rebuildAttempts.map { value ->
1019
+ JSONObject().apply {
1020
+ put("scope_id", value.scopeID)
1021
+ put("rebuild_id", value.rebuildID)
1022
+ put("client_generation", value.clientGeneration)
1023
+ put("schema_version", value.schemaVersion)
1024
+ put("schema_hash", value.schemaHash)
1025
+ put("generation", value.generation)
1026
+ put("cursor", value.cursor ?: JSONObject.NULL)
1027
+ put("page_limit", value.pageLimit)
1028
+ }
1029
+ })
1030
+ promise.resolve(JSONObject().apply {
1031
+ put("schema", schema)
1032
+ put("scope_states", scopeStates)
1033
+ put("scope_rows", scopeRows)
1034
+ put("rebuild_attempts", attempts)
1035
+ put("application_row_count", capture.applicationRowCount)
1036
+ put("mutation_ledger_count", capture.mutationLedgerCount)
1037
+ put("mutation_outcome_count", capture.mutationOutcomeCount)
1038
+ put("sealed_batch_count", capture.sealedBatchCount)
1039
+ put("rejected_mutation_count", capture.rejectedMutationCount)
1040
+ put("scope_state_count", capture.scopeStateCount)
1041
+ put("scope_row_count", capture.scopeRowCount)
1042
+ put("provenance_count", capture.provenanceCount)
1043
+ put("row_metadata_count", capture.rowMetadataCount)
1044
+ put("rebuild_attempt_count", capture.rebuildAttemptCount)
1045
+ put("rebuild_receipt_count", capture.rebuildReceiptCount)
1046
+ put(
1047
+ "provenance_maintenance_work_cursor",
1048
+ capture.provenanceMaintenanceWorkCursor.toString(),
1049
+ )
1050
+ }.toString())
1051
+ } catch (error: Exception) {
1052
+ rejectWithError(promise, error)
1053
+ }
1054
+ }
1055
+
1056
+ @ReactMethod
1057
+ override fun inspectDurableState(tableName: String, recordID: String, promise: Promise) {
1058
+ val c = client ?: run {
1059
+ promise.reject("NOT_CONNECTED", "Client not initialized")
1060
+ return
1061
+ }
1062
+ try {
1063
+ val inspection = SynchroInspection(c)
1064
+ val metadataJson: Any = inspection.rowMetadata(tableName, recordID)?.let { value ->
1065
+ JSONObject().apply {
1066
+ put("table_name", value.tableName)
1067
+ put("record_id", value.recordID)
1068
+ put("server_version", value.serverVersion)
1069
+ put("row_checksum", value.rowChecksum ?: JSONObject.NULL)
1070
+ }
1071
+ } ?: JSONObject.NULL
1072
+ val receipts = JSONArray(inspection.rebuildReceipts().map { value ->
1073
+ JSONObject().apply {
1074
+ put("rebuild_id_fingerprint", value.rebuildIDFingerprint)
1075
+ put("page_count", value.pageCount)
1076
+ put("returned_record_count", value.returnedRecordCount)
1077
+ put("request_chain_expected", JSONArray(value.requestChainExpected))
1078
+ put("request_chain_observed", JSONArray(value.requestChainObserved))
1079
+ put("record_identities_hex", JSONArray(value.recordIdentitiesHex))
1080
+ put("received_row_checksums", JSONArray(value.receivedRowChecksums))
1081
+ put("computed_row_checksums", JSONArray(value.computedRowChecksums))
1082
+ put("computed_scope_checksum", value.computedScopeChecksum ?: JSONObject.NULL)
1083
+ put("final_scope_checksum", value.finalScopeChecksum ?: JSONObject.NULL)
1084
+ put("stored_scope_checksum", value.storedScopeChecksum ?: JSONObject.NULL)
1085
+ put("local_scope_checksum", value.localScopeChecksum ?: JSONObject.NULL)
1086
+ }
1087
+ })
1088
+ promise.resolve(JSONObject().apply {
1089
+ put("row_metadata", metadataJson)
1090
+ put("rebuild_receipts", receipts)
1091
+ }.toString())
1092
+ } catch (error: Exception) {
1093
+ rejectWithError(promise, error)
1094
+ }
1095
+ }
1096
+
1097
+ @ReactMethod
1098
+ override fun inspectTransportObservations(promise: Promise) {
1099
+ val collector = transportObservationCollector ?: run {
1100
+ promise.reject("NOT_CONNECTED", "Transport observation is not configured")
1101
+ return
1102
+ }
1103
+ promise.resolve(
1104
+ Json { explicitNulls = false }.encodeToString(
1105
+ TransportObservationSnapshot.serializer(),
1106
+ collector.snapshot(),
1107
+ ),
1108
+ )
1109
+ }
1110
+
1111
+ @ReactMethod
1112
+ override fun armTransportPause(operationClass: String, promise: Promise) {
1113
+ try {
1114
+ val collector = transportObservationCollector ?: error("Transport observation is not configured")
1115
+ collector.armPause(transportOperationClass(operationClass))
1116
+ promise.resolve(null)
1117
+ } catch (error: Exception) {
1118
+ rejectWithError(promise, error)
1119
+ }
1120
+ }
1121
+
1122
+ @ReactMethod
1123
+ override fun awaitTransportPause(operationClass: String, timeoutMs: Double, promise: Promise) {
1124
+ scope.launch {
1125
+ try {
1126
+ require(
1127
+ timeoutMs.isFinite() &&
1128
+ timeoutMs >= 1 &&
1129
+ timeoutMs <= 60_000 &&
1130
+ timeoutMs % 1.0 == 0.0,
1131
+ ) { "Transport pause timeout is invalid" }
1132
+ val collector = transportObservationCollector ?: error("Transport observation is not configured")
1133
+ collector.awaitPause(transportOperationClass(operationClass), timeoutMs.toLong())
1134
+ promise.resolve(null)
1135
+ } catch (error: Exception) {
1136
+ rejectWithError(promise, error)
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ @ReactMethod
1142
+ override fun resumeTransportPause(promise: Promise) {
1143
+ try {
1144
+ val collector = transportObservationCollector ?: error("Transport observation is not configured")
1145
+ collector.resumePause()
1146
+ promise.resolve(null)
1147
+ } catch (error: Exception) {
1148
+ rejectWithError(promise, error)
1149
+ }
1150
+ }
1151
+
1152
+ @ReactMethod
1153
+ override fun getProcessIdentity(promise: Promise) {
1154
+ promise.resolve("android-app:${Process.myPid()}")
1155
+ }
1156
+
1157
+ @ReactMethod
1158
+ override fun clearRejectedMutations(promise: Promise) {
1159
+ val c = client ?: run {
1160
+ promise.reject("NOT_CONNECTED", "Client not initialized")
1161
+ return
1162
+ }
1163
+ try {
1164
+ c.clearRejectedMutations()
1165
+ promise.resolve(null)
1166
+ } catch (e: Exception) {
1167
+ rejectWithError(promise, e)
1168
+ }
1169
+ }
1170
+
649
1171
  // Event listener registration (required by RN)
650
1172
  @ReactMethod
651
1173
  override fun addListener(eventName: String) { /* no-op */ }
@@ -657,31 +1179,15 @@ class SynchroModule(reactContext: ReactApplicationContext) :
657
1179
 
658
1180
  private fun wireClientEvents(client: SynchroClient) {
659
1181
  statusSubscription?.cancel()
1182
+ syncEventSubscription?.cancel()
660
1183
  conflictSubscription?.cancel()
661
- snapshotSubscription?.cancel()
662
1184
 
663
1185
  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)
1186
+ emitOnStatusChange(statusPayload(status))
1187
+ }
1188
+
1189
+ syncEventSubscription = client.onSyncEvent { event ->
1190
+ emitOnSyncEvent(syncEventPayload(event))
685
1191
  }
686
1192
 
687
1193
  conflictSubscription = client.onConflict { event ->
@@ -693,37 +1199,305 @@ class SynchroModule(reactContext: ReactApplicationContext) :
693
1199
  }
694
1200
  emitOnConflict(params)
695
1201
  }
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
1202
  }
711
1203
 
712
- private fun clearRuntimeState() {
1204
+ private suspend fun clearRuntimeState() {
713
1205
  statusSubscription?.cancel()
1206
+ syncEventSubscription?.cancel()
714
1207
  conflictSubscription?.cancel()
715
- snapshotSubscription?.cancel()
716
1208
  statusSubscription = null
1209
+ syncEventSubscription = null
717
1210
  conflictSubscription = null
718
- snapshotSubscription = null
1211
+ transportObservationCollector?.cancelPauseBarrier()
1212
+ transportObservationCollector = null
719
1213
  observers.values.forEach { it.cancel() }
720
1214
  observers.clear()
721
- sessions.values.forEach { it.operations.close() }
722
- sessions.clear()
1215
+ val activeSessions = synchronized(transactionLock) {
1216
+ acceptingTransactions = false
1217
+ sessions.values.toList().also { sessions.clear() }
1218
+ }
1219
+ activeSessions.forEach { it.abort(TransactionAbortedException()) }
1220
+ activeSessions.map { it.job }.joinAll()
723
1221
  pendingAuthContinuations.values.forEach { it.cancel(CancellationException("client closed")) }
724
1222
  pendingAuthContinuations.clear()
725
- pendingSnapshotContinuations.values.forEach { it.cancel(CancellationException("client closed")) }
726
- pendingSnapshotContinuations.clear()
1223
+ }
1224
+
1225
+ private fun transportOperationClass(value: String): TransportOperationClass =
1226
+ TransportOperationClass.fromWire(value) ?: error("transport operation is invalid")
1227
+
1228
+ private fun rejectedMutationsJson(results: List<RejectedMutation>): String {
1229
+ return JSONArray(results.map { result ->
1230
+ JSONObject().apply {
1231
+ put("mutationID", result.mutationID)
1232
+ put("table", result.table)
1233
+ put("pk", jsonObjectToJsonObject(result.pk))
1234
+ put("status", mutationStatusWireValue(result.status))
1235
+ put("code", mutationRejectionCodeWireValue(result.code))
1236
+ put("message", result.message ?: JSONObject.NULL)
1237
+ put("serverRow", result.serverRow?.let { jsonObjectToJsonObject(it) } ?: JSONObject.NULL)
1238
+ put("serverVersion", result.serverVersion ?: JSONObject.NULL)
1239
+ }
1240
+ }).toString()
1241
+ }
1242
+
1243
+ private fun failurePayload(failure: SyncFailure): WritableMap = Arguments.createMap().apply {
1244
+ putString("operation", failure.operation.wireName)
1245
+ putString("code", failure.code.wireName)
1246
+ putBoolean("retryable", failure.retryable)
1247
+ putString("message", failure.message)
1248
+ putString("recoveryAction", failure.recoveryAction.wireName)
1249
+ putMap("metadata", Arguments.createMap().apply {
1250
+ failure.metadata.forEach { (key, value) -> putString(key, value) }
1251
+ })
1252
+ }
1253
+
1254
+ private fun statusPayload(status: SyncStatus): WritableMap = Arguments.createMap().apply {
1255
+ putString("status", status.state.wireName)
1256
+ putNull("retryAt")
1257
+ putNull("operation")
1258
+ putNull("failure")
1259
+ when (status) {
1260
+ is SyncStatus.Uninitialized -> Unit
1261
+ is SyncStatus.LocalReady -> Unit
1262
+ is SyncStatus.Connecting -> Unit
1263
+ is SyncStatus.SchemaApplying -> Unit
1264
+ is SyncStatus.Ready -> Unit
1265
+ is SyncStatus.Pushing -> Unit
1266
+ is SyncStatus.Pulling -> Unit
1267
+ is SyncStatus.Rebuilding -> Unit
1268
+ is SyncStatus.Backoff -> {
1269
+ putString("retryAt", status.retryAt.toString())
1270
+ putString("operation", status.operation)
1271
+ }
1272
+ is SyncStatus.Error -> putMap("failure", failurePayload(status.failure))
1273
+ is SyncStatus.Stopped -> Unit
1274
+ }
1275
+ }
1276
+
1277
+ private fun schemaPayload(schema: SchemaRef): WritableMap = Arguments.createMap().apply {
1278
+ putDouble("version", schema.version.toDouble())
1279
+ putString("hash", schema.hash)
1280
+ }
1281
+
1282
+ private fun syncEventPayload(event: SyncEvent): WritableMap {
1283
+ val payload = Arguments.createMap().apply {
1284
+ putString("type", "")
1285
+ putNull("from")
1286
+ putNull("to")
1287
+ putNull("operation")
1288
+ putNull("attempt")
1289
+ putNull("retryAt")
1290
+ putNull("source")
1291
+ putNull("target")
1292
+ putNull("action")
1293
+ putNull("mutationID")
1294
+ putNull("tableID")
1295
+ putNull("mutationStatus")
1296
+ putNull("rejectionCode")
1297
+ putNull("scopeID")
1298
+ putNull("rebuildID")
1299
+ putNull("failure")
1300
+ }
1301
+ when (event) {
1302
+ is SyncEvent.StateChanged -> {
1303
+ payload.putString("type", "state_changed")
1304
+ payload.putString("from", event.change.from.wireName)
1305
+ payload.putString("to", event.change.to.wireName)
1306
+ }
1307
+ is SyncEvent.MutationAccepted -> {
1308
+ payload.putString("type", "mutation_accepted")
1309
+ payload.putString("mutationID", event.mutation.mutationID)
1310
+ payload.putString("tableID", event.mutation.tableID)
1311
+ payload.putString("mutationStatus", mutationStatusWireValue(event.mutation.status))
1312
+ event.mutation.rejectionCode?.let { code ->
1313
+ payload.putString("rejectionCode", mutationRejectionCodeWireValue(code))
1314
+ }
1315
+ }
1316
+ is SyncEvent.MutationRejected -> {
1317
+ payload.putString("type", "mutation_rejected")
1318
+ payload.putString("mutationID", event.mutation.mutationID)
1319
+ payload.putString("tableID", event.mutation.tableID)
1320
+ payload.putString("mutationStatus", mutationStatusWireValue(event.mutation.status))
1321
+ event.mutation.rejectionCode?.let { code ->
1322
+ payload.putString("rejectionCode", mutationRejectionCodeWireValue(code))
1323
+ }
1324
+ }
1325
+ is SyncEvent.SchemaApplying -> {
1326
+ payload.putString("type", "schema_applying")
1327
+ payload.putMap("source", schemaPayload(event.schema.source))
1328
+ payload.putMap("target", schemaPayload(event.schema.target))
1329
+ payload.putString("action", schemaActionWireValue(event.schema.action))
1330
+ }
1331
+ is SyncEvent.SchemaApplied -> {
1332
+ payload.putString("type", "schema_applied")
1333
+ payload.putMap("source", schemaPayload(event.schema.source))
1334
+ payload.putMap("target", schemaPayload(event.schema.target))
1335
+ payload.putString("action", schemaActionWireValue(event.schema.action))
1336
+ }
1337
+ is SyncEvent.RebuildRequested -> {
1338
+ payload.putString("type", "rebuild_requested")
1339
+ payload.putString("scopeID", event.rebuild.scopeID)
1340
+ payload.putString("rebuildID", event.rebuild.rebuildID)
1341
+ }
1342
+ is SyncEvent.RebuildCompleted -> {
1343
+ payload.putString("type", "rebuild_completed")
1344
+ payload.putString("scopeID", event.rebuild.scopeID)
1345
+ payload.putString("rebuildID", event.rebuild.rebuildID)
1346
+ }
1347
+ is SyncEvent.Backoff -> {
1348
+ payload.putString("type", "backoff")
1349
+ payload.putString("operation", event.backoff.operation.wireName)
1350
+ payload.putDouble("attempt", event.backoff.attempt.toDouble())
1351
+ payload.putString("retryAt", event.backoff.retryAt.toString())
1352
+ }
1353
+ is SyncEvent.Failure -> {
1354
+ payload.putString("type", "failure")
1355
+ payload.putMap("failure", failurePayload(event.failure))
1356
+ }
1357
+ }
1358
+ return payload
1359
+ }
1360
+
1361
+ private fun syncStatusJson(status: SyncStatus): JSONObject = JSONObject().apply {
1362
+ put("status", status.state.wireName)
1363
+ put("retryAt", JSONObject.NULL)
1364
+ put("operation", JSONObject.NULL)
1365
+ put("failure", JSONObject.NULL)
1366
+ when (status) {
1367
+ is SyncStatus.Uninitialized -> Unit
1368
+ is SyncStatus.LocalReady -> Unit
1369
+ is SyncStatus.Connecting -> Unit
1370
+ is SyncStatus.SchemaApplying -> Unit
1371
+ is SyncStatus.Ready -> Unit
1372
+ is SyncStatus.Pushing -> Unit
1373
+ is SyncStatus.Pulling -> Unit
1374
+ is SyncStatus.Rebuilding -> Unit
1375
+ is SyncStatus.Backoff -> {
1376
+ put("retryAt", status.retryAt.toString())
1377
+ put("operation", status.operation)
1378
+ }
1379
+ is SyncStatus.Error -> put("failure", failureJson(status.failure))
1380
+ is SyncStatus.Stopped -> Unit
1381
+ }
1382
+ }
1383
+
1384
+ private fun failureJson(failure: SyncFailure): JSONObject = JSONObject().apply {
1385
+ put("operation", failure.operation.wireName)
1386
+ put("code", failure.code.wireName)
1387
+ put("retryable", failure.retryable)
1388
+ put("message", failure.message)
1389
+ put("recoveryAction", failure.recoveryAction.wireName)
1390
+ put("metadata", JSONObject().apply {
1391
+ failure.metadata.forEach { (key, value) -> put(key, value) }
1392
+ })
1393
+ }
1394
+
1395
+ private fun pendingMutationJson(mutation: PendingMutationInspection): JSONObject = JSONObject().apply {
1396
+ put("mutationID", mutation.mutationID)
1397
+ put("localOrder", mutation.localOrder)
1398
+ put("tableID", mutation.tableID)
1399
+ put("tableName", mutation.tableName)
1400
+ put("recordID", mutation.recordID)
1401
+ put("primaryKeyFieldID", mutation.primaryKeyFieldID)
1402
+ put("primaryKeyLogicalType", mutation.primaryKeyLogicalType)
1403
+ put("operation", operationWireValue(mutation.operation))
1404
+ put("authoredSchema", JSONObject().apply {
1405
+ put("version", mutation.authoredSchema.version)
1406
+ put("hash", mutation.authoredSchema.hash)
1407
+ })
1408
+ put("baseVersion", mutation.baseVersion ?: JSONObject.NULL)
1409
+ put("clientVersion", mutation.clientVersion)
1410
+ put("status", localMutationStatusWireValue(mutation.status))
1411
+ put("sourceKind", mutation.sourceKind)
1412
+ put("dependsOnMutationID", mutation.dependsOnMutationID ?: JSONObject.NULL)
1413
+ put("normalizedMutationID", mutation.normalizedMutationID ?: JSONObject.NULL)
1414
+ put("sealedBatchID", mutation.sealedBatchID ?: JSONObject.NULL)
1415
+ put("sealedOrdinal", mutation.sealedOrdinal ?: JSONObject.NULL)
1416
+ put("authoredFields", JSONArray(mutation.authoredFields.map { field ->
1417
+ JSONObject().apply {
1418
+ put("fieldID", field.fieldID)
1419
+ put("logicalType", field.logicalType)
1420
+ put("value", anyCodableToJsonValue(field.value.value))
1421
+ }
1422
+ }))
1423
+ }
1424
+
1425
+ private fun rejectedMutationJson(mutation: RejectedMutationInspection): JSONObject = JSONObject().apply {
1426
+ put("mutationID", mutation.mutationID)
1427
+ put("tableName", mutation.tableName)
1428
+ put("recordID", mutation.recordID)
1429
+ put("status", mutationStatusWireValue(mutation.status))
1430
+ put("code", mutationRejectionCodeWireValue(mutation.code))
1431
+ put("message", mutation.message ?: JSONObject.NULL)
1432
+ put("serverRowJSON", mutation.serverRowJSON ?: JSONObject.NULL)
1433
+ put("serverVersion", mutation.serverVersion ?: JSONObject.NULL)
1434
+ put("mutationJSON", mutation.mutationJSON)
1435
+ put("rejectionJSON", mutation.rejectionJSON)
1436
+ put("createdAt", mutation.createdAt)
1437
+ put("updatedAt", mutation.updatedAt)
1438
+ }
1439
+
1440
+ private fun operationWireValue(operation: Operation): String = when (operation) {
1441
+ Operation.INSERT -> "insert"
1442
+ Operation.UPSERT -> "upsert"
1443
+ Operation.UPDATE -> "update"
1444
+ Operation.DELETE -> "delete"
1445
+ }
1446
+
1447
+ private fun localMutationStatusWireValue(status: LocalMutationStatus): String = when (status) {
1448
+ LocalMutationStatus.PENDING -> "pending"
1449
+ LocalMutationStatus.SEALED -> "sealed"
1450
+ LocalMutationStatus.SUPERSEDED_BEFORE_SEND -> "superseded_before_send"
1451
+ LocalMutationStatus.CANCELLED_BEFORE_SEND -> "cancelled_before_send"
1452
+ LocalMutationStatus.BLOCKED_BY_PREDECESSOR -> "blocked_by_predecessor"
1453
+ LocalMutationStatus.SERVER_REJECTED -> "server_rejected"
1454
+ }
1455
+
1456
+ private fun mutationStatusWireValue(status: MutationStatus): String = when (status) {
1457
+ MutationStatus.APPLIED -> "applied"
1458
+ MutationStatus.CONFLICT -> "conflict"
1459
+ MutationStatus.REJECTED_TERMINAL -> "rejected_terminal"
1460
+ }
1461
+
1462
+ private fun schemaActionWireValue(action: SchemaAction): String = when (action) {
1463
+ SchemaAction.NONE -> "none"
1464
+ SchemaAction.REPLACE -> "replace"
1465
+ SchemaAction.REBUILD_LOCAL -> "rebuild_local"
1466
+ SchemaAction.UNSUPPORTED -> "unsupported"
1467
+ }
1468
+
1469
+ private fun mutationRejectionCodeWireValue(code: MutationRejectionCode): String = when (code) {
1470
+ MutationRejectionCode.VERSION_CONFLICT -> "version_conflict"
1471
+ MutationRejectionCode.ROW_ALREADY_EXISTS -> "row_already_exists"
1472
+ MutationRejectionCode.ROW_DELETED -> "row_deleted"
1473
+ MutationRejectionCode.ROW_NOT_FOUND -> "row_not_found"
1474
+ MutationRejectionCode.SCHEMA_INCOMPATIBLE -> "schema_incompatible"
1475
+ MutationRejectionCode.POLICY_REJECTED -> "policy_rejected"
1476
+ MutationRejectionCode.VALIDATION_FAILED -> "validation_failed"
1477
+ MutationRejectionCode.TABLE_NOT_SYNCED -> "table_not_synced"
1478
+ }
1479
+
1480
+ private fun jsonObjectToJsonObject(value: JsonObject): JSONObject {
1481
+ val obj = JSONObject()
1482
+ value.forEach { (key, element) ->
1483
+ obj.put(key, jsonElementToJsonValue(element))
1484
+ }
1485
+ return obj
1486
+ }
1487
+
1488
+ private fun jsonElementToJsonValue(value: JsonElement): Any? = when (value) {
1489
+ JsonNull -> JSONObject.NULL
1490
+ is JsonPrimitive -> when {
1491
+ value.isString -> value.content
1492
+ value.booleanOrNull != null -> value.boolean
1493
+ value.longOrNull != null -> value.long
1494
+ value.doubleOrNull != null -> value.double
1495
+ else -> value.content
1496
+ }
1497
+ is JsonObject -> jsonObjectToJsonObject(value)
1498
+ is JsonArray -> JSONArray().apply {
1499
+ value.forEach { put(jsonElementToJsonValue(it)) }
1500
+ }
727
1501
  }
728
1502
 
729
1503
  private fun anyCodableMapToJson(value: Map<String, AnyCodable>): String {
@@ -749,18 +1523,91 @@ class SynchroModule(reactContext: ReactApplicationContext) :
749
1523
  else -> value.toString()
750
1524
  }
751
1525
 
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
1526
+ private fun parseParams(params: ReadableArray): Array<Any?> {
1527
+ return Array(params.size()) { i ->
1528
+ when (params.getType(i)) {
1529
+ ReadableType.Null -> null
1530
+ ReadableType.Boolean -> params.getBoolean(i)
1531
+ ReadableType.Number -> sqliteNumberParam(params.getDouble(i), i)
1532
+ ReadableType.String -> params.getString(i)
1533
+ ReadableType.Map -> parseTaggedParam(
1534
+ params.getMap(i) ?: throw IllegalArgumentException("Invalid SQL bind tag at index $i"),
1535
+ i,
1536
+ )
1537
+ else -> throw IllegalArgumentException("Unsupported SQL bind value at index $i")
1538
+ }
1539
+ }
1540
+ }
1541
+
1542
+ private fun parseTaggedParam(map: ReadableMap, index: Int): Any {
1543
+ val keys = mutableSetOf<String>()
1544
+ val iterator = map.keySetIterator()
1545
+ while (iterator.hasNextKey()) {
1546
+ keys += iterator.nextKey()
1547
+ }
1548
+ if (keys != setOf("type", "base64") && keys != setOf("type", "value")) {
1549
+ throw IllegalArgumentException("Invalid SQL bind tag at index $index")
1550
+ }
1551
+ if (!map.hasKey("type") || map.getType("type") != ReadableType.String) {
1552
+ throw IllegalArgumentException("Invalid SQL bind tag at index $index")
1553
+ }
1554
+
1555
+ return when (map.getString("type")) {
1556
+ "bytes" -> {
1557
+ if (keys != setOf("type", "base64") || map.getType("base64") != ReadableType.String) {
1558
+ throw IllegalArgumentException("Invalid bytes bind tag at index $index")
1559
+ }
1560
+ decodeCanonicalBase64Url(map.getString("base64") ?: "", index)
1561
+ }
1562
+ "int64" -> {
1563
+ if (keys != setOf("type", "value") || map.getType("value") != ReadableType.String) {
1564
+ throw IllegalArgumentException("Invalid int64 bind tag at index $index")
1565
+ }
1566
+ parseCanonicalInt64(map.getString("value") ?: "", index)
1567
+ }
1568
+ else -> throw IllegalArgumentException("Invalid SQL bind tag at index $index")
757
1569
  }
758
1570
  }
759
1571
 
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
1572
+ private fun decodeCanonicalBase64Url(value: String, index: Int): ByteArray {
1573
+ require(BASE64_URL_PATTERN.matches(value) && value.length % 4 != 1) {
1574
+ "Invalid bytes bind tag at index $index"
1575
+ }
1576
+ val decoded = try {
1577
+ Base64.decode(value, BASE64_URL_FLAGS)
1578
+ } catch (_: IllegalArgumentException) {
1579
+ throw IllegalArgumentException("Invalid bytes bind tag at index $index")
1580
+ }
1581
+ val canonical = Base64.encodeToString(decoded, BASE64_URL_FLAGS)
1582
+ require(canonical == value) { "Invalid bytes bind tag at index $index" }
1583
+ return decoded
1584
+ }
1585
+
1586
+ private fun parseCanonicalInt64(value: String, index: Int): Long {
1587
+ require(INT64_PATTERN.matches(value)) {
1588
+ "Invalid int64 bind tag at index $index"
1589
+ }
1590
+ val parsed = value.toLongOrNull()
1591
+ ?: throw IllegalArgumentException("Invalid int64 bind tag at index $index")
1592
+ require(parsed.toString() == value) {
1593
+ "Invalid int64 bind tag at index $index"
1594
+ }
1595
+ return parsed
1596
+ }
1597
+
1598
+ private fun sqliteNumberParam(value: Double, index: Int): Any {
1599
+ if (!value.isFinite()) {
1600
+ throw IllegalArgumentException("Invalid SQL number bind value at index $index")
1601
+ }
1602
+ if (value % 1.0 == 0.0 &&
1603
+ (value < -MAX_SAFE_INTEGER.toDouble() || value > MAX_SAFE_INTEGER.toDouble())
1604
+ ) {
1605
+ throw IllegalArgumentException("SQL integer bind value is outside the safe range at index $index")
1606
+ }
1607
+ return if (value % 1.0 == 0.0) {
1608
+ value.toLong()
1609
+ } else {
1610
+ value
764
1611
  }
765
1612
  }
766
1613
 
@@ -786,23 +1633,43 @@ class SynchroModule(reactContext: ReactApplicationContext) :
786
1633
  )
787
1634
  }
788
1635
 
789
- private fun rowToJson(row: Row): String {
790
- val obj = JSONObject()
1636
+ private fun rowToWritableMap(row: Row): WritableMap {
1637
+ val map = Arguments.createMap()
791
1638
  for ((k, v) in row) {
792
- obj.put(k, v ?: JSONObject.NULL)
1639
+ putSQLiteValue(map, k, v)
793
1640
  }
794
- return obj.toString()
1641
+ return map
795
1642
  }
796
1643
 
797
- private fun rowsToJson(rows: List<Row>): String {
798
- val array = JSONArray()
1644
+ private fun rowsToWritableArray(rows: List<Row>): WritableArray {
1645
+ val array = Arguments.createArray()
799
1646
  for (row in rows) {
800
- val obj = JSONObject()
801
- for ((k, v) in row) {
802
- obj.put(k, v ?: JSONObject.NULL)
1647
+ array.pushMap(rowToWritableMap(row))
1648
+ }
1649
+ return array
1650
+ }
1651
+
1652
+ private fun putSQLiteValue(map: WritableMap, key: String, value: Any?) {
1653
+ when (value) {
1654
+ null -> map.putNull(key)
1655
+ is Boolean -> map.putBoolean(key, value)
1656
+ is Int -> map.putInt(key, value)
1657
+ is Long -> if (value in -MAX_SAFE_INTEGER..MAX_SAFE_INTEGER) {
1658
+ map.putDouble(key, value.toDouble())
1659
+ } else {
1660
+ map.putMap(key, Arguments.createMap().apply {
1661
+ putString("type", "int64")
1662
+ putString("value", value.toString())
1663
+ })
803
1664
  }
804
- array.put(obj)
1665
+ is Float -> map.putDouble(key, value.toDouble())
1666
+ is Double -> map.putDouble(key, value)
1667
+ is String -> map.putString(key, value)
1668
+ is ByteArray -> map.putMap(key, Arguments.createMap().apply {
1669
+ putString("type", "bytes")
1670
+ putString("base64", Base64.encodeToString(value, BASE64_URL_FLAGS))
1671
+ })
1672
+ else -> map.putString(key, value.toString())
805
1673
  }
806
- return array.toString()
807
1674
  }
808
1675
  }