@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.
- package/README.md +58 -4
- package/SynchroReactNative.podspec +6 -0
- package/android/build.gradle +5 -1
- package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/SynchroModuleTransactionTest.kt +591 -0
- package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/TransactionSessionTest.kt +314 -0
- package/android/src/main/kotlin/com/trainstar/synchro/rn/SynchroModule.kt +306 -110
- package/ios/SynchroModule.mm +27 -4
- package/ios/SynchroModule.swift +228 -84
- package/ios-tests/SynchroModuleTransactionTests.swift +707 -0
- package/ios-tests/TransactionSessionTests.swift +66 -0
- package/lib/module/NativeSynchro.js.map +1 -1
- package/lib/module/SynchroClient.js +115 -2
- package/lib/module/SynchroClient.js.map +1 -1
- package/lib/module/errors.js +15 -1
- package/lib/module/errors.js.map +1 -1
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/inspection.js +31 -3
- package/lib/module/inspection.js.map +1 -1
- package/lib/module/types.js +18 -0
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/example/src/conformance/runner.d.ts +1 -0
- package/lib/typescript/example/src/conformance/runner.d.ts.map +1 -1
- package/lib/typescript/src/NativeSynchro.d.ts +7 -1
- package/lib/typescript/src/NativeSynchro.d.ts.map +1 -1
- package/lib/typescript/src/SynchroClient.d.ts +29 -1
- package/lib/typescript/src/SynchroClient.d.ts.map +1 -1
- package/lib/typescript/src/errors.d.ts +7 -1
- package/lib/typescript/src/errors.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/inspection.d.ts +13 -2
- package/lib/typescript/src/inspection.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +44 -1
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/NativeSynchro.ts +6 -1
- package/src/SynchroClient.ts +128 -1
- package/src/errors.ts +30 -0
- package/src/index.ts +7 -0
- package/src/inspection.ts +44 -2
- package/src/types.ts +49 -1
package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/SynchroModuleTransactionTest.kt
ADDED
|
@@ -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
|
+
}
|