@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
@@ -8,6 +8,7 @@ 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
@@ -50,11 +51,12 @@ class SynchroModule(reactContext: ReactApplicationContext) :
50
51
  val INT64_PATTERN = Regex("^(?:0|-?[1-9][0-9]*)$")
51
52
  }
52
53
 
53
- private var client: SynchroClient? = null
54
+ internal var client: SynchroClient? = null
55
+ private set
54
56
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
55
57
  private val lifecycleMutex = Mutex()
56
58
  private val transactionLock = Any()
57
- private val sessions = ConcurrentHashMap<String, TransactionSession>()
59
+ internal val sessions = ConcurrentHashMap<String, TransactionSession>()
58
60
  private var acceptingTransactions = false
59
61
  private val observers = ConcurrentHashMap<String, Cancellable>()
60
62
  private val pendingAuthContinuations = ConcurrentHashMap<String, CancellableContinuation<String>>()
@@ -136,6 +138,9 @@ class SynchroModule(reactContext: ReactApplicationContext) :
136
138
  )
137
139
  is SynchroError.AlreadyStarted -> "ALREADY_STARTED" to emptyMap()
138
140
  is SynchroError.NotStarted -> "NOT_STARTED" to emptyMap()
141
+ is SynchroError.AtomicGroupInvalid -> "atomic_group_invalid" to mapOf(
142
+ "reason" to atomicGroupInvalidReasonWireValue(error.reason)
143
+ )
139
144
  }
140
145
  }
141
146
 
@@ -404,13 +409,7 @@ class SynchroModule(reactContext: ReactApplicationContext) :
404
409
  return
405
410
  }
406
411
  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)
412
+ val total = c.executeBatch(parseStatements(statements))
414
413
  val map = Arguments.createMap().apply {
415
414
  putInt("totalRowsAffected", total)
416
415
  }
@@ -422,11 +421,19 @@ class SynchroModule(reactContext: ReactApplicationContext) :
422
421
 
423
422
  // MARK: - Transactions
424
423
 
