@trainstar/synchro-react-native 0.3.2 → 0.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +58 -4
  2. package/SynchroReactNative.podspec +6 -0
  3. package/android/build.gradle +5 -1
  4. package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/SynchroModuleTransactionTest.kt +591 -0
  5. package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/TransactionSessionTest.kt +314 -0
  6. package/android/src/main/kotlin/com/trainstar/synchro/rn/SynchroModule.kt +306 -110
  7. package/ios/SynchroModule.mm +27 -4
  8. package/ios/SynchroModule.swift +228 -84
  9. package/ios-tests/SynchroModuleTransactionTests.swift +707 -0
  10. package/ios-tests/TransactionSessionTests.swift +66 -0
  11. package/lib/module/NativeSynchro.js.map +1 -1
  12. package/lib/module/SynchroClient.js +115 -2
  13. package/lib/module/SynchroClient.js.map +1 -1
  14. package/lib/module/errors.js +15 -1
  15. package/lib/module/errors.js.map +1 -1
  16. package/lib/module/index.js +1 -1
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/inspection.js +31 -3
  19. package/lib/module/inspection.js.map +1 -1
  20. package/lib/module/types.js +18 -0
  21. package/lib/module/types.js.map +1 -1
  22. package/lib/typescript/example/src/conformance/runner.d.ts +1 -0
  23. package/lib/typescript/example/src/conformance/runner.d.ts.map +1 -1
  24. package/lib/typescript/src/NativeSynchro.d.ts +7 -1
  25. package/lib/typescript/src/NativeSynchro.d.ts.map +1 -1
  26. package/lib/typescript/src/SynchroClient.d.ts +29 -1
  27. package/lib/typescript/src/SynchroClient.d.ts.map +1 -1
  28. package/lib/typescript/src/errors.d.ts +7 -1
  29. package/lib/typescript/src/errors.d.ts.map +1 -1
  30. package/lib/typescript/src/index.d.ts +3 -3
  31. package/lib/typescript/src/index.d.ts.map +1 -1
  32. package/lib/typescript/src/inspection.d.ts +13 -2
  33. package/lib/typescript/src/inspection.d.ts.map +1 -1
  34. package/lib/typescript/src/types.d.ts +44 -1
  35. package/lib/typescript/src/types.d.ts.map +1 -1
  36. package/package.json +3 -2
  37. package/src/NativeSynchro.ts +6 -1
  38. package/src/SynchroClient.ts +128 -1
  39. package/src/errors.ts +30 -0
  40. package/src/index.ts +7 -0
  41. package/src/inspection.ts +44 -2
  42. package/src/types.ts +49 -1
