@trainstar/synchro-react-native 0.3.1 → 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 (45) 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 +307 -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 +116 -3
  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/e2e/corpus-harness.d.ts.map +1 -1
  23. package/lib/typescript/example/src/conformance/runner.d.ts +1 -0
  24. package/lib/typescript/example/src/conformance/runner.d.ts.map +1 -1
  25. package/lib/typescript/example/src/timeouts.d.ts +2 -0
  26. package/lib/typescript/example/src/timeouts.d.ts.map +1 -0
  27. package/lib/typescript/src/NativeSynchro.d.ts +7 -1
  28. package/lib/typescript/src/NativeSynchro.d.ts.map +1 -1
  29. package/lib/typescript/src/SynchroClient.d.ts +29 -1
  30. package/lib/typescript/src/SynchroClient.d.ts.map +1 -1
  31. package/lib/typescript/src/errors.d.ts +7 -1
  32. package/lib/typescript/src/errors.d.ts.map +1 -1
  33. package/lib/typescript/src/index.d.ts +3 -3
  34. package/lib/typescript/src/index.d.ts.map +1 -1
  35. package/lib/typescript/src/inspection.d.ts +13 -2
  36. package/lib/typescript/src/inspection.d.ts.map +1 -1
  37. package/lib/typescript/src/types.d.ts +45 -2
  38. package/lib/typescript/src/types.d.ts.map +1 -1
  39. package/package.json +3 -2
  40. package/src/NativeSynchro.ts +6 -1
  41. package/src/SynchroClient.ts +129 -1
  42. package/src/errors.ts +30 -0
  43. package/src/index.ts +7 -0
  44. package/src/inspection.ts +44 -2
  45. package/src/types.ts +51 -2