425
- private class TransactionSession(val isWrite: Boolean) {
424
+ internal class TransactionSession(
425
+ val isWrite: Boolean,
426
+ private var beginSettlement: ((Result<String>) -> Unit)?,
427
+ ) {
426
428
  val operations = Channel<TransactionOp>(Channel.RENDEZVOUS)
427
429
  lateinit var job: Job
428
430
  private var abortCause: Throwable? = null
429
431
  private var terminal = false
432
+ private var terminalCompletion: CompletableDeferred<Unit>? = null
433
+
434
+ // A cancelled receive can drop an operation that its sender already handed over.
435
+ // The session owns each submitted operation until its completion settles.
436
+ private val outstanding = mutableSetOf<TransactionOp>()
430
437
 
431
438
  @Synchronized
432
439
  fun abort(cause: Throwable) {
@@ -436,16 +443,106 @@ class SynchroModule(reactContext: ReactApplicationContext) :
436
443
  operations.close(cause)
437
444
  }
438
445
 
446
+ /**
447
+ * Ends the session after its transaction returns. Each operation that the loop did not
448
+ * complete, received or not, settles with the abort cause. A rollback settles successfully.
449
+ */
450
+ @Synchronized
451
+ fun end() {
452
+ if (!terminal) {
453
+ terminal = true
454
+ operations.close()
455
+ }
456
+ outstanding.toList().forEach(::settleUnrun)
457
+ }
458
+
459
+ /** Submits one operation. A session that already ended settles it at once. */
460
+ suspend fun <T> perform(op: TransactionOp, deferred: CompletableDeferred<T>): T {
461
+ if (submit(op)) {
462
+ try {
463
+ operations.send(op)
464
+ } catch (_: Exception) {
465
+ // The channel closed or dropped the operation. The session settles it when it ends.
466
+ }
467
+ }
468
+ return deferred.await()
469
+ }
470
+
439
471
  @Synchronized
440
- fun completeNormally(): Boolean {
472
+ fun submit(op: TransactionOp): Boolean {
473
+ if (terminal) {
474
+ settleUnrun(op)
475
+ return false
476
+ }
477
+ outstanding += op
478
+ return true
479
+ }
480
+
481
+ private fun settleUnrun(op: TransactionOp) {
482
+ outstanding -= op
483
+ when (op) {
484
+ is TransactionOp.Rollback -> op.deferred.complete(Unit)
485
+ else -> op.deferred.completeExceptionally(abortCause ?: TransactionAbortedException())
486
+ }
487
+ }
488
+
489
+ private fun release(deferred: CompletableDeferred<*>) {
490
+ outstanding.removeAll { it.deferred === deferred }
491
+ }
492
+
493
+ /**
494
+ * Settles begin when the transaction callback starts.
495
+ * A close that aborted the session earlier rejects begin instead.
496
+ */
497
+ @Synchronized
498
+ fun acceptBegin(txID: String) {
499
+ val settle = beginSettlement
500
+ beginSettlement = null
501
+ val cause = abortCause
502
+ if (cause != null) {
503
+ settle?.invoke(Result.failure(cause))
504
+ throw cause
505
+ }
506
+ settle?.invoke(Result.success(txID))
507
+ }
508
+
509
+ /** Rejects begin when the transaction fails before its callback starts. */
510
+ @Synchronized
511
+ fun rejectBegin(error: Throwable) {
512
+ val settle = beginSettlement ?: return
513
+ beginSettlement = null
514
+ settle(Result.failure(error))
515
+ }
516
+
517
+ /**
518
+ * Takes ownership of a received commit or rollback completion before the terminal decision.
519
+ * The job settles the owned completion after the database transaction ends.
520
+ */
521
+ @Synchronized
522
+ fun acceptTerminal(completion: CompletableDeferred<Unit>): Boolean {
523
+ release(completion)
524
+ terminalCompletion = completion
441
525
  if (terminal) return false
442
526
  terminal = true
443
527
  operations.close()
444
528
  return true
445
529
  }
446
530
 
531
+ /** Settles the owned terminal completion after the database transaction ends. */
532
+ @Synchronized
533
+ fun finishTerminal(error: Throwable?) {
534
+ val completion = terminalCompletion ?: return
535
+ terminalCompletion = null
536
+ if (error == null) {
537
+ completion.complete(Unit)
538
+ } else {
539
+ completion.completeExceptionally(error)
540
+ }
541
+ }
542
+
447
543
  @Synchronized
448
544
  fun <T> complete(deferred: CompletableDeferred<T>, value: T) {
545
+ release(deferred)
449
546
  val cause = abortCause
450
547
  if (cause == null) {
451
548
  deferred.complete(value)
@@ -456,21 +553,22 @@ class SynchroModule(reactContext: ReactApplicationContext) :
456
553
 
457
554
  @Synchronized
458
555
  fun <T> completeExceptionally(deferred: CompletableDeferred<T>, error: Throwable) {
556
+ release(deferred)
459
557
  deferred.completeExceptionally(abortCause ?: error)
460
558
  }
461
559
  }
462
560
 
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()
561
+ internal sealed class TransactionOp {
562
+ abstract val deferred: CompletableDeferred<*>
563
+
564
+ class Query(val sql: String, val params: Array<Any?>, override val deferred: CompletableDeferred<WritableArray>) : TransactionOp()
565
+ class QueryOne(val sql: String, val params: Array<Any?>, override val deferred: CompletableDeferred<WritableMap?>) : TransactionOp()
566
+ class Execute(val sql: String, val params: Array<Any?>, override val deferred: CompletableDeferred<WritableMap>) : TransactionOp()
567
+ class Commit(override val deferred: CompletableDeferred<Unit>) : TransactionOp()
568
+ class Rollback(override val deferred: CompletableDeferred<Unit>) : TransactionOp()
469
569
  }
470
570
 
471
- private class TransactionRollbackException(
472
- val completion: CompletableDeferred<Unit>,
473
- ) : Exception("rollback")
571
+ internal class TransactionRollbackException : Exception("rollback")
474
572
 
475
573
  private class TransactionAbortedException : Exception("Client closed during transaction")
476
574
  private class TransactionExpiredException : Exception("Transaction timed out due to inactivity")
@@ -480,12 +578,17 @@ class SynchroModule(reactContext: ReactApplicationContext) :
480
578
  beginTransaction(isWrite = true, promise = promise)
481
579
  }
482
580
 
581
+ @ReactMethod
582
+ override fun beginAtomicWriteTransaction(promise: Promise) {
583
+ beginTransaction(isWrite = true, isAtomic = true, promise = promise)
584
+ }
585
+
483
586
  @ReactMethod
484
587
  override fun beginReadTransaction(promise: Promise) {
485
588
  beginTransaction(isWrite = false, promise = promise)
486
589
  }
487
590
 
488
- private fun beginTransaction(isWrite: Boolean, promise: Promise) {
591
+ private fun beginTransaction(isWrite: Boolean, promise: Promise, isAtomic: Boolean = false) {
489
592
  val c = synchronized(transactionLock) {
490
593
  client.takeIf { acceptingTransactions }
491
594
  } ?: run {
@@ -494,28 +597,31 @@ class SynchroModule(reactContext: ReactApplicationContext) :
494
597
  }
495
598
 
496
599
  val txID = UUID.randomUUID().toString()
497
- val session = TransactionSession(isWrite)
600
+ val session = TransactionSession(isWrite) { result ->
601
+ result.fold(
602
+ onSuccess = { promise.resolve(it) },
603
+ onFailure = { rejectWithError(promise, it) },
604
+ )
605
+ }
498
606
 
499
607
  val job = scope.launch(start = CoroutineStart.LAZY) {
500
- var finalDeferred: CompletableDeferred<Unit>? = null
501
608
  try {
502
609
  if (isWrite) {
503
- c.writeTransaction { transaction ->
504
- finalDeferred = runTransactionLoop(
610
+ val body: (ApplicationTransaction) -> Unit = { transaction ->
611
+ runTransactionLoop(
505
612
  txID = txID,
506
613
  session = session,
507
- promise = promise,
508
614
  query = transaction::query,
509
615
  queryOne = transaction::queryOne,
510
616
  execute = transaction::execute,
511
617
  )
512
618
  }
619
+ if (isAtomic) c.atomicWriteTransaction(body) else c.writeTransaction(body)
513
620
  } else {
514
621
  c.readTransaction { transaction ->
515
- finalDeferred = runTransactionLoop(
622
+ runTransactionLoop(
516
623
  txID = txID,
517
624
  session = session,
518
- promise = promise,
519
625
  query = transaction::query,
520
626
  queryOne = transaction::queryOne,
521
627
  execute = { _, _ ->
@@ -524,17 +630,19 @@ class SynchroModule(reactContext: ReactApplicationContext) :
524
630
  )
525
631
  }
526
632
  }
527
- finalDeferred?.complete(Unit)
633
+ session.finishTerminal(null)
528
634
  } catch (e: TimeoutCancellationException) {
529
635
  val timeout = TransactionExpiredException()
530
636
  session.abort(timeout)
531
- finalDeferred?.completeExceptionally(timeout)
637
+ session.finishTerminal(timeout)
532
638
  } catch (e: TransactionRollbackException) {
533
- e.completion.complete(Unit)
639
+ session.finishTerminal(null)
534
640
  } catch (e: Exception) {
535
- finalDeferred?.completeExceptionally(e)
641
+ // Acquisition can fail before the callback settles begin.
642
+ session.rejectBegin(e)
643
+ session.finishTerminal(e)
536
644
  } finally {
537
- session.completeNormally()
645
+ session.end()
538
646
  sessions.remove(txID, session)
539
647
  }
540
648
  }
@@ -555,16 +663,14 @@ class SynchroModule(reactContext: ReactApplicationContext) :
555
663
  job.start()
556
664
  }
557
665
 
558
- private fun runTransactionLoop(
666
+ internal fun runTransactionLoop(
559
667
  txID: String,
560
668
  session: TransactionSession,
561
- promise: Promise,
562
669
  query: (String, Array<out Any?>?) -> List<Row>,
563
670
  queryOne: (String, Array<out Any?>?) -> Row?,
564
671
  execute: (String, Array<out Any?>?) -> ExecResult,
565
- ): CompletableDeferred<Unit>? {
566
- promise.resolve(txID)
567
- var finalDeferred: CompletableDeferred<Unit>? = null
672
+ ) {
673
+ session.acceptBegin(txID)
568
674
  runBlocking {
569
675
  transactionLoop@ while (true) {
570
676
  val result = withTimeout(5000) { session.operations.receiveCatching() }
@@ -604,19 +710,16 @@ class SynchroModule(reactContext: ReactApplicationContext) :
604
710
  }
605
711
  }
606
712
  is TransactionOp.Commit -> {
607
- finalDeferred = op.deferred
608
- if (!session.completeNormally()) throw TransactionAbortedException()
713
+ if (!session.acceptTerminal(op.deferred)) throw TransactionAbortedException()
609
714
  break@transactionLoop
610
715
  }
611
716
  is TransactionOp.Rollback -> {
612
- finalDeferred = op.deferred
613
- if (!session.completeNormally()) throw TransactionAbortedException()
614
- throw TransactionRollbackException(op.deferred)
717
+ if (!session.acceptTerminal(op.deferred)) throw TransactionAbortedException()
718
+ throw TransactionRollbackException()
615
719
  }
616
720
  }
617
721
  }
618
722
  }
619
- return finalDeferred
620
723
  }
621
724
 
622
725
  @ReactMethod
@@ -628,8 +731,7 @@ class SynchroModule(reactContext: ReactApplicationContext) :
628
731
  scope.launch {
629
732
  try {
630
733
  val deferred = CompletableDeferred<WritableArray>()
631
- session.operations.send(TransactionOp.Query(sql, parseParams(params), deferred))
632
- promise.resolve(deferred.await())
734
+ promise.resolve(session.perform(TransactionOp.Query(sql, parseParams(params), deferred), deferred))
633
735
  } catch (e: Exception) {
634
736
  rejectWithError(promise, e)
635
737
  }
@@ -645,8 +747,7 @@ class SynchroModule(reactContext: ReactApplicationContext) :
645
747
  scope.launch {
646
748
  try {
647
749
  val deferred = CompletableDeferred<WritableMap?>()
648
- session.operations.send(TransactionOp.QueryOne(sql, parseParams(params), deferred))
649
- promise.resolve(deferred.await())
750
+ promise.resolve(session.perform(TransactionOp.QueryOne(sql, parseParams(params), deferred), deferred))
650
751
  } catch (e: Exception) {
651
752
  rejectWithError(promise, e)
652
753
  }
@@ -666,8 +767,7 @@ class SynchroModule(reactContext: ReactApplicationContext) :
666
767
  scope.launch {
667
768
  try {
668
769
  val deferred = CompletableDeferred<WritableMap>()
669
- session.operations.send(TransactionOp.Execute(sql, parseParams(params), deferred))
670
- promise.resolve(deferred.await())
770
+ promise.resolve(session.perform(TransactionOp.Execute(sql, parseParams(params), deferred), deferred))
671
771
  } catch (e: Exception) {
672
772
  rejectWithError(promise, e)
673
773
  }
@@ -683,8 +783,7 @@ class SynchroModule(reactContext: ReactApplicationContext) :
683
783
  scope.launch {
684
784
  try {
685
785
  val deferred = CompletableDeferred<Unit>()
686
- session.operations.send(TransactionOp.Commit(deferred))
687
- deferred.await()
786
+ session.perform(TransactionOp.Commit(deferred), deferred)
688
787
  promise.resolve(null)
689
788
  } catch (e: Exception) {
690
789
  rejectWithError(promise, e)
@@ -700,12 +799,12 @@ class SynchroModule(reactContext: ReactApplicationContext) :
700
799
  }
701
800
  scope.launch {
702
801
  try {
802
+ // A session that ends before it runs this rollback settles it successfully.
703
803
  val deferred = CompletableDeferred<Unit>()
704
- session.operations.send(TransactionOp.Rollback(deferred))
705
- deferred.await()
804
+ session.perform(TransactionOp.Rollback(deferred), deferred)
706
805
  promise.resolve(null)
707
806
  } catch (e: Exception) {
708
- promise.resolve(null) // Best-effort
807
+ rejectWithError(promise, e)
709
808
  }
710
809
  }
711
810
  }
@@ -972,6 +1071,19 @@ class SynchroModule(reactContext: ReactApplicationContext) :
972
1071
  }
973
1072
  }
974
1073
 
1074
+ @ReactMethod
1075
+ override fun inspectRetainedMutationRecords(promise: Promise) {
1076
+ val c = client ?: run {
1077
+ promise.reject("NOT_CONNECTED", "Client not initialized")
1078
+ return
1079
+ }
1080
+ try {
1081
+ promise.resolve(JSONArray(c.inspectRetainedMutationRecords().map(::retainedMutationJson)).toString())
1082
+ } catch (e: Exception) {
1083
+ rejectWithError(promise, e)
1084
+ }
1085
+ }
1086
+
975
1087
  @ReactMethod
976
1088
  override fun inspectRejectedMutations(promise: Promise) {
977
1089
  val c = client ?: run {
@@ -986,73 +1098,51 @@ class SynchroModule(reactContext: ReactApplicationContext) :
986
1098
  }
987
1099
 
988
1100
  @ReactMethod
989
- override fun inspectClientState(promise: Promise) {
1101
+ override fun inspectRejectedMutationRecords(promise: Promise) {
990
1102
  val c = client ?: run {
991
1103
  promise.reject("NOT_CONNECTED", "Client not initialized")
992
1104
  return
993
1105
  }
994
1106
  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)
1107
+ promise.resolve(JSONArray(c.inspectRejectedMutationRecords().map(::retainedRejectionJson)).toString())
1108
+ } catch (e: Exception) {
1109
+ rejectWithError(promise, e)
1110
+ }
1111
+ }
1112
+
1113
+ /** Reads client state, retained details, and the requested application rows from one read-only snapshot. */
1114
+ @ReactMethod
1115
+ override fun inspectClientStateSnapshot(rowStatements: ReadableArray, promise: Promise) {
1116
+ val c = client ?: run {
1117
+ promise.reject("NOT_CONNECTED", "Client not initialized")
1118
+ return
1119
+ }
1120
+ try {
1121
+ val statements = parseStatements(rowStatements)
1122
+ val rows = mutableListOf<Row>()
1123
+ val snapshot = SynchroInspection(c).captureSnapshot(maximumRecords = 512) { _, transaction ->
1124
+ for (statement in statements) {
1125
+ rows += transaction.query(statement.sql, statement.params)
1028
1126
  }
1127
+ }
1128
+ val inspection = JSONObject().apply {
1129
+ put("client_state", clientStateJson(snapshot.capture))
1130
+ put("retained_mutations", snapshot.retainedMutations?.let { JSONArray(it.map(::retainedMutationJson)) } ?: JSONObject.NULL)
1131
+ put("rejected_mutations", snapshot.rejectedMutations?.let { JSONArray(it.map(::retainedRejectionJson)) } ?: JSONObject.NULL)
1132
+ }
1133
+ promise.resolve(Arguments.createMap().apply {
1134
+ putString("inspection", inspection.toString())
1135
+ putArray("applicationRows", rowsToWritableArray(rows))
1029
1136
  })
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
1137
  } catch (error: Exception) {
1052
1138
  rejectWithError(promise, error)
1053
1139
  }
1054
1140
  }
1055
1141
 
1142
+ /**
1143
+ * Reads one record's row metadata and every rebuild receipt. The inspection snapshot bounds
1144
+ * row metadata, so a durable proof reads its identity directly.
1145
+ */
1056
1146
  @ReactMethod
1057
1147
  override fun inspectDurableState(tableName: String, recordID: String, promise: Promise) {
1058
1148
  val c = client ?: run {
@@ -1392,6 +1482,87 @@ class SynchroModule(reactContext: ReactApplicationContext) :
1392
1482
  })
1393
1483
  }
1394
1484
 
1485
+ private fun clientStateJson(capture: ClientStateCaptureInspection): JSONObject {
1486
+ val schema: Any = capture.schema?.let {
1487
+ JSONObject().put("version", it.version).put("hash", it.hash)
1488
+ } ?: JSONObject.NULL
1489
+ val scopeStates = JSONArray(capture.scopeStates.map { value ->
1490
+ JSONObject().apply {
1491
+ put("scope_id", value.scopeID)
1492
+ put("cursor", value.cursor ?: JSONObject.NULL)
1493
+ put("checksum", value.checksum ?: JSONObject.NULL)
1494
+ put("local_checksum", value.localChecksum)
1495
+ put("generation", value.generation)
1496
+ }
1497
+ })
1498
+ val scopeRows = JSONArray(capture.scopeRows.map { value ->
1499
+ JSONObject().apply {
1500
+ put("scope_id", value.scopeID)
1501
+ put("table_name", value.tableName)
1502
+ put("record_id", value.recordID)
1503
+ put("checksum", value.checksum)
1504
+ put("generation", value.generation)
1505
+ }
1506
+ })
1507
+ val attempts = JSONArray(capture.rebuildAttempts.map { value ->
1508
+ JSONObject().apply {
1509
+ put("scope_id", value.scopeID)
1510
+ put("rebuild_id", value.rebuildID)
1511
+ put("client_generation", value.clientGeneration)
1512
+ put("schema_version", value.schemaVersion)
1513
+ put("schema_hash", value.schemaHash)
1514
+ put("generation", value.generation)
1515
+ put("cursor", value.cursor ?: JSONObject.NULL)
1516
+ put("page_limit", value.pageLimit)
1517
+ }
1518
+ })
1519
+ return JSONObject().apply {
1520
+ put("schema", schema)
1521
+ put("scope_states", scopeStates)
1522
+ put("scope_rows", scopeRows)
1523
+ put("rebuild_attempts", attempts)
1524
+ put("application_row_count", capture.applicationRowCount)
1525
+ put("mutation_ledger_count", capture.mutationLedgerCount)
1526
+ put("mutation_outcome_count", capture.mutationOutcomeCount)
1527
+ put("sealed_batch_count", capture.sealedBatchCount)
1528
+ put("rejected_mutation_count", capture.rejectedMutationCount)
1529
+ put("scope_state_count", capture.scopeStateCount)
1530
+ put("scope_row_count", capture.scopeRowCount)
1531
+ put("provenance_count", capture.provenanceCount)
1532
+ put("row_metadata_count", capture.rowMetadataCount)
1533
+ put("rebuild_attempt_count", capture.rebuildAttemptCount)
1534
+ put("rebuild_receipt_count", capture.rebuildReceiptCount)
1535
+ put(
1536
+ "provenance_maintenance_work_cursor",
1537
+ capture.provenanceMaintenanceWorkCursor.toString(),
1538
+ )
1539
+ }
1540
+ }
1541
+
1542
+ private fun parseStatements(statements: ReadableArray): List<SQLStatement> =
1543
+ (0 until statements.size()).map { i ->
1544
+ val item = statements.getMap(i) ?: throw IllegalArgumentException("Invalid SQL statement at index $i")
1545
+ val sql = item.getString("sql") ?: throw IllegalArgumentException("Missing SQL at index $i")
1546
+ val params = if (item.hasKey("params")) item.getArray("params") else null
1547
+ SQLStatement(sql, params?.let { parseParams(it) } ?: emptyArray())
1548
+ }
1549
+
1550
+ private fun retainedMutationJson(value: RetainedMutationInspection): JSONObject = when (value) {
1551
+ is RetainedMutationInspection.Current -> pendingMutationJson(value.mutation).put("representation", "current")
1552
+ is RetainedMutationInspection.Legacy -> JSONObject().apply {
1553
+ put("representation", "legacy")
1554
+ put("mutationID", value.mutation.mutationID)
1555
+ put("localOrder", value.mutation.localOrder)
1556
+ put("tableName", value.mutation.tableName)
1557
+ put("recordID", value.mutation.recordID)
1558
+ put("operation", operationWireValue(value.mutation.operation))
1559
+ put("baseVersion", value.mutation.baseVersion ?: JSONObject.NULL)
1560
+ put("clientVersion", value.mutation.clientVersion)
1561
+ put("status", localMutationStatusWireValue(value.mutation.status))
1562
+ put("sourceKind", value.mutation.sourceKind)
1563
+ }
1564
+ }
1565
+
1395
1566
  private fun pendingMutationJson(mutation: PendingMutationInspection): JSONObject = JSONObject().apply {
1396
1567
  put("mutationID", mutation.mutationID)
1397
1568
  put("localOrder", mutation.localOrder)
@@ -1422,6 +1593,23 @@ class SynchroModule(reactContext: ReactApplicationContext) :
1422
1593
  }))
1423
1594
  }
1424
1595
 
1596
+ private fun retainedRejectionJson(value: RetainedRejectionInspection): JSONObject = when (value) {
1597
+ is RetainedRejectionInspection.Current -> rejectedMutationJson(value.rejection).put("representation", "current")
1598
+ is RetainedRejectionInspection.Legacy -> JSONObject().apply {
1599
+ put("representation", "legacy")
1600
+ put("mutationID", value.rejection.mutationID)
1601
+ put("tableName", value.rejection.tableName)
1602
+ put("recordID", value.rejection.recordID)
1603
+ put("status", mutationStatusWireValue(value.rejection.status))
1604
+ put("code", mutationRejectionCodeWireValue(value.rejection.code))
1605
+ put("message", value.rejection.message ?: JSONObject.NULL)
1606
+ put("serverRowJSON", value.rejection.serverRowJSON ?: JSONObject.NULL)
1607
+ put("serverVersion", value.rejection.serverVersion ?: JSONObject.NULL)
1608
+ put("createdAt", value.rejection.createdAt)
1609
+ put("updatedAt", value.rejection.updatedAt)
1610
+ }
1611
+ }
1612
+
1425
1613
  private fun rejectedMutationJson(mutation: RejectedMutationInspection): JSONObject = JSONObject().apply {
1426
1614
  put("mutationID", mutation.mutationID)
1427
1615
  put("tableName", mutation.tableName)
@@ -1476,6 +1664,14 @@ class SynchroModule(reactContext: ReactApplicationContext) :
1476
1664
  MutationRejectionCode.POLICY_REJECTED -> "policy_rejected"
1477
1665
  MutationRejectionCode.VALIDATION_FAILED -> "validation_failed"
1478
1666
  MutationRejectionCode.TABLE_NOT_SYNCED -> "table_not_synced"
1667
+ MutationRejectionCode.ATOMIC_BATCH_REJECTED -> "atomic_batch_rejected"
1668
+ }
1669
+
1670
+ private fun atomicGroupInvalidReasonWireValue(reason: AtomicGroupInvalidReason): String = when (reason) {
1671
+ AtomicGroupInvalidReason.DELETE_FOLLOWED_BY_WRITE -> "deleteFollowedByWrite"
1672
+ AtomicGroupInvalidReason.TOO_MANY_MUTATIONS -> "tooManyMutations"
1673
+ AtomicGroupInvalidReason.MUTATION_TOO_LARGE -> "mutationTooLarge"
1674
+ AtomicGroupInvalidReason.REQUEST_TOO_LARGE -> "requestTooLarge"
1479
1675
  }
1480
1676
 
1481
1677
  private fun jsonObjectToJsonObject(value: JsonObject): JSONObject {