@@ -0,0 +1,591 @@
1
+ package com.trainstar.synchro.rn
2
+
3
+ import android.database.DatabaseUtils
4
+ import android.database.sqlite.SQLiteDatabase
5
+ import android.database.sqlite.SQLiteDatabaseLockedException
6
+ import androidx.test.ext.junit.runners.AndroidJUnit4
7
+ import androidx.test.platform.app.InstrumentationRegistry
8
+ import com.facebook.react.bridge.JavaOnlyArray
9
+ import com.facebook.react.bridge.JavaOnlyMap
10
+ import com.facebook.react.bridge.Promise
11
+ import com.facebook.react.bridge.BridgeReactContext
12
+ import com.facebook.react.bridge.ReadableMap
13
+ import com.facebook.react.bridge.WritableMap
14
+ import com.facebook.react.soloader.OpenSourceMergedSoMapping
15
+ import com.facebook.soloader.SoLoader
16
+ import java.util.UUID
17
+ import java.util.concurrent.Callable
18
+ import java.util.concurrent.CountDownLatch
19
+ import java.util.concurrent.ExecutionException
20
+ import java.util.concurrent.ExecutorService
21
+ import java.util.concurrent.Executors
22
+ import java.util.concurrent.TimeUnit
23
+ import java.util.concurrent.atomic.AtomicInteger
24
+ import kotlinx.coroutines.joinAll
25
+ import kotlinx.coroutines.runBlocking
26
+ import kotlinx.coroutines.withTimeout
27
+ import org.json.JSONArray
28
+ import org.json.JSONObject
29
+ import org.junit.After
30
+ import org.junit.Assert.assertEquals
31
+ import org.junit.Assert.assertNull
32
+ import org.junit.Assert.assertTrue
33
+ import org.junit.Before
34
+ import org.junit.Test
35
+ import org.junit.runner.RunWith
36
+
37
+ private const val SETTLEMENT_TIMEOUT_SECONDS = 60L
38
+ private const val COLUMNS_JSON = """[{"name":"id","type":"TEXT","primaryKey":true},{"name":"name","type":"TEXT"}]"""
39
+ private const val INSERT_SQL = "INSERT INTO bridge_items (id, name) VALUES (?, ?)"
40
+ private const val SELECT_NAME_SQL = "SELECT name FROM bridge_items WHERE id = ?"
41
+ private const val LEGACY_REJECTION_SQL = """
42
+ INSERT INTO _synchro_rejected_mutations
43
+ (mutation_id, table_name, record_id, status, code, message, server_row_json, server_version, created_at, updated_at)
44
+ VALUES ('m1', 'orders', 'r0', 'rejected_terminal', 'policy_rejected', 'blocked', '{"id":"r0"}', 'server-v7',
45
+ '2026-01-01T00:00:00.000000Z', '2026-01-01T00:00:00.000000Z')
46
+ """
47
+
48
+ /**
49
+ * Module events: these tests call neither start nor stop, and they register no bridge observer.
50
+ * The module emits only from auth requests, bridge observers, and SDK status, sync-event, and
51
+ * conflict callbacks. The SDK invokes those callbacks only for lifecycle transitions and sync,
52
+ * and module close cancels its subscriptions before it closes the client. The direct native
53
+ * onChange subscription in the held-commit test is not a module event.
54
+ */
55
+ @RunWith(AndroidJUnit4::class)
56
+ class SynchroModuleTransactionTest {
57
+ private val context = InstrumentationRegistry.getInstrumentation().targetContext
58
+ private val databaseName = "synchro_bridge_${UUID.randomUUID()}.db"
59
+ private val gates = ArrayDeque<AutoCloseable>()
60
+ private lateinit var module: SynchroModule
61
+
62
+ @Before
63
+ fun setUp() {
64
+ // Bridge result maps are native React Native maps.
65
+ SoLoader.init(context, OpenSourceMergedSoMapping)
66
+ module = SynchroModule(bridgeTestContext(context))
67
+ }
68
+
69
+ @After
70
+ fun tearDown() {
71
+ val failures = mutableListOf<Throwable>()
72
+ // Release every gate before close joins the registered transactions.
73
+ while (gates.isNotEmpty()) {
74
+ try {
75
+ gates.removeLast().close()
76
+ } catch (error: Throwable) {
77
+ failures += error
78
+ }
79
+ }
80
+ if (::module.isInitialized) {
81
+ try {
82
+ val close = RecordingPromise("teardown close")
83
+ module.close(close)
84
+ if (!close.waitForSettlement()) {
85
+ failures += AssertionError("teardown close did not settle")
86
+ } else if (close.resolutions.size != 1 || close.rejections.isNotEmpty()) {
87
+ failures += AssertionError(
88
+ "teardown close did not resolve once: ${close.resolutions.size} resolutions, ${close.rejections}",
89
+ )
90
+ }
91
+ } catch (error: Throwable) {
92
+ failures += error
93
+ }
94
+ }
95
+ if (failures.isEmpty()) {
96
+ val file = context.getDatabasePath(databaseName)
97
+ if (file.exists() && !context.deleteDatabase(databaseName)) {
98
+ failures += AssertionError("delete database $databaseName failed")
99
+ }
100
+ } else {
101
+ failures += AssertionError("cleanup was incomplete, so database $databaseName was kept")
102
+ }
103
+ if (failures.isNotEmpty()) {
104
+ val first = failures.first()
105
+ failures.drop(1).forEach(first::addSuppressed)
106
+ throw first
107
+ }
108
+ }
109
+
110
+ @Test
111
+ fun acquisitionFailureRejectsBeginOnce() {
112
+ initializeModule()
113
+ val external = openExternalConnection()
114
+ external.acquireWriteLock()
115
+
116
+ val begin = RecordingPromise("begin")
117
+ module.beginWriteTransaction(begin)
118
+ // The held write lock keeps the transaction callback from starting. The registered
119
+ // transaction job has finished its SDK call and catch when this barrier returns.
120
+ joinRegisteredTransactions()
121
+ assertEquals(emptyList<Any?>(), begin.resolutions)
122
+ assertEquals(listOf("UNKNOWN"), begin.rejections.map { it.code })
123
+ val cause = begin.rejections.single().throwable
124
+ assertTrue("begin rejection is not the SQLite acquisition error: $cause", cause is SQLiteDatabaseLockedException)
125
+
126
+ // Client close writes the stopped lifecycle state, so the lock is released before close.
127
+ external.releaseWriteLock()
128
+ val close = closeModule()
129
+ assertEquals(1, begin.rejections.size)
130
+ assertEquals(1, close.resolutions.size)
131
+ assertEquals(0L, external.rowCount())
132
+
133
+ initializeModule()
134
+ val id = UUID.randomUUID().toString()
135
+ val transaction = commitRow(id, "after-acquisition-failure")
136
+ val reopenedClose = closeModule()
137
+ requireSettledOnce(transaction + reopenedClose)
138
+ assertEquals(listOf("after-acquisition-failure"), external.names(id))
139
+ }
140
+
141
+ @Test
142
+ fun commitResolvesAfterDurableCompletion() {
143
+ initializeModule()
144
+ val external = openExternalConnection()
145
+ val id = UUID.randomUUID().toString()
146
+
147
+ val transaction = commitRow(id, "committed")
148
+ // The commit settled, so another connection sees the row.
149
+ assertEquals(listOf("committed"), external.names(id))
150
+
151
+ val close = closeModule()
152
+ requireSettledOnce(transaction + close)
153
+ assertEquals(listOf("committed"), external.names(id))
154
+ }
155
+
156
+ @Test
157
+ fun rollbackResolvesAfterRollback() {
158
+ initializeModule()
159
+ val external = openExternalConnection()
160
+ val id = UUID.randomUUID().toString()
161
+
162
+ val begin = settle("begin") { module.beginWriteTransaction(it) }
163
+ val txID = begin.resolvedValue() as String
164
+ val insert = settle("insert") { module.txExecute(txID, INSERT_SQL, JavaOnlyArray.of(id, "rolled-back"), it) }
165
+ assertEquals(1, (insert.resolvedValue() as ReadableMap).getInt("rowsAffected"))
166
+ val observe = settle("observe") { module.txQueryOne(txID, SELECT_NAME_SQL, JavaOnlyArray.of(id), it) }
167
+ assertEquals("rolled-back", (observe.resolvedValue() as ReadableMap).getString("name"))
168
+
169
+ val rollback = settle("rollback") { module.rollbackTransaction(txID, it) }
170
+ rollback.resolvedValue()
171
+ assertEquals(emptyList<String>(), external.names(id))
172
+ val after = settle("query after rollback") { module.queryOne(SELECT_NAME_SQL, JavaOnlyArray.of(id), it) }
173
+ assertNull(after.resolvedValue())
174
+
175
+ val close = closeModule()
176
+ requireSettledOnce(listOf(begin, insert, observe, rollback, after, close))
177
+ }
178
+
179
+ /**
180
+ * Observes an accepted commit after its durable write and before its SDK call returns.
181
+ * This is not a window inside SQLite COMMIT.
182
+ */
183
+ @Test
184
+ fun closeWaitsForAcceptedCommitHeldAfterDurableWrite() {
185
+ initializeModule()
186
+ val external = openExternalConnection()
187
+ val client = checkNotNull(module.client)
188
+ val hold = ChangeHold().also(gates::addLast)
189
+ val subscription = client.onChange(listOf("bridge_items")) { hold.enter() }
190
+ gates.addLast(AutoCloseable { subscription.cancel() })
191
+ val id = UUID.randomUUID().toString()
192
+
193
+ val begin = settle("begin") { module.beginWriteTransaction(it) }
194
+ val txID = begin.resolvedValue() as String
195
+ val insert = settle("insert") { module.txExecute(txID, INSERT_SQL, JavaOnlyArray.of(id, "held"), it) }
196
+ assertEquals(1, (insert.resolvedValue() as ReadableMap).getInt("rowsAffected"))
197
+ val commit = RecordingPromise("commit")
198
+ module.commitTransaction(txID, commit)
199
+ hold.awaitEntry()
200
+
201
+ // The write is durable, but the SDK call has not returned, so the promise is pending.
202
+ assertEquals(listOf("held"), external.names(id))
203
+ assertEquals(0, commit.settlementCount)
204
+ assertEquals(setOf(txID), module.sessions.keys.toSet())
205
+
206
+ val close = RecordingPromise("close")
207
+ module.close(close)
208
+ awaitCloseDetachesSessions()
209
+ assertEquals(0, close.settlementCount)
210
+ assertEquals(0, commit.settlementCount)
211
+
212
+ hold.release()
213
+ commit.awaitSettlement()
214
+ close.awaitSettlement()
215
+ assertTrue(hold.heldUntilRelease)
216
+ assertEquals(1, hold.entryCount)
217
+ subscription.cancel()
218
+ requireSettledOnce(listOf(begin, insert, commit, close))
219
+ assertEquals(listOf("held"), external.names(id))
220
+
221
+ initializeModule()
222
+ val laterID = UUID.randomUUID().toString()
223
+ val later = commitRow(laterID, "after-held-commit")
224
+ val laterClose = closeModule()
225
+ requireSettledOnce(later + laterClose)
226
+ assertEquals(listOf("after-held-commit"), external.names(laterID))
227
+ }
228
+
229
+ @Test
230
+ fun reinitializationKeepsCommittedRowsAndAcceptsWork() {
231
+ initializeModule()
232
+ val firstID = UUID.randomUUID().toString()
233
+ val first = commitRow(firstID, "before-close")
234
+ val firstClose = closeModule()
235
+
236
+ initializeModule()
237
+ val read = settle("query after reinitialize") { module.queryOne(SELECT_NAME_SQL, JavaOnlyArray.of(firstID), it) }
238
+ assertEquals("before-close", (read.resolvedValue() as ReadableMap).getString("name"))
239
+ val secondID = UUID.randomUUID().toString()
240
+ val second = commitRow(secondID, "after-reinitialize")
241
+ val external = openExternalConnection()
242
+ val secondClose = closeModule()
243
+
244
+ requireSettledOnce(first + firstClose + read + second + secondClose)
245
+ assertEquals(2L, external.rowCount())
246
+ assertEquals(listOf("before-close"), external.names(firstID))
247
+ assertEquals(listOf("after-reinitialize"), external.names(secondID))
248
+ }
249
+
250
+ @Test
251
+ fun clientStateSnapshotReadsRequestedRowsInsideReadOnlySnapshot() {
252
+ initializeModule()
253
+ val firstID = UUID.randomUUID().toString()
254
+ val secondID = UUID.randomUUID().toString()
255
+ val commits = commitRow(firstID, "first") + commitRow(secondID, "second")
256
+
257
+ val snapshot = settle("snapshot") {
258
+ module.inspectClientStateSnapshot(
259
+ JavaOnlyArray.of(JavaOnlyMap.of("sql", "SELECT id, name FROM bridge_items WHERE id = ?", "params", JavaOnlyArray.of(firstID))),
260
+ it,
261
+ )
262
+ }
263
+ val result = snapshot.resolvedValue() as ReadableMap
264
+ assertEquals(setOf("inspection", "applicationRows"), result.toHashMap().keys)
265
+ val rows = result.getArray("applicationRows")!!
266
+ assertEquals(1, rows.size())
267
+ assertEquals(firstID, rows.getMap(0)!!.getString("id"))
268
+ assertEquals("first", rows.getMap(0)!!.getString("name"))
269
+ val inspection = JSONObject(result.getString("inspection")!!)
270
+ assertEquals(
271
+ setOf("client_state", "retained_mutations", "rejected_mutations"),
272
+ inspection.keys().asSequence().toSet(),
273
+ )
274
+ // bridge_items is a local table, so the mutation ledger stays empty.
275
+ for (member in listOf("retained_mutations", "rejected_mutations")) {
276
+ assertEquals(member, 0, inspection.getJSONArray(member).length())
277
+ }
278
+ val clientState = inspection.getJSONObject("client_state")
279
+ assertEquals(0, clientState.getInt("mutation_ledger_count"))
280
+ assertEquals(0, clientState.getInt("rejected_mutation_count"))
281
+
282
+ val write = settle("snapshot write") {
283
+ module.inspectClientStateSnapshot(
284
+ JavaOnlyArray.of(JavaOnlyMap.of("sql", "DELETE FROM bridge_items WHERE id = ?", "params", JavaOnlyArray.of(firstID))),
285
+ it,
286
+ )
287
+ }
288
+ assertEquals(0, write.resolutions.size)
289
+ assertEquals(1, write.rejections.size)
290
+ val external = openExternalConnection()
291
+ val close = closeModule()
292
+
293
+ requireSettledOnce(commits + snapshot + close)
294
+ assertEquals(listOf("first"), external.names(firstID))
295
+ assertEquals(2L, external.rowCount())
296
+ }
297
+
298
+ @Test
299
+ fun rejectedInspectionMapsLegacyRejectionWithStoredFieldsOnly() {
300
+ initializeModule()
301
+ val external = openExternalConnection()
302
+ // A rejection stored before the mutation ledger has no exact mutation or rejection JSON.
303
+ external.execute(LEGACY_REJECTION_SQL)
304
+ val expected = JSONObject()
305
+ .put("representation", "legacy")
306
+ .put("mutationID", "m1")
307
+ .put("tableName", "orders")
308
+ .put("recordID", "r0")
309
+ .put("status", "rejected_terminal")
310
+ .put("code", "policy_rejected")
311
+ .put("message", "blocked")
312
+ .put("serverRowJSON", """{"id":"r0"}""")
313
+ .put("serverVersion", "server-v7")
314
+ .put("createdAt", "2026-01-01T00:00:00.000000Z")
315
+ .put("updatedAt", "2026-01-01T00:00:00.000000Z")
316
+
317
+ val inspect = settle("inspect rejected records") { module.inspectRejectedMutationRecords(it) }
318
+ val records = JSONArray(inspect.resolvedValue() as String)
319
+ assertEquals(1, records.length())
320
+ assertEquals(expected.toMap(), records.getJSONObject(0).toMap())
321
+ // The deprecated method keeps its published result: a legacy rejection cannot be inspected.
322
+ val deprecated = settle("inspect rejected") { module.inspectRejectedMutations(it) }
323
+ assertEquals(0, deprecated.resolutions.size)
324
+ assertEquals(1, deprecated.rejections.size)
325
+ val snapshot = settle("snapshot") { module.inspectClientStateSnapshot(JavaOnlyArray.of(), it) }
326
+ val inspection = JSONObject((snapshot.resolvedValue() as ReadableMap).getString("inspection")!!)
327
+ val snapshotRecords = inspection.getJSONArray("rejected_mutations")
328
+ assertEquals(1, snapshotRecords.length())
329
+ assertEquals(expected.toMap(), snapshotRecords.getJSONObject(0).toMap())
330
+ val close = closeModule()
331
+
332
+ requireSettledOnce(listOf(inspect, snapshot, close))
333
+ }
334
+
335
+ private fun JSONObject.toMap(): Map<String, Any?> = keys().asSequence().associateWith { get(it) }
336
+
337
+ private fun initializeModule() {
338
+ val config = JavaOnlyMap.of(
339
+ "dbPath", databaseName,
340
+ "serverURL", "https://synchro.invalid",
341
+ "clientID", UUID.randomUUID().toString(),
342
+ "platform", "android",
343
+ "appVersion", "1.0.0",
344
+ "requireNewDatabase", false,
345
+ )
346
+ settle("initialize") { module.initialize(config, it) }.resolvedValue()
347
+ settle("create table") { module.createTable("bridge_items", COLUMNS_JSON, null, it) }.resolvedValue()
348
+ }
349
+
350
+ private fun openExternalConnection(): ExternalConnection {
351
+ val name = settle("get path") { module.getPath(it) }.resolvedValue() as String
352
+ // SQLiteOpenHelper resolves a bare database name in the app database directory.
353
+ val path = context.getDatabasePath(name).absolutePath
354
+ return ExternalConnection(path).also(gates::addLast).also { it.open() }
355
+ }
356
+
357
+ private fun commitRow(id: String, name: String): List<RecordingPromise> {
358
+ val begin = settle("begin") { module.beginWriteTransaction(it) }
359
+ val txID = begin.resolvedValue() as String
360
+ val insert = settle("insert") { module.txExecute(txID, INSERT_SQL, JavaOnlyArray.of(id, name), it) }
361
+ assertEquals(1, (insert.resolvedValue() as ReadableMap).getInt("rowsAffected"))
362
+ val commit = settle("commit") { module.commitTransaction(txID, it) }
363
+ commit.resolvedValue()
364
+ return listOf(begin, insert, commit)
365
+ }
366
+
367
+ private fun closeModule(): RecordingPromise =
368
+ settle("close") { module.close(it) }.also { it.resolvedValue() }
369
+
370
+ /**
371
+ * beginWriteTransaction registers its session before it returns. The job removes the session
372
+ * in finally after the SDK call and its catch finish, so an empty registry is a completion.
373
+ */
374
+ private fun joinRegisteredTransactions() {
375
+ val jobs = module.sessions.values.map { it.job }
376
+ runBlocking { withTimeout(TimeUnit.SECONDS.toMillis(SETTLEMENT_TIMEOUT_SECONDS)) { jobs.joinAll() } }
377
+ assertTrue("registered transactions remain after their jobs finished", module.sessions.isEmpty())
378
+ }
379
+
380
+ /**
381
+ * Close removes every registered session under the registry lock before it joins their jobs.
382
+ * The held job cannot reach its own removal, so an empty registry shows that close detached it.
383
+ */
384
+ private fun awaitCloseDetachesSessions() {
385
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(SETTLEMENT_TIMEOUT_SECONDS)
386
+ while (module.sessions.isNotEmpty()) {
387
+ assertTrue("close did not detach the registered session", System.nanoTime() < deadline)
388
+ Thread.sleep(10)
389
+ }
390
+ }
391
+
392
+ private fun settle(name: String, call: (Promise) -> Unit): RecordingPromise =
393
+ RecordingPromise(name).also {
394
+ call(it)
395
+ it.awaitSettlement()
396
+ }
397
+
398
+ /** Checks the counters after a close that joined every registered transaction job. */
399
+ private fun requireSettledOnce(promises: List<RecordingPromise>) {
400
+ for (promise in promises) {
401
+ assertEquals("${promise.name} resolutions", 1, promise.resolutions.size)
402
+ assertEquals("${promise.name} rejections", emptyList<Rejection>(), promise.rejections)
403
+ }
404
+ }
405
+ }
406
+
407
+ /**
408
+ * RN 0.83 makes ReactApplicationContext abstract. BridgeReactContext is its concrete
409
+ * @VisibleForTesting subclass. Its legacy-architecture assertion is inert only because the pinned
410
+ * react-android 0.83.0 debug artifact sets UNSTABLE_ENABLE_MINIFY_LEGACY_ARCHITECTURE to false.
411
+ * This context runs no JavaScript, so these tests do not cover the bridgeless JavaScript host.
412
+ */
413
+ @Suppress("DEPRECATION")
414
+ internal fun bridgeTestContext(context: android.content.Context): BridgeReactContext = BridgeReactContext(context)
415
+
416
+ private data class Rejection(val code: String?, val throwable: Throwable?)
417
+
418
+ /** Records every settlement of one bridge promise, including a settlement after the first. */
419
+ private class RecordingPromise(val name: String) : Promise {
420
+ private val lock = Any()
421
+ private val resolved = mutableListOf<Any?>()
422
+ private val rejected = mutableListOf<Rejection>()
423
+ private val firstSettlement = CountDownLatch(1)
424
+
425
+ val resolutions: List<Any?> get() = synchronized(lock) { resolved.toList() }
426
+ val rejections: List<Rejection> get() = synchronized(lock) { rejected.toList() }
427
+ val settlementCount: Int get() = synchronized(lock) { resolved.size + rejected.size }
428
+
429
+ fun waitForSettlement(): Boolean = firstSettlement.await(SETTLEMENT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
430
+
431
+ fun awaitSettlement() {
432
+ assertTrue("$name did not settle", waitForSettlement())
433
+ }
434
+
435
+ fun resolvedValue(): Any? {
436
+ assertEquals("$name rejections", emptyList<Rejection>(), rejections)
437
+ val values = resolutions
438
+ assertEquals("$name resolutions", 1, values.size)
439
+ return values.single()
440
+ }
441
+
442
+ override fun resolve(value: Any?) = record { resolved.add(value) }
443
+ override fun reject(code: String, message: String?) = rejectWith(code, null)
444
+ override fun reject(code: String, throwable: Throwable?) = rejectWith(code, throwable)
445
+ override fun reject(code: String, message: String?, throwable: Throwable?) = rejectWith(code, throwable)
446
+ override fun reject(throwable: Throwable) = rejectWith(null, throwable)
447
+ override fun reject(throwable: Throwable, userInfo: WritableMap) = rejectWith(null, throwable)
448
+ override fun reject(code: String, userInfo: WritableMap) = rejectWith(code, null)
449
+ override fun reject(code: String, throwable: Throwable?, userInfo: WritableMap) = rejectWith(code, throwable)
450
+ override fun reject(code: String, message: String?, userInfo: WritableMap) = rejectWith(code, null)
451
+ override fun reject(code: String?, message: String?, throwable: Throwable?, userInfo: WritableMap?) =
452
+ rejectWith(code, throwable)
453
+
454
+ @Deprecated("Promise declares this overload.")
455
+ override fun reject(message: String) = rejectWith(null, null)
456
+
457
+ private fun rejectWith(code: String?, throwable: Throwable?) = record { rejected.add(Rejection(code, throwable)) }
458
+
459
+ private fun record(change: () -> Unit) {
460
+ synchronized(lock) { change() }
461
+ firstSettlement.countDown()
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Holds the first native change callback until the test releases it. Later callbacks pass.
467
+ * The SDK calls it inside its own exception handler, so it records results and never throws.
468
+ */
469
+ private class ChangeHold : AutoCloseable {
470
+ private val entered = CountDownLatch(1)
471
+ private val released = CountDownLatch(1)
472
+ private val entries = AtomicInteger()
473
+
474
+ @Volatile
475
+ var heldUntilRelease = false
476
+ private set
477
+
478
+ val entryCount: Int get() = entries.get()
479
+
480
+ fun enter() {
481
+ if (entries.incrementAndGet() != 1) return
482
+ entered.countDown()
483
+ heldUntilRelease = try {
484
+ released.await(SETTLEMENT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
485
+ } catch (_: InterruptedException) {
486
+ Thread.currentThread().interrupt()
487
+ false
488
+ }
489
+ }
490
+
491
+ fun awaitEntry() {
492
+ assertTrue("native change callback did not run", entered.await(SETTLEMENT_TIMEOUT_SECONDS, TimeUnit.SECONDS))
493
+ }
494
+
495
+ fun release() = released.countDown()
496
+
497
+ override fun close() = release()
498
+ }
499
+
500
+ /**
501
+ * A test-owned connection to the module database file.
502
+ * Android binds a SQLite transaction to one thread, so every call runs on one owned thread.
503
+ * The test registers the connection for cleanup before [open] starts, so a late open still has an owner.
504
+ */
505
+ private class ExternalConnection(private val path: String) : AutoCloseable {
506
+ private val thread: ExecutorService = Executors.newSingleThreadExecutor()
507
+
508
+ // Only the owned thread reads or writes these fields.
509
+ private var database: SQLiteDatabase? = null
510
+ private var holdsWriteLock = false
511
+
512
+ fun open() = onThread {
513
+ check(database == null) { "external connection is already open" }
514
+ database = SQLiteDatabase.openDatabase(
515
+ path,
516
+ null,
517
+ SQLiteDatabase.OPEN_READWRITE or SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING,
518
+ )
519
+ }
520
+
521
+ fun acquireWriteLock() = onThread {
522
+ openDatabase().beginTransaction()
523
+ holdsWriteLock = true
524
+ }
525
+
526
+ fun releaseWriteLock() = onThread { releaseOnThread() }
527
+
528
+ fun names(id: String): List<String> = onThread {
529
+ openDatabase().rawQuery(SELECT_NAME_SQL, arrayOf(id)).use { cursor ->
530
+ buildList { while (cursor.moveToNext()) add(cursor.getString(0)) }
531
+ }
532
+ }
533
+
534
+ fun execute(sql: String) = onThread { openDatabase().execSQL(sql) }
535
+
536
+ fun rowCount(): Long = onThread {
537
+ DatabaseUtils.longForQuery(openDatabase(), "SELECT count(*) FROM bridge_items", null)
538
+ }
539
+
540
+ /**
541
+ * Ends a held transaction, closes the database, and joins the owned thread. The close task
542
+ * runs after an earlier open task, so it also closes a database that opened after a timeout.
543
+ */
544
+ override fun close() {
545
+ val failures = mutableListOf<Throwable>()
546
+ try {
547
+ failures += onThread {
548
+ val releaseFailure = runCatching { releaseOnThread() }.exceptionOrNull()
549
+ val closeFailure = runCatching {
550
+ database?.close()
551
+ database = null
552
+ }.exceptionOrNull()
553
+ listOfNotNull(releaseFailure, closeFailure)
554
+ }
555
+ } catch (error: Throwable) {
556
+ failures += error
557
+ }
558
+ thread.shutdown()
559
+ try {
560
+ if (!thread.awaitTermination(SETTLEMENT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
561
+ failures += AssertionError("external connection thread did not stop")
562
+ }
563
+ } catch (error: InterruptedException) {
564
+ Thread.currentThread().interrupt()
565
+ failures += error
566
+ }
567
+ if (failures.isNotEmpty()) {
568
+ val first = failures.first()
569
+ failures.drop(1).forEach(first::addSuppressed)
570
+ throw first
571
+ }
572
+ }
573
+
574
+ private fun openDatabase(): SQLiteDatabase = checkNotNull(database) { "external connection is not open" }
575
+
576
+ private fun releaseOnThread() {
577
+ if (!holdsWriteLock) return
578
+ openDatabase().endTransaction()
579
+ holdsWriteLock = false
580
+ }
581
+
582
+ /** Runs [block] on the owned thread and rethrows its original failure. */
583
+ private fun <T> onThread(block: () -> T): T {
584
+ val task = thread.submit(Callable { block() })
585
+ return try {
586
+ task.get(SETTLEMENT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
587
+ } catch (error: ExecutionException) {
588
+ throw error.cause ?: error
589
+ }
590
+ }
591
+ }