@@ -0,0 +1,314 @@
1
+ package com.trainstar.synchro.rn
2
+
3
+ import androidx.test.ext.junit.runners.AndroidJUnit4
4
+ import androidx.test.platform.app.InstrumentationRegistry
5
+ import com.trainstar.synchro.ExecResult
6
+ import com.facebook.react.bridge.WritableMap
7
+ import java.util.concurrent.Callable
8
+ import java.util.concurrent.CountDownLatch
9
+ import java.util.concurrent.ExecutionException
10
+ import java.util.concurrent.Executors
11
+ import java.util.concurrent.TimeUnit
12
+ import java.util.concurrent.atomic.AtomicInteger
13
+ import java.util.concurrent.atomic.AtomicReference
14
+ import kotlinx.coroutines.CompletableDeferred
15
+ import kotlinx.coroutines.ExperimentalCoroutinesApi
16
+ import kotlinx.coroutines.async
17
+ import kotlinx.coroutines.asCoroutineDispatcher
18
+ import kotlinx.coroutines.channels.ChannelResult
19
+ import kotlinx.coroutines.runBlocking
20
+ import kotlinx.coroutines.withTimeout
21
+ import org.junit.Assert.assertEquals
22
+ import org.junit.Assert.assertFalse
23
+ import org.junit.Assert.assertSame
24
+ import org.junit.Assert.assertThrows
25
+ import org.junit.Assert.assertTrue
26
+ import org.junit.Test
27
+ import org.junit.runner.RunWith
28
+
29
+ private const val GUARD_SECONDS = 60L
30
+
31
+ private class SessionError(name: String) : Exception(name)
32
+
33
+ /** Records the begin settlements that the actual session delivers. */
34
+ private class BeginRecorder {
35
+ private val lock = Any()
36
+ private val results = mutableListOf<Result<String>>()
37
+
38
+ fun record(result: Result<String>) {
39
+ synchronized(lock) { results += result }
40
+ }
41
+
42
+ val successes: List<String> get() = synchronized(lock) { results.mapNotNull { it.getOrNull() } }
43
+ val failures: List<Throwable> get() = synchronized(lock) { results.mapNotNull { it.exceptionOrNull() } }
44
+ }
45
+
46
+ /** A received terminal completion and a count of its observable completion deliveries. */
47
+ private class ReceivedTerminal {
48
+ val deferred = CompletableDeferred<Unit>()
49
+ private val deliveries = AtomicInteger()
50
+
51
+ init {
52
+ deferred.invokeOnCompletion { deliveries.incrementAndGet() }
53
+ }
54
+
55
+ val deliveryCount: Int get() = deliveries.get()
56
+ }
57
+
58
+ /**
59
+ * Direct tests of the production TransactionSession and one terminal-only run of the actual loop.
60
+ * Most tests select the order of the session calls. The dropped-operation test forces a physical
61
+ * Channel schedule.
62
+ */
63
+ @OptIn(ExperimentalCoroutinesApi::class)
64
+ @RunWith(AndroidJUnit4::class)
65
+ class TransactionSessionTest {
66
+ @Test
67
+ fun failureBeforeCallbackRejectsStoredBeginOnce() {
68
+ val begin = BeginRecorder()
69
+ val session = SynchroModule.TransactionSession(isWrite = true, beginSettlement = begin::record)
70
+ val acquisition = SessionError("acquisition")
71
+
72
+ session.rejectBegin(acquisition)
73
+ session.rejectBegin(SessionError("later failure"))
74
+
75
+ assertEquals(emptyList<String>(), begin.successes)
76
+ assertEquals(1, begin.failures.size)
77
+ assertSame(acquisition, begin.failures.single())
78
+ }
79
+
80
+ @Test
81
+ fun abortBeforeBeginAcceptanceRejectsBeginAndUnwindsCallback() {
82
+ val begin = BeginRecorder()
83
+ val session = SynchroModule.TransactionSession(isWrite = true, beginSettlement = begin::record)
84
+ val close = SessionError("close")
85
+
86
+ session.abort(close)
87
+ val thrown = assertThrows(SessionError::class.java) { session.acceptBegin("abort-before-begin") }
88
+ session.rejectBegin(SessionError("callback exit"))
89
+
90
+ assertSame(close, thrown)
91
+ assertEquals(emptyList<String>(), begin.successes)
92
+ assertEquals(1, begin.failures.size)
93
+ assertSame(close, begin.failures.single())
94
+ }
95
+
96
+ @Test
97
+ fun acceptedBeginIsNotSettledAgainByLaterFailure() {
98
+ val begin = BeginRecorder()
99
+ val session = SynchroModule.TransactionSession(isWrite = true, beginSettlement = begin::record)
100
+
101
+ session.acceptBegin("accepted")
102
+ session.abort(SessionError("close"))
103
+ session.rejectBegin(SessionError("callback exit"))
104
+
105
+ assertEquals(listOf("accepted"), begin.successes)
106
+ assertEquals(emptyList<Throwable>(), begin.failures)
107
+ }
108
+
109
+ /** The exact lost-owner order: close wins after the loop received a terminal operation. */
110
+ @Test
111
+ fun abortBeforeAcceptanceKeepsReceivedTerminalUntilFinish() {
112
+ val session = acceptedSession()
113
+ val received = ReceivedTerminal()
114
+ val exit = SessionError("transaction exit")
115
+
116
+ session.abort(SessionError("close"))
117
+ assertFalse(session.acceptTerminal(received.deferred))
118
+ assertFalse(received.deferred.isCompleted)
119
+ assertEquals(0, received.deliveryCount)
120
+
121
+ session.finishTerminal(exit)
122
+ assertTrue("received terminal operation lost its owner", received.deferred.isCompleted)
123
+ assertSame(exit, received.deferred.getCompletionExceptionOrNull())
124
+ assertEquals(1, received.deliveryCount)
125
+ }
126
+
127
+ @Test
128
+ fun acceptanceBeforeAbortKeepsTerminalUntilSuccessfulFinish() {
129
+ val session = acceptedSession()
130
+ val received = ReceivedTerminal()
131
+
132
+ assertTrue(session.acceptTerminal(received.deferred))
133
+ session.abort(SessionError("close"))
134
+ assertFalse(received.deferred.isCompleted)
135
+ assertEquals(0, received.deliveryCount)
136
+
137
+ session.finishTerminal(null)
138
+ assertTrue(received.deferred.isCompleted)
139
+ assertEquals(null, received.deferred.getCompletionExceptionOrNull())
140
+ assertEquals(1, received.deliveryCount)
141
+ }
142
+
143
+ @Test
144
+ fun acceptedTerminalRejectsWithTransactionExitError() {
145
+ val session = acceptedSession()
146
+ val received = ReceivedTerminal()
147
+ val exit = SessionError("transaction exit")
148
+
149
+ assertTrue(session.acceptTerminal(received.deferred))
150
+ session.finishTerminal(exit)
151
+
152
+ assertTrue(received.deferred.isCompleted)
153
+ assertSame(exit, received.deferred.getCompletionExceptionOrNull())
154
+ assertEquals(1, received.deliveryCount)
155
+ }
156
+
157
+ @Test
158
+ fun commitLoopTransfersReceivedDeferredToSession() {
159
+ val result = runTerminalLoop(commit = true)
160
+
161
+ assertEquals(null, result.loopFailure)
162
+ result.requireOwnedUntilFinish()
163
+ }
164
+
165
+ @Test
166
+ fun rollbackLoopTransfersReceivedDeferredToSession() {
167
+ val result = runTerminalLoop(commit = false)
168
+
169
+ assertTrue(
170
+ "rollback loop did not exit with its control exception: ${result.loopFailure}",
171
+ result.loopFailure is SynchroModule.TransactionRollbackException,
172
+ )
173
+ result.requireOwnedUntilFinish()
174
+ }
175
+
176
+ /**
177
+ * Forces the Channel prompt-cancellation schedule on the actual session channel. The sender
178
+ * hands the operation to a waiting receive. The receive is cancelled, as the idle timeout
179
+ * cancels it, before its blocked thread resumes it. The channel then drops the operation.
180
+ */
181
+ @Test
182
+ fun operationDroppedByCancelledReceiveSettlesWhenTransactionEnds() {
183
+ val session = acceptedSession()
184
+ val receiverThread = Executors.newSingleThreadExecutor()
185
+ val receiverBlocked = CountDownLatch(1)
186
+ val releaseReceiver = CountDownLatch(1)
187
+ val deferred = CompletableDeferred<WritableMap>()
188
+ val operation = SynchroModule.TransactionOp.Execute("UPDATE items SET name = 'dropped'", emptyArray(), deferred)
189
+ val timeout = SessionError("idle timeout")
190
+ val delivered = AtomicReference<ChannelResult<SynchroModule.TransactionOp>?>()
191
+ try {
192
+ runBlocking {
193
+ withTimeout(TimeUnit.SECONDS.toMillis(GUARD_SECONDS)) {
194
+ val receive = async(receiverThread.asCoroutineDispatcher()) {
195
+ delivered.set(session.operations.receiveCatching())
196
+ }
197
+ // The receive suspends before this task runs, because the executor has one thread.
198
+ receiverThread.execute {
199
+ receiverBlocked.countDown()
200
+ releaseReceiver.await()
201
+ }
202
+ assertTrue(receiverBlocked.await(GUARD_SECONDS, TimeUnit.SECONDS))
203
+ assertTrue(session.submit(operation))
204
+ // The receive waits, so the rendezvous transfer completes and send returns.
205
+ session.operations.send(operation)
206
+ receive.cancel()
207
+ releaseReceiver.countDown()
208
+ receive.join()
209
+ assertTrue(receive.isCancelled)
210
+ assertEquals("the forced schedule delivered the operation", null, delivered.get())
211
+ }
212
+ }
213
+ assertFalse(deferred.isCompleted)
214
+
215
+ // The job does this on idle timeout: it aborts in its catch and ends in its finally.
216
+ session.abort(timeout)
217
+ session.end()
218
+
219
+ assertTrue("dropped operation lost its completion owner", deferred.isCompleted)
220
+ assertSame(timeout, deferred.getCompletionExceptionOrNull())
221
+ val late = CompletableDeferred<WritableMap>()
222
+ assertFalse(session.submit(SynchroModule.TransactionOp.Execute("SELECT 1", emptyArray(), late)))
223
+ assertSame(timeout, late.getCompletionExceptionOrNull())
224
+ } finally {
225
+ releaseReceiver.countDown()
226
+ receiverThread.shutdown()
227
+ assertTrue(receiverThread.awaitTermination(GUARD_SECONDS, TimeUnit.SECONDS))
228
+ }
229
+ }
230
+
231
+ private fun acceptedSession(): SynchroModule.TransactionSession {
232
+ val begin = BeginRecorder()
233
+ val session = SynchroModule.TransactionSession(isWrite = true, beginSettlement = begin::record)
234
+ session.acceptBegin("terminal")
235
+ assertEquals(listOf("terminal"), begin.successes)
236
+ return session
237
+ }
238
+
239
+ private class LoopResult(
240
+ val session: SynchroModule.TransactionSession,
241
+ val received: ReceivedTerminal,
242
+ val begin: BeginRecorder,
243
+ val sqlCalls: Int,
244
+ val loopFailure: Throwable?,
245
+ ) {
246
+ fun requireOwnedUntilFinish() {
247
+ assertEquals(listOf("wiring"), begin.successes)
248
+ assertEquals(0, sqlCalls)
249
+ assertFalse(received.deferred.isCompleted)
250
+ assertEquals(0, received.deliveryCount)
251
+ val exit = SessionError("selected transaction exit")
252
+ session.finishTerminal(exit)
253
+ assertTrue("loop did not transfer the received terminal to the session", received.deferred.isCompleted)
254
+ assertSame(exit, received.deferred.getCompletionExceptionOrNull())
255
+ assertEquals(1, received.deliveryCount)
256
+ }
257
+ }
258
+
259
+ /** Runs the actual loop on an owned thread and sends one terminal operation through its channel. */
260
+ private fun runTerminalLoop(commit: Boolean): LoopResult {
261
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
262
+ val module = SynchroModule(bridgeTestContext(context))
263
+ val begin = BeginRecorder()
264
+ val session = SynchroModule.TransactionSession(isWrite = true, beginSettlement = begin::record)
265
+ val received = ReceivedTerminal()
266
+ val sqlCalls = AtomicInteger()
267
+ val loopThread = Executors.newSingleThreadExecutor()
268
+ var primary: Throwable? = null
269
+ try {
270
+ val loop = loopThread.submit(Callable {
271
+ module.runTransactionLoop(
272
+ txID = "wiring",
273
+ session = session,
274
+ query = { _, _ -> sqlCalls.incrementAndGet(); emptyList() },
275
+ queryOne = { _, _ -> sqlCalls.incrementAndGet(); null },
276
+ execute = { _, _ -> sqlCalls.incrementAndGet(); ExecResult(rowsAffected = 0) },
277
+ )
278
+ })
279
+ val operation = if (commit) {
280
+ SynchroModule.TransactionOp.Commit(received.deferred)
281
+ } else {
282
+ SynchroModule.TransactionOp.Rollback(received.deferred)
283
+ }
284
+ runBlocking { withTimeout(TimeUnit.SECONDS.toMillis(GUARD_SECONDS)) { session.operations.send(operation) } }
285
+ val failure = try {
286
+ loop.get(GUARD_SECONDS, TimeUnit.SECONDS)
287
+ null
288
+ } catch (error: ExecutionException) {
289
+ error.cause
290
+ }
291
+ return LoopResult(session, received, begin, sqlCalls.get(), failure)
292
+ } catch (error: Throwable) {
293
+ primary = error
294
+ // Closing the actual session channel ends a loop that still waits for an operation.
295
+ session.abort(error)
296
+ throw error
297
+ } finally {
298
+ loopThread.shutdown()
299
+ val cleanup = try {
300
+ if (loopThread.awaitTermination(GUARD_SECONDS, TimeUnit.SECONDS)) {
301
+ null
302
+ } else {
303
+ AssertionError("loop thread did not stop")
304
+ }
305
+ } catch (error: InterruptedException) {
306
+ Thread.currentThread().interrupt()
307
+ error
308
+ }
309
+ if (cleanup != null) {
310
+ primary?.addSuppressed(cleanup) ?: throw cleanup
311
+ }
312
+ }
313
+ }
314
+ }