@trainstar/synchro-react-native 0.1.2 → 0.3.2
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/README.md +103 -24
- package/SynchroReactNative.podspec +3 -2
- package/android/build.gradle +8 -3
- package/android/src/main/kotlin/com/trainstar/synchro/rn/SynchroModule.kt +1106 -238
- package/ios/SynchroModule.mm +178 -51
- package/ios/SynchroModule.swift +1382 -295
- package/lib/module/NativeSynchro.js +5 -0
- package/lib/module/NativeSynchro.js.map +1 -1
- package/lib/module/SynchroClient.js +637 -65
- package/lib/module/SynchroClient.js.map +1 -1
- package/lib/module/digest.js +82 -0
- package/lib/module/digest.js.map +1 -0
- package/lib/module/errors.js +165 -22
- package/lib/module/errors.js.map +1 -1
- package/lib/module/hooks/useQuery.js +37 -3
- package/lib/module/hooks/useQuery.js.map +1 -1
- package/lib/module/hooks/useSyncStatus.js +51 -6
- package/lib/module/hooks/useSyncStatus.js.map +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/inspection.js +121 -0
- package/lib/module/inspection.js.map +1 -0
- package/lib/module/sqliteValues.js +87 -0
- package/lib/module/sqliteValues.js.map +1 -0
- package/lib/module/types.js +10 -0
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/example/e2e/corpus-harness.d.ts +34 -0
- package/lib/typescript/example/e2e/corpus-harness.d.ts.map +1 -0
- package/lib/typescript/example/src/conformance/runner.d.ts +125 -0
- package/lib/typescript/example/src/conformance/runner.d.ts.map +1 -0
- package/lib/typescript/example/src/conformance/types.d.ts +71 -0
- package/lib/typescript/example/src/conformance/types.d.ts.map +1 -0
- package/lib/typescript/example/src/timeouts.d.ts +2 -0
- package/lib/typescript/example/src/timeouts.d.ts.map +1 -0
- package/lib/typescript/src/NativeSynchro.d.ts +59 -17
- package/lib/typescript/src/NativeSynchro.d.ts.map +1 -1
- package/lib/typescript/src/SynchroClient.d.ts +27 -10
- package/lib/typescript/src/SynchroClient.d.ts.map +1 -1
- package/lib/typescript/src/digest.d.ts +3 -0
- package/lib/typescript/src/digest.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +37 -9
- package/lib/typescript/src/errors.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useQuery.d.ts +2 -2
- package/lib/typescript/src/hooks/useQuery.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useSyncStatus.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +4 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/inspection.d.ts +43 -0
- package/lib/typescript/src/inspection.d.ts.map +1 -0
- package/lib/typescript/src/sqliteValues.d.ts +8 -0
- package/lib/typescript/src/sqliteValues.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +203 -11
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +20 -8
- package/src/NativeSynchro.ts +93 -26
- package/src/SynchroClient.ts +900 -120
- package/src/digest.ts +93 -0
- package/src/errors.ts +273 -33
- package/src/hooks/useQuery.ts +59 -16
- package/src/hooks/useSyncStatus.ts +40 -7
- package/src/index.ts +44 -3
- package/src/inspection.ts +226 -0
- package/src/sqliteValues.ts +105 -0
- package/src/types.ts +318 -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
204
|
+
seedDatabasePath = seedDatabasePath,
|
|
122
205
|
)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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,
|
|
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
|
|
182
|
-
|
|
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,
|
|
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
|
|
197
|
-
|
|
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,
|
|
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
|
|
212
|
-
val
|
|
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(
|
|
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
|
|
230
|
-
|
|
231
|
-
val
|
|
232
|
-
val
|
|
233
|
-
|
|
234
|
-
|
|
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<
|
|
254
|
-
data class QueryOne(val sql: String, val params: Array<Any?>, val deferred: CompletableDeferred<
|
|
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
|
|
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 =
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
619
|
+
return finalDeferred
|
|
374
620
|
}
|
|
375
621
|
|
|
376
622
|
@ReactMethod
|
|
377
|
-
override fun txQuery(txID: String, sql: String,
|
|
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
|
|
385
|
-
|
|
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,
|
|
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
|
|
403
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
793
|
+
val nativeParams = parseParams(params)
|
|
547
794
|
val tableList = (0 until tables.size()).mapNotNull { tables.getString(it) }
|
|
548
|
-
val cancellable = c.watch(sql,
|
|
795
|
+
val cancellable = c.watch(sql, nativeParams, tableList) { rows ->
|
|
549
796
|
val eventParams = Arguments.createMap().apply {
|
|
550
797
|
putString("observerID", observerID)
|
|
551
|
-
|
|
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
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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,306 @@ 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
|
-
|
|
1211
|
+
transportObservationCollector?.cancelPauseBarrier()
|
|
1212
|
+
transportObservationCollector = null
|
|
719
1213
|
observers.values.forEach { it.cancel() }
|
|
720
1214
|
observers.clear()
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
726
|
-
|
|
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
|
+
LocalMutationStatus.EXCEEDS_PUSH_LIMIT -> "exceeds_push_limit"
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
private fun mutationStatusWireValue(status: MutationStatus): String = when (status) {
|
|
1458
|
+
MutationStatus.APPLIED -> "applied"
|
|
1459
|
+
MutationStatus.CONFLICT -> "conflict"
|
|
1460
|
+
MutationStatus.REJECTED_TERMINAL -> "rejected_terminal"
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
private fun schemaActionWireValue(action: SchemaAction): String = when (action) {
|
|
1464
|
+
SchemaAction.NONE -> "none"
|
|
1465
|
+
SchemaAction.REPLACE -> "replace"
|
|
1466
|
+
SchemaAction.REBUILD_LOCAL -> "rebuild_local"
|
|
1467
|
+
SchemaAction.UNSUPPORTED -> "unsupported"
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
private fun mutationRejectionCodeWireValue(code: MutationRejectionCode): String = when (code) {
|
|
1471
|
+
MutationRejectionCode.VERSION_CONFLICT -> "version_conflict"
|
|
1472
|
+
MutationRejectionCode.ROW_ALREADY_EXISTS -> "row_already_exists"
|
|
1473
|
+
MutationRejectionCode.ROW_DELETED -> "row_deleted"
|
|
1474
|
+
MutationRejectionCode.ROW_NOT_FOUND -> "row_not_found"
|
|
1475
|
+
MutationRejectionCode.SCHEMA_INCOMPATIBLE -> "schema_incompatible"
|
|
1476
|
+
MutationRejectionCode.POLICY_REJECTED -> "policy_rejected"
|
|
1477
|
+
MutationRejectionCode.VALIDATION_FAILED -> "validation_failed"
|
|
1478
|
+
MutationRejectionCode.TABLE_NOT_SYNCED -> "table_not_synced"
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
private fun jsonObjectToJsonObject(value: JsonObject): JSONObject {
|
|
1482
|
+
val obj = JSONObject()
|
|
1483
|
+
value.forEach { (key, element) ->
|
|
1484
|
+
obj.put(key, jsonElementToJsonValue(element))
|
|
1485
|
+
}
|
|
1486
|
+
return obj
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
private fun jsonElementToJsonValue(value: JsonElement): Any? = when (value) {
|
|
1490
|
+
JsonNull -> JSONObject.NULL
|
|
1491
|
+
is JsonPrimitive -> when {
|
|
1492
|
+
value.isString -> value.content
|
|
1493
|
+
value.booleanOrNull != null -> value.boolean
|
|
1494
|
+
value.longOrNull != null -> value.long
|
|
1495
|
+
value.doubleOrNull != null -> value.double
|
|
1496
|
+
else -> value.content
|
|
1497
|
+
}
|
|
1498
|
+
is JsonObject -> jsonObjectToJsonObject(value)
|
|
1499
|
+
is JsonArray -> JSONArray().apply {
|
|
1500
|
+
value.forEach { put(jsonElementToJsonValue(it)) }
|
|
1501
|
+
}
|
|
727
1502
|
}
|
|
728
1503
|
|
|
729
1504
|
private fun anyCodableMapToJson(value: Map<String, AnyCodable>): String {
|
|
@@ -749,18 +1524,91 @@ class SynchroModule(reactContext: ReactApplicationContext) :
|
|
|
749
1524
|
else -> value.toString()
|
|
750
1525
|
}
|
|
751
1526
|
|
|
752
|
-
private fun parseParams(
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1527
|
+
private fun parseParams(params: ReadableArray): Array<Any?> {
|
|
1528
|
+
return Array(params.size()) { i ->
|
|
1529
|
+
when (params.getType(i)) {
|
|
1530
|
+
ReadableType.Null -> null
|
|
1531
|
+
ReadableType.Boolean -> params.getBoolean(i)
|
|
1532
|
+
ReadableType.Number -> sqliteNumberParam(params.getDouble(i), i)
|
|
1533
|
+
ReadableType.String -> params.getString(i)
|
|
1534
|
+
ReadableType.Map -> parseTaggedParam(
|
|
1535
|
+
params.getMap(i) ?: throw IllegalArgumentException("Invalid SQL bind tag at index $i"),
|
|
1536
|
+
i,
|
|
1537
|
+
)
|
|
1538
|
+
else -> throw IllegalArgumentException("Unsupported SQL bind value at index $i")
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
private fun parseTaggedParam(map: ReadableMap, index: Int): Any {
|
|
1544
|
+
val keys = mutableSetOf<String>()
|
|
1545
|
+
val iterator = map.keySetIterator()
|
|
1546
|
+
while (iterator.hasNextKey()) {
|
|
1547
|
+
keys += iterator.nextKey()
|
|
1548
|
+
}
|
|
1549
|
+
if (keys != setOf("type", "base64") && keys != setOf("type", "value")) {
|
|
1550
|
+
throw IllegalArgumentException("Invalid SQL bind tag at index $index")
|
|
1551
|
+
}
|
|
1552
|
+
if (!map.hasKey("type") || map.getType("type") != ReadableType.String) {
|
|
1553
|
+
throw IllegalArgumentException("Invalid SQL bind tag at index $index")
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
return when (map.getString("type")) {
|
|
1557
|
+
"bytes" -> {
|
|
1558
|
+
if (keys != setOf("type", "base64") || map.getType("base64") != ReadableType.String) {
|
|
1559
|
+
throw IllegalArgumentException("Invalid bytes bind tag at index $index")
|
|
1560
|
+
}
|
|
1561
|
+
decodeCanonicalBase64Url(map.getString("base64") ?: "", index)
|
|
1562
|
+
}
|
|
1563
|
+
"int64" -> {
|
|
1564
|
+
if (keys != setOf("type", "value") || map.getType("value") != ReadableType.String) {
|
|
1565
|
+
throw IllegalArgumentException("Invalid int64 bind tag at index $index")
|
|
1566
|
+
}
|
|
1567
|
+
parseCanonicalInt64(map.getString("value") ?: "", index)
|
|
1568
|
+
}
|
|
1569
|
+
else -> throw IllegalArgumentException("Invalid SQL bind tag at index $index")
|
|
757
1570
|
}
|
|
758
1571
|
}
|
|
759
1572
|
|
|
760
|
-
private fun
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1573
|
+
private fun decodeCanonicalBase64Url(value: String, index: Int): ByteArray {
|
|
1574
|
+
require(BASE64_URL_PATTERN.matches(value) && value.length % 4 != 1) {
|
|
1575
|
+
"Invalid bytes bind tag at index $index"
|
|
1576
|
+
}
|
|
1577
|
+
val decoded = try {
|
|
1578
|
+
Base64.decode(value, BASE64_URL_FLAGS)
|
|
1579
|
+
} catch (_: IllegalArgumentException) {
|
|
1580
|
+
throw IllegalArgumentException("Invalid bytes bind tag at index $index")
|
|
1581
|
+
}
|
|
1582
|
+
val canonical = Base64.encodeToString(decoded, BASE64_URL_FLAGS)
|
|
1583
|
+
require(canonical == value) { "Invalid bytes bind tag at index $index" }
|
|
1584
|
+
return decoded
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
private fun parseCanonicalInt64(value: String, index: Int): Long {
|
|
1588
|
+
require(INT64_PATTERN.matches(value)) {
|
|
1589
|
+
"Invalid int64 bind tag at index $index"
|
|
1590
|
+
}
|
|
1591
|
+
val parsed = value.toLongOrNull()
|
|
1592
|
+
?: throw IllegalArgumentException("Invalid int64 bind tag at index $index")
|
|
1593
|
+
require(parsed.toString() == value) {
|
|
1594
|
+
"Invalid int64 bind tag at index $index"
|
|
1595
|
+
}
|
|
1596
|
+
return parsed
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
private fun sqliteNumberParam(value: Double, index: Int): Any {
|
|
1600
|
+
if (!value.isFinite()) {
|
|
1601
|
+
throw IllegalArgumentException("Invalid SQL number bind value at index $index")
|
|
1602
|
+
}
|
|
1603
|
+
if (value % 1.0 == 0.0 &&
|
|
1604
|
+
(value < -MAX_SAFE_INTEGER.toDouble() || value > MAX_SAFE_INTEGER.toDouble())
|
|
1605
|
+
) {
|
|
1606
|
+
throw IllegalArgumentException("SQL integer bind value is outside the safe range at index $index")
|
|
1607
|
+
}
|
|
1608
|
+
return if (value % 1.0 == 0.0) {
|
|
1609
|
+
value.toLong()
|
|
1610
|
+
} else {
|
|
1611
|
+
value
|
|
764
1612
|
}
|
|
765
1613
|
}
|
|
766
1614
|
|
|
@@ -786,23 +1634,43 @@ class SynchroModule(reactContext: ReactApplicationContext) :
|
|
|
786
1634
|
)
|
|
787
1635
|
}
|
|
788
1636
|
|
|
789
|
-
private fun
|
|
790
|
-
val
|
|
1637
|
+
private fun rowToWritableMap(row: Row): WritableMap {
|
|
1638
|
+
val map = Arguments.createMap()
|
|
791
1639
|
for ((k, v) in row) {
|
|
792
|
-
|
|
1640
|
+
putSQLiteValue(map, k, v)
|
|
793
1641
|
}
|
|
794
|
-
return
|
|
1642
|
+
return map
|
|
795
1643
|
}
|
|
796
1644
|
|
|
797
|
-
private fun
|
|
798
|
-
val array =
|
|
1645
|
+
private fun rowsToWritableArray(rows: List<Row>): WritableArray {
|
|
1646
|
+
val array = Arguments.createArray()
|
|
799
1647
|
for (row in rows) {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1648
|
+
array.pushMap(rowToWritableMap(row))
|
|
1649
|
+
}
|
|
1650
|
+
return array
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
private fun putSQLiteValue(map: WritableMap, key: String, value: Any?) {
|
|
1654
|
+
when (value) {
|
|
1655
|
+
null -> map.putNull(key)
|
|
1656
|
+
is Boolean -> map.putBoolean(key, value)
|
|
1657
|
+
is Int -> map.putInt(key, value)
|
|
1658
|
+
is Long -> if (value in -MAX_SAFE_INTEGER..MAX_SAFE_INTEGER) {
|
|
1659
|
+
map.putDouble(key, value.toDouble())
|
|
1660
|
+
} else {
|
|
1661
|
+
map.putMap(key, Arguments.createMap().apply {
|
|
1662
|
+
putString("type", "int64")
|
|
1663
|
+
putString("value", value.toString())
|
|
1664
|
+
})
|
|
803
1665
|
}
|
|
804
|
-
|
|
1666
|
+
is Float -> map.putDouble(key, value.toDouble())
|
|
1667
|
+
is Double -> map.putDouble(key, value)
|
|
1668
|
+
is String -> map.putString(key, value)
|
|
1669
|
+
is ByteArray -> map.putMap(key, Arguments.createMap().apply {
|
|
1670
|
+
putString("type", "bytes")
|
|
1671
|
+
putString("base64", Base64.encodeToString(value, BASE64_URL_FLAGS))
|
|
1672
|
+
})
|
|
1673
|
+
else -> map.putString(key, value.toString())
|
|
805
1674
|
}
|
|
806
|
-
return array.toString()
|
|
807
1675
|
}
|
|
808
1676
|
}
|