@trainstar/synchro-react-native 0.3.2 → 0.4.0-rc.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.
Files changed (50) hide show
  1. package/README.md +112 -7
  2. package/SynchroReactNative.podspec +18 -1
  3. package/android/build.gradle +5 -1
  4. package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/SynchroModuleTransactionTest.kt +593 -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 +328 -112
  7. package/ios/SynchroModule.mm +27 -4
  8. package/ios/SynchroModule.swift +251 -89
  9. package/ios-test-host/main.m +48 -0
  10. package/ios-tests/SynchroModuleTransactionTests.swift +709 -0
  11. package/ios-tests/TransactionSessionTests.swift +66 -0
  12. package/lib/module/NativeSynchro.js.map +1 -1
  13. package/lib/module/SynchroClient.js +141 -2
  14. package/lib/module/SynchroClient.js.map +1 -1
  15. package/lib/module/errors.js +15 -1
  16. package/lib/module/errors.js.map +1 -1
  17. package/lib/module/index.js +1 -1
  18. package/lib/module/index.js.map +1 -1
  19. package/lib/module/inspection.js +99 -8
  20. package/lib/module/inspection.js.map +1 -1
  21. package/lib/module/types.js +18 -0
  22. package/lib/module/types.js.map +1 -1
  23. package/lib/typescript/example/e2e/schema-check.test.d.ts +2 -0
  24. package/lib/typescript/example/e2e/schema-check.test.d.ts.map +1 -0
  25. package/lib/typescript/example/src/conformance/runner.d.ts +17 -2
  26. package/lib/typescript/example/src/conformance/runner.d.ts.map +1 -1
  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 +23 -5
  36. package/lib/typescript/src/inspection.d.ts.map +1 -1
  37. package/lib/typescript/src/types.d.ts +62 -1
  38. package/lib/typescript/src/types.d.ts.map +1 -1
  39. package/package.json +21 -6
  40. package/src/NativeSynchro.ts +6 -1
  41. package/src/SynchroClient.ts +160 -1
  42. package/src/errors.ts +30 -0
  43. package/src/index.ts +7 -0
  44. package/src/inspection.ts +138 -9
  45. package/src/types.ts +70 -1
  46. package/tooling-patches/apply.cjs +163 -0
  47. package/tooling-patches/cli-clean-20.2.0.patch +21 -0
  48. package/tooling-patches/cli-config-android-20.2.0.patch +13 -0
  49. package/tooling-patches/react-native-builder-bob-0.40.18.patch +74 -0
  50. package/tooling-patches/stream-json-1.9.1.patch +40 -0
@@ -8,10 +8,14 @@ import android.util.Base64
8
8
  import android.os.Process
9
9
  import com.facebook.react.bridge.*
10
10
  import com.trainstar.synchro.*
11
+ import com.trainstar.synchro.inspection.ClientStateCaptureInspection
11
12
  import com.trainstar.synchro.inspection.SynchroInspection
12
13
  import com.trainstar.synchro.inspection.TransportObservationCollector
13
14
  import com.trainstar.synchro.inspection.TransportObservationSnapshot
14
15
  import com.trainstar.synchro.inspection.TransportOperationClass
16
+ import com.trainstar.synchro.inspection.MigrationCheckpoint
17
+ import com.trainstar.synchro.inspection.MigrationJournalInspection
18
+ import com.trainstar.synchro.inspection.PhysicalSchemaColumnInspection
15
19
  import com.trainstar.synchro.inspection.withTransportObservation
16
20
  import kotlinx.coroutines.ExperimentalCoroutinesApi
17
21
  import kotlinx.coroutines.*
@@ -50,11 +54,12 @@ class SynchroModule(reactContext: ReactApplicationContext) :
50
54
  val INT64_PATTERN = Regex("^(?:0|-?[1-9][0-9]*)$")
51
55
  }
52
56
 
53
- private var client: SynchroClient? = null
57
+ internal var client: SynchroClient? = null
58
+ private set
54
59
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
55
60
  private val lifecycleMutex = Mutex()
56
61
  private val transactionLock = Any()
57
- private val sessions = ConcurrentHashMap<String, TransactionSession>()
62
+ internal val sessions = ConcurrentHashMap<String, TransactionSession>()
58
63
  private var acceptingTransactions = false
59
64
  private val observers = ConcurrentHashMap<String, Cancellable>()
