@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.
- package/LICENSE +20 -0
- package/README.md +122 -0
- package/SynchroReactNative.podspec +22 -0
- package/android/build.gradle +81 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/kotlin/com/trainstar/synchro/rn/SynchroModule.kt +808 -0
- package/android/src/main/kotlin/com/trainstar/synchro/rn/SynchroPackage.kt +32 -0
- package/ios/SynchroModule.mm +368 -0
- package/ios/SynchroModule.swift +970 -0
- package/lib/module/NativeSynchro.js +5 -0
- package/lib/module/NativeSynchro.js.map +1 -0
- package/lib/module/SynchroClient.js +295 -0
- package/lib/module/SynchroClient.js.map +1 -0
- package/lib/module/errors.js +150 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/hooks/index.js +6 -0
- package/lib/module/hooks/index.js.map +1 -0
- package/lib/module/hooks/usePendingChanges.js +25 -0
- package/lib/module/hooks/usePendingChanges.js.map +1 -0
- package/lib/module/hooks/useQuery.js +74 -0
- package/lib/module/hooks/useQuery.js.map +1 -0
- package/lib/module/hooks/useSyncStatus.js +15 -0
- package/lib/module/hooks/useSyncStatus.js.map +1 -0
- package/lib/module/index.js +8 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeSynchro.d.ts +84 -0
- package/lib/typescript/src/NativeSynchro.d.ts.map +1 -0
- package/lib/typescript/src/SynchroClient.d.ts +30 -0
- package/lib/typescript/src/SynchroClient.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +61 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/hooks/index.d.ts +4 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/usePendingChanges.d.ts +3 -0
- package/lib/typescript/src/hooks/usePendingChanges.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useQuery.d.ts +12 -0
- package/lib/typescript/src/hooks/useQuery.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useSyncStatus.d.ts +4 -0
- package/lib/typescript/src/hooks/useSyncStatus.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +8 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +55 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +146 -0
- package/src/NativeSynchro.ts +114 -0
- package/src/SynchroClient.ts +424 -0
- package/src/errors.ts +205 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/usePendingChanges.ts +32 -0
- package/src/hooks/useQuery.ts +93 -0
- package/src/hooks/useSyncStatus.ts +17 -0
- package/src/index.ts +41 -0
- 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
|
+
}
|