60
65
  private val pendingAuthContinuations = ConcurrentHashMap<String, CancellableContinuation<String>>()
@@ -136,6 +141,9 @@ class SynchroModule(reactContext: ReactApplicationContext) :
136
141
  )
137
142
  is SynchroError.AlreadyStarted -> "ALREADY_STARTED" to emptyMap()
138
143
  is SynchroError.NotStarted -> "NOT_STARTED" to emptyMap()
144
+ is SynchroError.AtomicGroupInvalid -> "atomic_group_invalid" to mapOf(
145
+ "reason" to atomicGroupInvalidReasonWireValue(error.reason)
146
+ )
139
147
  }
140
148
  }
141
149
 
@@ -404,13 +412,7 @@ class SynchroModule(reactContext: ReactApplicationContext) :
404
412
  return
405
413
  }
406
414
  try {
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)
415
+ val total = c.executeBatch(parseStatements(statements))
414
416
  val map = Arguments.createMap().apply {
415
417
  putInt("totalRowsAffected", total)
416
418
  }
@@ -422,11 +424,19 @@ class SynchroModule(reactContext: ReactApplicationContext) :
422
424
 
423
425
  // MARK: - Transactions
424
426
 
425
- private class TransactionSession(val isWrite: Boolean) {
427
+ internal class TransactionSession(
428
+ val isWrite: Boolean,
429
+ private var beginSettlement: ((Result<String>) -> Unit)?,
430
+ ) {
426
431
  val operations = Channel<TransactionOp>(Channel.RENDEZVOUS)
427
432
  lateinit var job: Job
428
433
  private var abortCause: Throwable? = null
429
434
  private var terminal = false
435
+ private var terminalCompletion: CompletableDeferred<Unit>? = null
436
+
437
+ // A cancelled receive can drop an operation that its sender already handed over.
438
+ // The session owns each submitted operation until its completion settles.
439
+ private val outstanding = mutableSetOf<TransactionOp>()
430
440
 
431
441
  @Synchronized
432
442
  fun abort(cause: Throwable) {
@@ -436,16 +446,106 @@ class SynchroModule(reactContext: ReactApplicationContext) :
436
446
  operations.close(cause)
437
447
  }
438
448
 
449
+ /**
450
+ * Ends the session after its transaction returns. Each operation that the loop did not
451
+ * complete, received or not, settles with the abort cause. A rollback settles successfully.
452
+ */
453
+ @Synchronized
454
+ fun end() {
455
+ if (!terminal) {
456
+ terminal = true
457
+ operations.close()
458
+ }
459
+ outstanding.toList().forEach(::settleUnrun)
460
+ }
461
+
462
+ /** Submits one operation. A session that already ended settles it at once. */
463
+ suspend fun <T> perform(op: TransactionOp, deferred: CompletableDeferred<T>): T {
464
+ if (submit(op)) {
465
+ try {
466
+ operations.send(op)
467
+ } catch (_: Exception) {
468
+ // The channel closed or dropped the operation. The session settles it when it ends.
469
+ }
470
+ }
471
+ return deferred.await()
472
+ }
473
+
474
+ @Synchronized
475
+ fun submit(op: TransactionOp): Boolean {
476
+ if (terminal) {
477
+ settleUnrun(op)
478
+ return false
479
+ }
480
+ outstanding += op
481
+ return true
482
+ }
483
+
484
+ private fun settleUnrun(op: TransactionOp) {
485
+ outstanding -= op
486
+ when (op) {
487
+ is TransactionOp.Rollback -> op.deferred.complete(Unit)
488
+ else -> op.deferred.completeExceptionally(abortCause ?: TransactionAbortedException())
489
+ }
490
+ }
491
+
492
+ private fun release(deferred: CompletableDeferred<*>) {
493
+ outstanding.removeAll { it.deferred === deferred }
494
+ }
495
+
496
+ /**
497
+ * Settles begin when the transaction callback starts.
498
+ * A close that aborted the session earlier rejects begin instead.
499
+ */
500
+ @Synchronized
501
+ fun acceptBegin(txID: String) {
502
+ val settle = beginSettlement
503
+ beginSettlement = null
504
+ val cause = abortCause
505
+ if (cause != null) {
506
+ settle?.invoke(Result.failure(cause))
507
+ throw cause
508
+ }
509
+ settle?.invoke(Result.success(txID))
510
+ }
511
+
512
+ /** Rejects begin when the transaction fails before its callback starts. */
513
+ @Synchronized
514
+ fun rejectBegin(error: Throwable) {
515
+ val settle = beginSettlement ?: return
516
+ beginSettlement = null
517
+ settle(Result.failure(error))
518
+ }
519
+
520
+ /**
521
+ * Takes ownership of a received commit or rollback completion before the terminal decision.
522
+ * The job settles the owned completion after the database transaction ends.
523
+ */
439
524
  @Synchronized
440
- fun completeNormally(): Boolean {
525
+ fun acceptTerminal(completion: CompletableDeferred<Unit>): Boolean {
526
+ release(completion)
527
+ terminalCompletion = completion
441
528
  if (terminal) return false
442
529
  terminal = true
443
530
  operations.close()
444
531
  return true
445
532
  }
446
533
 
534
+ /** Settles the owned terminal completion after the database transaction ends. */
535
+ @Synchronized
536
+ fun finishTerminal(error: Throwable?) {
537
+ val completion = terminalCompletion ?: return
538
+ terminalCompletion = null
539
+ if (error == null) {
540
+ completion.complete(Unit)
541
+ } else {
542
+ completion.completeExceptionally(error)
543
+ }
544
+ }
545
+
447
546
  @Synchronized
448
547
  fun <T> complete(deferred: CompletableDeferred<T>, value: T) {
548
+ release(deferred)
449
549
  val cause = abortCause
450
550
  if (cause == null) {
451
551
  deferred.complete(value)
@@ -456,21 +556,22 @@ class SynchroModule(reactContext: ReactApplicationContext) :
456
556
 
457
557
  @Synchronized
458
558
  fun <T> completeExceptionally(deferred: CompletableDeferred<T>, error: Throwable) {
559
+ release(deferred)
459
560
  deferred.completeExceptionally(abortCause ?: error)
460
561
  }
461
562
  }
462
563
 
463
- private sealed class TransactionOp {
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()
466
- data class Execute(val sql: String, val params: Array<Any?>, val deferred: CompletableDeferred<WritableMap>) : TransactionOp()
467
- class Commit(val deferred: CompletableDeferred<Unit>) : TransactionOp()
468
- class Rollback(val deferred: CompletableDeferred<Unit>) : TransactionOp()
564
+ internal sealed class TransactionOp {
565
+ abstract val deferred: CompletableDeferred<*>
566
+
567
+ class Query(val sql: String, val params: Array<Any?>, override val deferred: CompletableDeferred<WritableArray>) : TransactionOp()
568
+ class QueryOne(val sql: String, val params: Array<Any?>, override val deferred: CompletableDeferred<WritableMap?>) : TransactionOp()
569
+ class Execute(val sql: String, val params: Array<Any?>, override val deferred: CompletableDeferred<WritableMap>) : TransactionOp()
570
+ class Commit(override val deferred: CompletableDeferred<Unit>) : TransactionOp()
571
+ class Rollback(override val deferred: CompletableDeferred<Unit>) : TransactionOp()
469
572
  }
470
573
 
471
- private class TransactionRollbackException(
472
- val completion: CompletableDeferred<Unit>,
473
- ) : Exception("rollback")
574
+ internal class TransactionRollbackException : Exception("rollback")
474
575
 
475
576
  private class TransactionAbortedException : Exception("Client closed during transaction")
476
577
  private class TransactionExpiredException : Exception("Transaction timed out due to inactivity")
@@ -480,12 +581,17 @@ class SynchroModule(reactContext: ReactApplicationContext) :
480
581
  beginTransaction(isWrite = true, promise = promise)
481
582
  }
482
583
 
584
+ @ReactMethod
585
+ override fun beginAtomicWriteTransaction(promise: Promise) {
586
+ beginTransaction(isWrite = true, isAtomic = true, promise = promise)
587
+ }
588
+
483
589
  @ReactMethod
484
590
  override fun beginReadTransaction(promise: Promise) {
485
591
  beginTransaction(isWrite = false, promise = promise)
486
592
  }
487
593
 
488
- private fun beginTransaction(isWrite: Boolean, promise: Promise) {
594
+ private fun beginTransaction(isWrite: Boolean, promise: Promise, isAtomic: Boolean = false) {
489
595
  val c = synchronized(transactionLock) {
490
596
  client.takeIf { acceptingTransactions }
491
597
  } ?: run {
@@ -494,28 +600,31 @@ class SynchroModule(reactContext: ReactApplicationContext) :
494
600
  }
495
601
 
496
602
  val txID = UUID.randomUUID().toString()
497
- val session = TransactionSession(isWrite)
603
+ val session = TransactionSession(isWrite) { result ->
604
+ result.fold(
605
+ onSuccess = { promise.resolve(it) },
606
+ onFailure = { rejectWithError(promise, it) },
607
+ )
608
+ }
498
609
 
499
610
  val job = scope.launch(start = CoroutineStart.LAZY) {
500
- var finalDeferred: CompletableDeferred<Unit>? = null
501
611
  try {
502
612
  if (isWrite) {
503
- c.writeTransaction { transaction ->
504
- finalDeferred = runTransactionLoop(
613
+ val body: (ApplicationTransaction) -> Unit = { transaction ->
614
+ runTransactionLoop(
505
615
  txID = txID,
506
616
  session = session,
507
- promise = promise,
508
617
  query = transaction::query,
509
618
  queryOne = transaction::queryOne,
510
619
  execute = transaction::execute,
511
620
  )
512
621
  }
622
+ if (isAtomic) c.atomicWriteTransaction(body) else c.writeTransaction(body)
513
623
  } else {
514
624
  c.readTransaction { transaction ->
515
- finalDeferred = runTransactionLoop(
625
+ runTransactionLoop(
516
626
  txID = txID,
517
627
  session = session,
518
- promise = promise,
519
628
  query = transaction::query,
520
629
  queryOne = transaction::queryOne,
521
630
  execute = { _, _ ->
@@ -524,17 +633,19 @@ class SynchroModule(reactContext: ReactApplicationContext) :
524
633
  )
525
634
  }
526
635
  }
527
- finalDeferred?.complete(Unit)
636
+ session.finishTerminal(null)
528
637
  } catch (e: TimeoutCancellationException) {
529
638
  val timeout = TransactionExpiredException()
530
639
  session.abort(timeout)
531
- finalDeferred?.completeExceptionally(timeout)
640
+ session.finishTerminal(timeout)
532
641
  } catch (e: TransactionRollbackException) {
533
- e.completion.complete(Unit)
642
+ session.finishTerminal(null)
534
643
  } catch (e: Exception) {
535
- finalDeferred?.completeExceptionally(e)
644
+ // Acquisition can fail before the callback settles begin.
645
+ session.rejectBegin(e)
646
+ session.finishTerminal(e)
536
647
  } finally {
537
- session.completeNormally()
648
+ session.end()
538
649
  sessions.remove(txID, session)
539
650
  }
540
651
  }
@@ -555,16 +666,14 @@ class SynchroModule(reactContext: ReactApplicationContext) :
555
666
  job.start()
556
667
  }
557
668
 
558
- private fun runTransactionLoop(
669
+ internal fun runTransactionLoop(
559
670
  txID: String,
560
671
  session: TransactionSession,
561
- promise: Promise,
562
672
  query: (String, Array<out Any?>?) -> List<Row>,
563
673
  queryOne: (String, Array<out Any?>?) -> Row?,
564
674
  execute: (String, Array<out Any?>?) -> ExecResult,
565
- ): CompletableDeferred<Unit>? {
566
- promise.resolve(txID)
567
- var finalDeferred: CompletableDeferred<Unit>? = null
675
+ ) {
676
+ session.acceptBegin(txID)
568
677
  runBlocking {
569
678
  transactionLoop@ while (true) {
570
679
  val result = withTimeout(5000) { session.operations.receiveCatching() }
@@ -604,19 +713,16 @@ class SynchroModule(reactContext: ReactApplicationContext) :
604
713
  }
605
714
  }
606
715
  is TransactionOp.Commit -> {
607
- finalDeferred = op.deferred
608
- if (!session.completeNormally()) throw TransactionAbortedException()
716
+ if (!session.acceptTerminal(op.deferred)) throw TransactionAbortedException()
609
717
  break@transactionLoop
610
718
  }
611
719
  is TransactionOp.Rollback -> {
612
- finalDeferred = op.deferred
613
- if (!session.completeNormally()) throw TransactionAbortedException()
614
- throw TransactionRollbackException(op.deferred)
720
+ if (!session.acceptTerminal(op.deferred)) throw TransactionAbortedException()
721
+ throw TransactionRollbackException()
615
722
  }
616
723
  }
617
724
  }
618
725
  }
619
- return finalDeferred
620
726
  }
621
727
 
622
728
  @ReactMethod
@@ -628,8 +734,7 @@ class SynchroModule(reactContext: ReactApplicationContext) :
628
734
  scope.launch {
629
735
  try {
630
736
  val deferred = CompletableDeferred<WritableArray>()
631
- session.operations.send(TransactionOp.Query(sql, parseParams(params), deferred))
632
- promise.resolve(deferred.await())
737
+ promise.resolve(session.perform(TransactionOp.Query(sql, parseParams(params), deferred), deferred))
633
738
  } catch (e: Exception) {
634
739
  rejectWithError(promise, e)
635
740
  }
@@ -645,8 +750,7 @@ class SynchroModule(reactContext: ReactApplicationContext) :
645
750
  scope.launch {
646
751
  try {
647
752
  val deferred = CompletableDeferred<WritableMap?>()
648
- session.operations.send(TransactionOp.QueryOne(sql, parseParams(params), deferred))
649
- promise.resolve(deferred.await())
753
+ promise.resolve(session.perform(TransactionOp.QueryOne(sql, parseParams(params), deferred), deferred))
650
754
  } catch (e: Exception) {
651
755
  rejectWithError(promise, e)
652
756
  }
@@ -666,8 +770,7 @@ class SynchroModule(reactContext: ReactApplicationContext) :
666
770
  scope.launch {
667
771
  try {
668
772
  val deferred = CompletableDeferred<WritableMap>()
669
- session.operations.send(TransactionOp.Execute(sql, parseParams(params), deferred))
670
- promise.resolve(deferred.await())
773
+ promise.resolve(session.perform(TransactionOp.Execute(sql, parseParams(params), deferred), deferred))
671
774
  } catch (e: Exception) {
672
775
  rejectWithError(promise, e)
673
776
  }
@@ -683,8 +786,7 @@ class SynchroModule(reactContext: ReactApplicationContext) :
683
786
  scope.launch {
684
787
  try {
685
788
  val deferred = CompletableDeferred<Unit>()
686
- session.operations.send(TransactionOp.Commit(deferred))
687
- deferred.await()
789
+ session.perform(TransactionOp.Commit(deferred), deferred)
688
790
  promise.resolve(null)
689
791
  } catch (e: Exception) {
690
792
  rejectWithError(promise, e)
@@ -700,12 +802,12 @@ class SynchroModule(reactContext: ReactApplicationContext) :
700
802
  }
701
803
  scope.launch {
702
804
  try {
805
+ // A session that ends before it runs this rollback settles it successfully.
703
806
  val deferred = CompletableDeferred<Unit>()
704
- session.operations.send(TransactionOp.Rollback(deferred))
705
- deferred.await()
807
+ session.perform(TransactionOp.Rollback(deferred), deferred)
706
808
  promise.resolve(null)
707
809
  } catch (e: Exception) {
708
- promise.resolve(null) // Best-effort
810
+ rejectWithError(promise, e)
709
811
  }
710
812
  }
711
813
  }
@@ -972,6 +1074,19 @@ class SynchroModule(reactContext: ReactApplicationContext) :
972
1074
  }
973
1075
  }
974
1076
 
1077
+ @ReactMethod
1078
+ override fun inspectRetainedMutationRecords(promise: Promise) {
1079
+ val c = client ?: run {
1080
+ promise.reject("NOT_CONNECTED", "Client not initialized")
1081
+ return
1082
+ }
1083
+ try {
1084
+ promise.resolve(JSONArray(c.inspectRetainedMutationRecords().map(::retainedMutationJson)).toString())
1085
+ } catch (e: Exception) {
1086
+ rejectWithError(promise, e)
1087
+ }
1088
+ }
1089
+
975
1090
  @ReactMethod
976
1091
  override fun inspectRejectedMutations(promise: Promise) {
977
1092
  val c = client ?: run {
@@ -986,73 +1101,61 @@ class SynchroModule(reactContext: ReactApplicationContext) :
986
1101
  }
987
1102
 
988
1103
  @ReactMethod
989
- override fun inspectClientState(promise: Promise) {
1104
+ override fun inspectRejectedMutationRecords(promise: Promise) {
990
1105
  val c = client ?: run {
991
1106
  promise.reject("NOT_CONNECTED", "Client not initialized")
992
1107
  return
993
1108
  }
994
1109
  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)
1110
+ promise.resolve(JSONArray(c.inspectRejectedMutationRecords().map(::retainedRejectionJson)).toString())
1111
+ } catch (e: Exception) {
1112
+ rejectWithError(promise, e)
1113
+ }
1114
+ }
1115
+
1116
+ /** Reads client state, retained details, and the requested application rows from one read-only snapshot. */
1117
+ @ReactMethod
1118
+ override fun inspectClientStateSnapshot(rowStatements: ReadableArray, promise: Promise) {
1119
+ val c = client ?: run {
1120
+ promise.reject("NOT_CONNECTED", "Client not initialized")
1121
+ return
1122
+ }
1123
+ try {
1124
+ val statements = parseStatements(rowStatements)
1125
+ val rows = mutableListOf<Row>()
1126
+ val snapshot = SynchroInspection(c).captureSnapshot(maximumRecords = 512) { _, transaction ->
1127
+ for (statement in statements) {
1128
+ rows += transaction.query(statement.sql, statement.params)
1028
1129
  }
1130
+ }
1131
+ val inspection = JSONObject().apply {
1132
+ put("client_state", clientStateJson(snapshot.capture))
1133
+ put("migration_journal", snapshot.capture.migrationJournal?.let {
1134
+ JSONObject(Json.encodeToString(MigrationJournalInspection.serializer(), it))
1135
+ } ?: JSONObject.NULL)
1136
+ put("migration_journal_truncated", snapshot.capture.migrationJournalTruncated)
1137
+ put("physical_schema", JSONArray(snapshot.capture.physicalSchema.map {
1138
+ JSONObject(Json.encodeToString(PhysicalSchemaColumnInspection.serializer(), it))
1139
+ }))
1140
+ put("physical_schema_truncated", snapshot.capture.physicalSchemaTruncated)
1141
+ put("accepted_mutation_outcomes", JSONObject(snapshot.capture.acceptedMutationOutcomes))
1142
+ put("accepted_mutation_outcomes_truncated", snapshot.capture.acceptedMutationOutcomesTruncated)
1143
+ put("retained_mutations", snapshot.retainedMutations?.let { JSONArray(it.map(::retainedMutationJson)) } ?: JSONObject.NULL)
1144
+ put("rejected_mutations", snapshot.rejectedMutations?.let { JSONArray(it.map(::retainedRejectionJson)) } ?: JSONObject.NULL)
1145
+ }
1146
+ promise.resolve(Arguments.createMap().apply {
1147
+ putString("inspection", inspection.toString())
1148
+ putArray("applicationRows", rowsToWritableArray(rows))
1029
1149
  })
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
1150
  } catch (error: Exception) {
1052
1151
  rejectWithError(promise, error)
1053
1152
  }
1054
1153
  }
1055
1154
 
1155
+ /**
1156
+ * Reads one record's row metadata and every rebuild receipt. The inspection snapshot bounds
1157
+ * row metadata, so a durable proof reads its identity directly.
1158
+ */
1056
1159
  @ReactMethod
1057
1160
  override fun inspectDurableState(tableName: String, recordID: String, promise: Promise) {
1058
1161
  val c = client ?: run {
@@ -1112,7 +1215,8 @@ class SynchroModule(reactContext: ReactApplicationContext) :
1112
1215
  override fun armTransportPause(operationClass: String, promise: Promise) {
1113
1216
  try {
1114
1217
  val collector = transportObservationCollector ?: error("Transport observation is not configured")
1115
- collector.armPause(transportOperationClass(operationClass))
1218
+ val checkpoint = MigrationCheckpoint.fromWire(operationClass)
1219
+ if (checkpoint != null) collector.armPause(checkpoint) else collector.armPause(transportOperationClass(operationClass))
1116
1220
  promise.resolve(null)
1117
1221
  } catch (error: Exception) {
1118
1222
  rejectWithError(promise, error)
@@ -1130,7 +1234,12 @@ class SynchroModule(reactContext: ReactApplicationContext) :
1130
1234
  timeoutMs % 1.0 == 0.0,
1131
1235
  ) { "Transport pause timeout is invalid" }
1132
1236
  val collector = transportObservationCollector ?: error("Transport observation is not configured")
1133
- collector.awaitPause(transportOperationClass(operationClass), timeoutMs.toLong())
1237
+ val checkpoint = MigrationCheckpoint.fromWire(operationClass)
1238
+ if (checkpoint != null) {
1239
+ collector.awaitPause(checkpoint, timeoutMs.toLong())
1240
+ } else {
1241
+ collector.awaitPause(transportOperationClass(operationClass), timeoutMs.toLong())
1242
+ }
1134
1243
  promise.resolve(null)
1135
1244
  } catch (error: Exception) {
1136
1245
  rejectWithError(promise, error)
@@ -1392,6 +1501,88 @@ class SynchroModule(reactContext: ReactApplicationContext) :
1392
1501
  })
1393
1502
  }
1394
1503
 
1504
+ private fun clientStateJson(capture: ClientStateCaptureInspection): JSONObject {
1505
+ val schema: Any = capture.schema?.let {
1506
+ JSONObject().put("version", it.version).put("hash", it.hash)
1507
+ } ?: JSONObject.NULL
1508
+ val scopeStates = JSONArray(capture.scopeStates.map { value ->
1509
+ JSONObject().apply {
1510
+ put("scope_id", value.scopeID)
1511
+ put("cursor", value.cursor ?: JSONObject.NULL)
1512
+ put("checksum", value.checksum ?: JSONObject.NULL)
1513
+ put("local_checksum", value.localChecksum)
1514
+ put("generation", value.generation)
1515
+ }
1516
+ })
1517
+ val scopeRows = JSONArray(capture.scopeRows.map { value ->
1518
+ JSONObject().apply {
1519
+ put("scope_id", value.scopeID)
1520
+ put("table_name", value.tableName)
1521
+ put("record_id", value.recordID)
1522
+ put("checksum", value.checksum)
1523
+ put("generation", value.generation)
1524
+ }
1525
+ })
1526
+ val attempts = JSONArray(capture.rebuildAttempts.map { value ->
1527
+ JSONObject().apply {
1528
+ put("scope_id", value.scopeID)
1529
+ put("rebuild_id", value.rebuildID)
1530
+ put("client_generation", value.clientGeneration)
1531
+ put("schema_version", value.schemaVersion)
1532
+ put("schema_hash", value.schemaHash)
1533
+ put("generation", value.generation)
1534
+ put("cursor", value.cursor ?: JSONObject.NULL)
1535
+ put("page_limit", value.pageLimit)
1536
+ }
1537
+ })
1538
+ return JSONObject().apply {
1539
+ put("schema", schema)
1540
+ put("scope_states", scopeStates)
1541
+ put("scope_rows", scopeRows)
1542
+ put("rebuild_attempts", attempts)
1543
+ put("application_row_count", capture.applicationRowCount)
1544
+ put("mutation_ledger_count", capture.mutationLedgerCount)
1545
+ put("mutation_outcome_count", capture.mutationOutcomeCount)
1546
+ put("sealed_batch_count", capture.sealedBatchCount)
1547
+ put("rejected_mutation_count", capture.rejectedMutationCount)
1548
+ put("scope_state_count", capture.scopeStateCount)
1549
+ put("scope_row_count", capture.scopeRowCount)
1550
+ put("provenance_count", capture.provenanceCount)
1551
+ put("row_metadata_count", capture.rowMetadataCount)
1552
+ put("rebuild_attempt_count", capture.rebuildAttemptCount)
1553
+ put("rebuild_receipt_count", capture.rebuildReceiptCount)
1554
+ put("capture_overflowed", capture.overflowed)
1555
+ put(
1556
+ "provenance_maintenance_work_cursor",
1557
+ capture.provenanceMaintenanceWorkCursor.toString(),
1558
+ )
1559
+ }
1560
+ }
1561
+
1562
+ private fun parseStatements(statements: ReadableArray): List<SQLStatement> =
1563
+ (0 until statements.size()).map { i ->
1564
+ val item = statements.getMap(i) ?: throw IllegalArgumentException("Invalid SQL statement at index $i")
1565
+ val sql = item.getString("sql") ?: throw IllegalArgumentException("Missing SQL at index $i")
1566
+ val params = if (item.hasKey("params")) item.getArray("params") else null
1567
+ SQLStatement(sql, params?.let { parseParams(it) } ?: emptyArray())
1568
+ }
1569
+
1570
+ private fun retainedMutationJson(value: RetainedMutationInspection): JSONObject = when (value) {
1571
+ is RetainedMutationInspection.Current -> pendingMutationJson(value.mutation).put("representation", "current")
1572
+ is RetainedMutationInspection.Legacy -> JSONObject().apply {
1573
+ put("representation", "legacy")
1574
+ put("mutationID", value.mutation.mutationID)
1575
+ put("localOrder", value.mutation.localOrder)
1576
+ put("tableName", value.mutation.tableName)
1577
+ put("recordID", value.mutation.recordID)
1578
+ put("operation", operationWireValue(value.mutation.operation))
1579
+ put("baseVersion", value.mutation.baseVersion ?: JSONObject.NULL)
1580
+ put("clientVersion", value.mutation.clientVersion)
1581
+ put("status", localMutationStatusWireValue(value.mutation.status))
1582
+ put("sourceKind", value.mutation.sourceKind)
1583
+ }
1584
+ }
1585
+
1395
1586
  private fun pendingMutationJson(mutation: PendingMutationInspection): JSONObject = JSONObject().apply {
1396
1587
  put("mutationID", mutation.mutationID)
1397
1588
  put("localOrder", mutation.localOrder)
@@ -1422,6 +1613,23 @@ class SynchroModule(reactContext: ReactApplicationContext) :
1422
1613
  }))
1423
1614
  }
1424
1615
 
1616
+ private fun retainedRejectionJson(value: RetainedRejectionInspection): JSONObject = when (value) {
1617
+ is RetainedRejectionInspection.Current -> rejectedMutationJson(value.rejection).put("representation", "current")
1618
+ is RetainedRejectionInspection.Legacy -> JSONObject().apply {
1619
+ put("representation", "legacy")
1620
+ put("mutationID", value.rejection.mutationID)
1621
+ put("tableName", value.rejection.tableName)
1622
+ put("recordID", value.rejection.recordID)
1623
+ put("status", mutationStatusWireValue(value.rejection.status))
1624
+ put("code", mutationRejectionCodeWireValue(value.rejection.code))
1625
+ put("message", value.rejection.message ?: JSONObject.NULL)
1626
+ put("serverRowJSON", value.rejection.serverRowJSON ?: JSONObject.NULL)
1627
+ put("serverVersion", value.rejection.serverVersion ?: JSONObject.NULL)
1628
+ put("createdAt", value.rejection.createdAt)
1629
+ put("updatedAt", value.rejection.updatedAt)
1630
+ }
1631
+ }
1632
+
1425
1633
  private fun rejectedMutationJson(mutation: RejectedMutationInspection): JSONObject = JSONObject().apply {
1426
1634
  put("mutationID", mutation.mutationID)
1427
1635
  put("tableName", mutation.tableName)
@@ -1476,6 +1684,14 @@ class SynchroModule(reactContext: ReactApplicationContext) :
1476
1684
  MutationRejectionCode.POLICY_REJECTED -> "policy_rejected"
1477
1685
  MutationRejectionCode.VALIDATION_FAILED -> "validation_failed"
1478
1686
  MutationRejectionCode.TABLE_NOT_SYNCED -> "table_not_synced"
1687
+ MutationRejectionCode.ATOMIC_BATCH_REJECTED -> "atomic_batch_rejected"
1688
+ }
1689
+
1690
+ private fun atomicGroupInvalidReasonWireValue(reason: AtomicGroupInvalidReason): String = when (reason) {
1691
+ AtomicGroupInvalidReason.DELETE_FOLLOWED_BY_WRITE -> "deleteFollowedByWrite"
1692
+ AtomicGroupInvalidReason.TOO_MANY_MUTATIONS -> "tooManyMutations"
1693
+ AtomicGroupInvalidReason.MUTATION_TOO_LARGE -> "mutationTooLarge"
1694
+ AtomicGroupInvalidReason.REQUEST_TOO_LARGE -> "requestTooLarge"
1479
1695
  }
1480
1696
 
1481
1697
  private fun jsonObjectToJsonObject(value: JsonObject): JSONObject {