@trainstar/synchro-react-native 0.4.0-rc.3 → 0.4.0-rc.4
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 +35 -5
- package/SynchroReactNative.podspec +2 -1
- package/android/build.gradle +8 -7
- package/ios/SynchroModule.swift +776 -540
- package/ios/SynchroOwnership.swift +173 -0
- package/ios-test-host/{main.m → AppDelegate.m} +0 -7
- package/ios-test-host/main.swift +12 -0
- package/ios-tests/SynchroModuleTransactionTests.swift +93 -72
- package/ios-tests/TransactionSessionTests.swift +167 -12
- package/package.json +4 -1
- package/tooling-patches/apply.cjs +82 -1
- package/tooling-patches/react-native-gradle-plugin-0.82.1-kotlin-2.4.21.patch +131 -0
- package/tooling-patches/react-native-gradle-plugin-0.88.0-kotlin-2.4.21.patch +30 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import os
|
|
3
|
+
import React
|
|
4
|
+
|
|
5
|
+
/// Values that can cross wrapper execution boundaries without carrying Foundation containers.
|
|
6
|
+
enum BridgeValue: Sendable {
|
|
7
|
+
case null
|
|
8
|
+
case bool(Bool)
|
|
9
|
+
case integer(Int64)
|
|
10
|
+
case double(Double)
|
|
11
|
+
case string(String)
|
|
12
|
+
case array([BridgeValue])
|
|
13
|
+
case object([String: BridgeValue])
|
|
14
|
+
|
|
15
|
+
func foundationValue() -> Any {
|
|
16
|
+
switch self {
|
|
17
|
+
case .null:
|
|
18
|
+
return NSNull()
|
|
19
|
+
case .bool(let value):
|
|
20
|
+
return NSNumber(value: value)
|
|
21
|
+
case .integer(let value):
|
|
22
|
+
return NSNumber(value: value)
|
|
23
|
+
case .double(let value):
|
|
24
|
+
return NSNumber(value: value)
|
|
25
|
+
case .string(let value):
|
|
26
|
+
return value
|
|
27
|
+
case .array(let values):
|
|
28
|
+
return values.map { $0.foundationValue() }
|
|
29
|
+
case .object(let values):
|
|
30
|
+
return values.mapValues { $0.foundationValue() }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
enum BridgePromiseValue: Sendable {
|
|
36
|
+
case void
|
|
37
|
+
case value(BridgeValue)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Owns the imported React promise blocks and settles them exactly once.
|
|
41
|
+
///
|
|
42
|
+
/// React Native copies these blocks for an escaping module invocation. Its TurboModule
|
|
43
|
+
/// implementation protects its shared resolve/reject state with a mutex and schedules
|
|
44
|
+
/// JavaScript through `AsyncCallback`. React Native also permits native callbacks from
|
|
45
|
+
/// any queue. Swift cannot express those Objective-C runtime guarantees as `Sendable`.
|
|
46
|
+
final class ReactPromiseSettlement: @unchecked Sendable {
|
|
47
|
+
private let lock = NSLock()
|
|
48
|
+
private var resolveBlock: RCTPromiseResolveBlock?
|
|
49
|
+
private var rejectBlock: RCTPromiseRejectBlock?
|
|
50
|
+
|
|
51
|
+
init(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
52
|
+
resolveBlock = resolve
|
|
53
|
+
rejectBlock = reject
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@discardableResult
|
|
57
|
+
func resolve(_ value: BridgePromiseValue) -> Bool {
|
|
58
|
+
lock.lock()
|
|
59
|
+
guard let resolveBlock else {
|
|
60
|
+
lock.unlock()
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
self.resolveBlock = nil
|
|
64
|
+
rejectBlock = nil
|
|
65
|
+
lock.unlock()
|
|
66
|
+
|
|
67
|
+
switch value {
|
|
68
|
+
case .void:
|
|
69
|
+
resolveBlock(nil)
|
|
70
|
+
case .value(let value):
|
|
71
|
+
resolveBlock(value.foundationValue())
|
|
72
|
+
}
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@discardableResult
|
|
77
|
+
func reject(_ code: String?, _ message: String?, _ error: Error?) -> Bool {
|
|
78
|
+
lock.lock()
|
|
79
|
+
guard let rejectBlock else {
|
|
80
|
+
lock.unlock()
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
resolveBlock = nil
|
|
84
|
+
self.rejectBlock = nil
|
|
85
|
+
lock.unlock()
|
|
86
|
+
|
|
87
|
+
rejectBlock(code, message, error)
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Owns the Objective-C event target without creating an implementation cycle.
|
|
93
|
+
///
|
|
94
|
+
/// The delegate is installed by Objective-C++, can be released outside Swift, and is
|
|
95
|
+
/// invoked only on main. The lock protects the weak reference, not callback execution.
|
|
96
|
+
final class ReactEventTarget: @unchecked Sendable {
|
|
97
|
+
private let lock = NSLock()
|
|
98
|
+
private weak var delegate: SynchroEventEmitting?
|
|
99
|
+
|
|
100
|
+
var currentDelegate: SynchroEventEmitting? {
|
|
101
|
+
lock.lock()
|
|
102
|
+
defer { lock.unlock() }
|
|
103
|
+
return delegate
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func setDelegate(_ delegate: SynchroEventEmitting?) {
|
|
107
|
+
lock.lock()
|
|
108
|
+
self.delegate = delegate
|
|
109
|
+
lock.unlock()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func emit(_ name: String, body: BridgeValue) {
|
|
113
|
+
DispatchQueue.main.async { [self, name, body] in
|
|
114
|
+
lock.lock()
|
|
115
|
+
let delegate = self.delegate
|
|
116
|
+
lock.unlock()
|
|
117
|
+
guard let payload = body.foundationValue() as? NSDictionary else {
|
|
118
|
+
preconditionFailure("Event payload must be an object")
|
|
119
|
+
}
|
|
120
|
+
delegate?.emitEvent(name, body: payload)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
final class AuthRequestStore: Sendable {
|
|
126
|
+
private struct State: Sendable {
|
|
127
|
+
var acceptsRequests = false
|
|
128
|
+
var continuations: [String: CheckedContinuation<String, Error>] = [:]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private let state = OSAllocatedUnfairLock(initialState: State())
|
|
132
|
+
|
|
133
|
+
func activate() {
|
|
134
|
+
state.withLock { $0.acceptsRequests = true }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func insert(_ continuation: CheckedContinuation<String, Error>, for requestID: String) -> Bool {
|
|
138
|
+
state.withLock { state in
|
|
139
|
+
guard state.acceptsRequests else { return false }
|
|
140
|
+
state.continuations[requestID] = continuation
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
func resolve(_ requestID: String, token: String) {
|
|
146
|
+
let continuation = state.withLock { $0.continuations.removeValue(forKey: requestID) }
|
|
147
|
+
continuation?.resume(returning: token)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func reject(_ requestID: String, error: Error) {
|
|
151
|
+
let continuation = state.withLock { $0.continuations.removeValue(forKey: requestID) }
|
|
152
|
+
continuation?.resume(throwing: error)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func cancelAll() {
|
|
156
|
+
let continuations = state.withLock { state in
|
|
157
|
+
state.acceptsRequests = false
|
|
158
|
+
let continuations = Array(state.continuations.values)
|
|
159
|
+
state.continuations.removeAll()
|
|
160
|
+
return continuations
|
|
161
|
+
}
|
|
162
|
+
let error = NSError(
|
|
163
|
+
domain: "SynchroModule",
|
|
164
|
+
code: 0,
|
|
165
|
+
userInfo: [NSLocalizedDescriptionKey: "client closed"]
|
|
166
|
+
)
|
|
167
|
+
continuations.forEach { $0.resume(throwing: error) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
var count: Int {
|
|
171
|
+
state.withLock { $0.continuations.count }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -244,6 +244,7 @@ final class SynchroModuleTransactionTests: XCTestCase {
|
|
|
244
244
|
private var events: EventRecorder!
|
|
245
245
|
private var cleanup: CleanupState!
|
|
246
246
|
private var databasePath: String!
|
|
247
|
+
private var externalConnections: [ExternalConnection] = []
|
|
247
248
|
|
|
248
249
|
override func setUpWithError() throws {
|
|
249
250
|
try super.setUpWithError()
|
|
@@ -257,32 +258,51 @@ final class SynchroModuleTransactionTests: XCTestCase {
|
|
|
257
258
|
self.events = events
|
|
258
259
|
self.cleanup = cleanup
|
|
259
260
|
self.databasePath = databasePath
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
override func tearDownWithError() throws {
|
|
264
|
+
let teardownDatabasePath = databasePath ?? "unknown database path"
|
|
265
|
+
var moduleClosed = false
|
|
266
|
+
for connection in externalConnections.reversed() {
|
|
267
|
+
do {
|
|
268
|
+
try connection.close()
|
|
269
|
+
} catch {
|
|
270
|
+
cleanup.recordFailure()
|
|
271
|
+
XCTFail("close external connection: \(error)")
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
externalConnections.removeAll()
|
|
275
|
+
|
|
276
|
+
if let module {
|
|
277
|
+
let close = PromiseRecorder("teardown close")
|
|
278
|
+
module.close(close.resolve, reject: close.reject)
|
|
279
|
+
let settled = XCTWaiter().wait(for: [close.firstSettlement], timeout: settlementTimeout) == .completed
|
|
280
|
+
moduleClosed = settled && close.resolutions.count == 1 && close.rejections.isEmpty
|
|
281
|
+
if !moduleClosed {
|
|
282
|
+
XCTFail("teardown close did not resolve once (settled: \(settled), resolutions: \(close.resolutions.count), rejections: \(close.rejections)). Kept \(teardownDatabasePath).")
|
|
283
|
+
}
|
|
284
|
+
module.eventDelegate = nil
|
|
285
|
+
}
|
|
286
|
+
withExtendedLifetime(events) {}
|
|
287
|
+
|
|
288
|
+
if cleanup.hasFailure {
|
|
289
|
+
XCTFail("a test-owned resource did not close. Kept \(teardownDatabasePath).")
|
|
290
|
+
} else if moduleClosed {
|
|
291
|
+
for suffix in ["", "-wal", "-shm"] {
|
|
292
|
+
let path = teardownDatabasePath + suffix
|
|
293
|
+
guard FileManager.default.fileExists(atPath: path) else { continue }
|
|
294
|
+
do {
|
|
295
|
+
try FileManager.default.removeItem(atPath: path)
|
|
296
|
+
} catch {
|
|
297
|
+
XCTFail("remove \(path): \(error)")
|
|
283
298
|
}
|
|
284
299
|
}
|
|
285
300
|
}
|
|
301
|
+
module = nil
|
|
302
|
+
events = nil
|
|
303
|
+
cleanup = nil
|
|
304
|
+
databasePath = nil
|
|
305
|
+
try super.tearDownWithError()
|
|
286
306
|
}
|
|
287
307
|
|
|
288
308
|
func testAcquisitionFailureRejectsBeginOnce() throws {
|
|
@@ -392,21 +412,15 @@ final class SynchroModuleTransactionTests: XCTestCase {
|
|
|
392
412
|
let held = try holdReadTransactions(count: 5)
|
|
393
413
|
let ordinary = PromiseRecorder("ordinary query with five readers")
|
|
394
414
|
let commits = held.ids.indices.map { PromiseRecorder("commit reader \($0)") }
|
|
395
|
-
let invocationReturned = XCTestExpectation(description: "ordinary query invocation returns")
|
|
396
|
-
let methodQueue = DispatchQueue(label: "test.react-native.query-dispatch")
|
|
397
415
|
let module = try XCTUnwrap(self.module)
|
|
398
416
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
for (index, txID) in held.ids.enumerated() {
|
|
405
|
-
module.commitTransaction(txID, resolve: commits[index].resolve, reject: commits[index].reject)
|
|
406
|
-
}
|
|
417
|
+
let invocationStarted = Date()
|
|
418
|
+
module.query("SELECT 1", params: [], resolve: ordinary.resolve, reject: ordinary.reject)
|
|
419
|
+
XCTAssertLessThan(Date().timeIntervalSince(invocationStarted), promptDispatchTimeout)
|
|
420
|
+
for (index, txID) in held.ids.enumerated() {
|
|
421
|
+
module.commitTransaction(txID, resolve: commits[index].resolve, reject: commits[index].reject)
|
|
407
422
|
}
|
|
408
423
|
|
|
409
|
-
XCTAssertEqual(XCTWaiter().wait(for: [invocationReturned], timeout: promptDispatchTimeout), .completed)
|
|
410
424
|
waitForSettlements([ordinary] + commits, timeout: promptDispatchTimeout)
|
|
411
425
|
XCTAssertEqual((try resolvedValue(ordinary) as? [[String: Any]])?.count, 1)
|
|
412
426
|
let close = try closeModule()
|
|
@@ -420,21 +434,15 @@ final class SynchroModuleTransactionTests: XCTestCase {
|
|
|
420
434
|
let held = try holdReadTransactions(count: 5)
|
|
421
435
|
let ordinary = PromiseRecorder("ordinary query one with five readers")
|
|
422
436
|
let rollbacks = held.ids.indices.map { PromiseRecorder("rollback reader \($0)") }
|
|
423
|
-
let invocationReturned = XCTestExpectation(description: "ordinary query one invocation returns")
|
|
424
|
-
let methodQueue = DispatchQueue(label: "test.react-native.query-one-dispatch")
|
|
425
437
|
let module = try XCTUnwrap(self.module)
|
|
426
438
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
for (index, txID) in held.ids.enumerated() {
|
|
433
|
-
module.rollbackTransaction(txID, resolve: rollbacks[index].resolve, reject: rollbacks[index].reject)
|
|
434
|
-
}
|
|
439
|
+
let invocationStarted = Date()
|
|
440
|
+
module.queryOne("SELECT 1", params: [], resolve: ordinary.resolve, reject: ordinary.reject)
|
|
441
|
+
XCTAssertLessThan(Date().timeIntervalSince(invocationStarted), promptDispatchTimeout)
|
|
442
|
+
for (index, txID) in held.ids.enumerated() {
|
|
443
|
+
module.rollbackTransaction(txID, resolve: rollbacks[index].resolve, reject: rollbacks[index].reject)
|
|
435
444
|
}
|
|
436
445
|
|
|
437
|
-
XCTAssertEqual(XCTWaiter().wait(for: [invocationReturned], timeout: promptDispatchTimeout), .completed)
|
|
438
446
|
waitForSettlements([ordinary] + rollbacks, timeout: promptDispatchTimeout)
|
|
439
447
|
XCTAssertEqual((try resolvedValue(ordinary) as? [String: Any])?.count, 1)
|
|
440
448
|
let close = try closeModule()
|
|
@@ -446,7 +454,6 @@ final class SynchroModuleTransactionTests: XCTestCase {
|
|
|
446
454
|
func testOrdinaryWriterSurfacesDoNotBlockWriteTransactionCommands() throws {
|
|
447
455
|
try initializeModule()
|
|
448
456
|
let module = try XCTUnwrap(self.module)
|
|
449
|
-
let methodQueue = DispatchQueue(label: "test.react-native.writer-dispatch")
|
|
450
457
|
let ordinaryCalls: [(String, (PromiseRecorder) -> Void)] = [
|
|
451
458
|
("execute", { recorder in
|
|
452
459
|
module.execute("SELECT 1", params: [], resolve: recorder.resolve, reject: recorder.reject)
|
|
@@ -482,21 +489,11 @@ final class SynchroModuleTransactionTests: XCTestCase {
|
|
|
482
489
|
}
|
|
483
490
|
let ordinary = PromiseRecorder(name)
|
|
484
491
|
let commit = PromiseRecorder("commit after \(name)")
|
|
485
|
-
let
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
invocationReturned.fulfill()
|
|
490
|
-
}
|
|
491
|
-
methodQueue.async {
|
|
492
|
-
module.commitTransaction(txID, resolve: commit.resolve, reject: commit.reject)
|
|
493
|
-
}
|
|
492
|
+
let invocationStarted = Date()
|
|
493
|
+
call(ordinary)
|
|
494
|
+
XCTAssertLessThan(Date().timeIntervalSince(invocationStarted), promptDispatchTimeout, name)
|
|
495
|
+
module.commitTransaction(txID, resolve: commit.resolve, reject: commit.reject)
|
|
494
496
|
|
|
495
|
-
XCTAssertEqual(
|
|
496
|
-
XCTWaiter().wait(for: [invocationReturned], timeout: promptDispatchTimeout),
|
|
497
|
-
.completed,
|
|
498
|
-
name
|
|
499
|
-
)
|
|
500
497
|
waitForSettlements([ordinary, commit])
|
|
501
498
|
recorders += [begin, probe, ordinary, commit]
|
|
502
499
|
}
|
|
@@ -673,6 +670,40 @@ final class SynchroModuleTransactionTests: XCTestCase {
|
|
|
673
670
|
XCTAssertEqual(events.names, [])
|
|
674
671
|
}
|
|
675
672
|
|
|
673
|
+
func testUnsupportedCyclicSQLParametersRejectPromptlyAndSettleOnce() throws {
|
|
674
|
+
try initializeModule()
|
|
675
|
+
let cyclicArray = NSMutableArray()
|
|
676
|
+
cyclicArray.add(cyclicArray)
|
|
677
|
+
let cyclicDictionary = NSMutableDictionary()
|
|
678
|
+
cyclicDictionary["type"] = "bytes"
|
|
679
|
+
cyclicDictionary["base64"] = cyclicDictionary
|
|
680
|
+
let inputs: [(String, Any)] = [
|
|
681
|
+
("cyclic array", cyclicArray),
|
|
682
|
+
("cyclic dictionary", cyclicDictionary),
|
|
683
|
+
]
|
|
684
|
+
var rejections: [PromiseRecorder] = []
|
|
685
|
+
|
|
686
|
+
for (name, value) in inputs {
|
|
687
|
+
let recorder = PromiseRecorder(name)
|
|
688
|
+
let invocationStarted = Date()
|
|
689
|
+
module.query("SELECT ?", params: [value], resolve: recorder.resolve, reject: recorder.reject)
|
|
690
|
+
XCTAssertLessThan(Date().timeIntervalSince(invocationStarted), promptDispatchTimeout, name)
|
|
691
|
+
waitForSettlements([recorder], timeout: promptDispatchTimeout)
|
|
692
|
+
rejections.append(recorder)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
cyclicArray.removeAllObjects()
|
|
696
|
+
cyclicDictionary.removeAllObjects()
|
|
697
|
+
let close = try closeModule()
|
|
698
|
+
for recorder in rejections {
|
|
699
|
+
XCTAssertEqual(recorder.resolutions.count, 0, recorder.name)
|
|
700
|
+
XCTAssertEqual(recorder.rejections, ["UNKNOWN"], recorder.name)
|
|
701
|
+
XCTAssertEqual(recorder.settlementCount, 1, recorder.name)
|
|
702
|
+
}
|
|
703
|
+
requireSettledOnce([close])
|
|
704
|
+
XCTAssertEqual(events.names, [])
|
|
705
|
+
}
|
|
706
|
+
|
|
676
707
|
// MARK: - Inspection snapshot
|
|
677
708
|
|
|
678
709
|
func testClientStateSnapshotReadsRequestedRowsInsideReadOnlySnapshot() throws {
|
|
@@ -874,15 +905,7 @@ final class SynchroModuleTransactionTests: XCTestCase {
|
|
|
874
905
|
private func openExternalConnection() throws -> ExternalConnection {
|
|
875
906
|
let getPath = settle("get path") { module.getPath($0.resolve, reject: $0.reject) }
|
|
876
907
|
let connection = try ExternalConnection(path: try XCTUnwrap(try resolvedValue(getPath) as? String))
|
|
877
|
-
|
|
878
|
-
addTeardownBlock {
|
|
879
|
-
do {
|
|
880
|
-
try connection.close()
|
|
881
|
-
} catch {
|
|
882
|
-
cleanup.recordFailure()
|
|
883
|
-
XCTFail("close external connection: \(error)")
|
|
884
|
-
}
|
|
885
|
-
}
|
|
908
|
+
externalConnections.append(connection)
|
|
886
909
|
return connection
|
|
887
910
|
}
|
|
888
911
|
|
|
@@ -909,9 +932,7 @@ final class SynchroModuleTransactionTests: XCTestCase {
|
|
|
909
932
|
|
|
910
933
|
private func registrySnapshot() throws -> [String] {
|
|
911
934
|
let module = try XCTUnwrap(self.module)
|
|
912
|
-
module.
|
|
913
|
-
defer { module.sessionsLock.unlock() }
|
|
914
|
-
return Array(module.sessions.keys)
|
|
935
|
+
return module.activeTransactionIDs
|
|
915
936
|
}
|
|
916
937
|
|
|
917
938
|
/// Close removes every registered session under the registry lock before it waits for them.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
@testable import SynchroReactNative
|
|
2
|
+
import os
|
|
2
3
|
import XCTest
|
|
3
4
|
|
|
4
5
|
private struct SessionError: Error, Equatable {
|
|
@@ -6,21 +7,95 @@ private struct SessionError: Error, Equatable {
|
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
/// Records the begin completions that the actual session delivers.
|
|
9
|
-
private final class BeginRecorder {
|
|
10
|
-
private
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
results.append(result)
|
|
10
|
+
private final class BeginRecorder: Sendable {
|
|
11
|
+
private struct State: Sendable {
|
|
12
|
+
var successes: [String] = []
|
|
13
|
+
var failures: [SessionError?] = []
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
private let state = OSAllocatedUnfairLock(initialState: State())
|
|
17
|
+
|
|
16
18
|
var successes: [String] {
|
|
17
|
-
|
|
19
|
+
state.withLock { $0.successes }
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
var failures: [SessionError?] {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
state.withLock { $0.failures }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func settlement() -> ReactPromiseSettlement {
|
|
27
|
+
ReactPromiseSettlement(
|
|
28
|
+
resolve: { [self] value in
|
|
29
|
+
let stringValue = value as? String
|
|
30
|
+
state.withLock { state in
|
|
31
|
+
if let stringValue {
|
|
32
|
+
state.successes.append(stringValue)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
reject: { [self] _, _, error in
|
|
37
|
+
state.withLock { $0.failures.append(error as? SessionError) }
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private final class SettlementCounter: Sendable {
|
|
44
|
+
private let countState = OSAllocatedUnfairLock(initialState: 0)
|
|
45
|
+
|
|
46
|
+
var count: Int {
|
|
47
|
+
countState.withLock { $0 }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func increment() {
|
|
51
|
+
countState.withLock { $0 += 1 }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private final class EventRecorder: NSObject, SynchroEventEmitting {
|
|
56
|
+
private struct State {
|
|
57
|
+
var name: String?
|
|
58
|
+
var body: NSDictionary?
|
|
59
|
+
var deliveredOnMain = false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private let state = NSLock()
|
|
63
|
+
private var value = State()
|
|
64
|
+
let expectation: XCTestExpectation
|
|
65
|
+
|
|
66
|
+
init(expectation: XCTestExpectation) {
|
|
67
|
+
self.expectation = expectation
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func emitEvent(_ name: String, body: NSDictionary) {
|
|
71
|
+
state.lock()
|
|
72
|
+
value.name = name
|
|
73
|
+
value.body = body
|
|
74
|
+
value.deliveredOnMain = Thread.isMainThread
|
|
75
|
+
state.unlock()
|
|
76
|
+
expectation.fulfill()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func snapshot() -> (String?, NSDictionary?, Bool) {
|
|
80
|
+
state.lock()
|
|
81
|
+
defer { state.unlock() }
|
|
82
|
+
return (value.name, value.body, value.deliveredOnMain)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private struct AuthStoreTimeout: Error {}
|
|
87
|
+
|
|
88
|
+
private func pendingAuthRequest(
|
|
89
|
+
_ store: AuthRequestStore,
|
|
90
|
+
id: String,
|
|
91
|
+
insertion: CheckedContinuation<Bool, Never>
|
|
92
|
+
) async throws -> String {
|
|
93
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
94
|
+
let inserted = store.insert(continuation, for: id)
|
|
95
|
+
insertion.resume(returning: inserted)
|
|
96
|
+
guard inserted else {
|
|
97
|
+
continuation.resume(throwing: AuthStoreTimeout())
|
|
98
|
+
return
|
|
24
99
|
}
|
|
25
100
|
}
|
|
26
101
|
}
|
|
@@ -29,7 +104,7 @@ private final class BeginRecorder {
|
|
|
29
104
|
final class TransactionSessionTests: XCTestCase {
|
|
30
105
|
func testFailureBeforeCallbackRejectsStoredBeginOnce() {
|
|
31
106
|
let begin = BeginRecorder()
|
|
32
|
-
let session = TransactionSession(isWrite: true,
|
|
107
|
+
let session = TransactionSession(isWrite: true, beginSettlement: begin.settlement())
|
|
33
108
|
|
|
34
109
|
session.rejectBegin(SessionError(name: "acquisition"))
|
|
35
110
|
session.rejectBegin(SessionError(name: "later failure"))
|
|
@@ -40,7 +115,7 @@ final class TransactionSessionTests: XCTestCase {
|
|
|
40
115
|
|
|
41
116
|
func testAbortBeforeBeginAcceptanceRejectsBeginAndUnwindsCallback() {
|
|
42
117
|
let begin = BeginRecorder()
|
|
43
|
-
let session = TransactionSession(isWrite: true,
|
|
118
|
+
let session = TransactionSession(isWrite: true, beginSettlement: begin.settlement())
|
|
44
119
|
|
|
45
120
|
session.abort(SessionError(name: "close"))
|
|
46
121
|
XCTAssertThrowsError(try session.acceptBegin("abort-before-begin")) { error in
|
|
@@ -54,7 +129,7 @@ final class TransactionSessionTests: XCTestCase {
|
|
|
54
129
|
|
|
55
130
|
func testAcceptedBeginIsNotSettledAgainByLaterFailure() throws {
|
|
56
131
|
let begin = BeginRecorder()
|
|
57
|
-
let session = TransactionSession(isWrite: true,
|
|
132
|
+
let session = TransactionSession(isWrite: true, beginSettlement: begin.settlement())
|
|
58
133
|
|
|
59
134
|
try session.acceptBegin("accepted")
|
|
60
135
|
session.abort(SessionError(name: "close"))
|
|
@@ -63,4 +138,84 @@ final class TransactionSessionTests: XCTestCase {
|
|
|
63
138
|
XCTAssertEqual(begin.successes, ["accepted"])
|
|
64
139
|
XCTAssertEqual(begin.failures, [])
|
|
65
140
|
}
|
|
141
|
+
|
|
142
|
+
func testPromiseSettlementRaceInvokesOneBlock() {
|
|
143
|
+
let counter = SettlementCounter()
|
|
144
|
+
let settlement = ReactPromiseSettlement(
|
|
145
|
+
resolve: { _ in counter.increment() },
|
|
146
|
+
reject: { _, _, _ in counter.increment() }
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
DispatchQueue.concurrentPerform(iterations: 100) { index in
|
|
150
|
+
if index.isMultiple(of: 2) {
|
|
151
|
+
settlement.resolve(.void)
|
|
152
|
+
} else {
|
|
153
|
+
settlement.reject("TEST", "test", nil)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
XCTAssertEqual(counter.count, 1)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func testPromiseSettlementUnlocksBeforeReentrantInvocation() {
|
|
161
|
+
let counter = SettlementCounter()
|
|
162
|
+
let holder = OSAllocatedUnfairLock<ReactPromiseSettlement?>(initialState: nil)
|
|
163
|
+
let settlement = ReactPromiseSettlement(
|
|
164
|
+
resolve: { _ in
|
|
165
|
+
counter.increment()
|
|
166
|
+
holder.withLock { $0 }?.reject("REENTRANT", "reentrant", nil)
|
|
167
|
+
},
|
|
168
|
+
reject: { _, _, _ in counter.increment() }
|
|
169
|
+
)
|
|
170
|
+
holder.withLock { $0 = settlement }
|
|
171
|
+
|
|
172
|
+
settlement.resolve(.void)
|
|
173
|
+
|
|
174
|
+
XCTAssertEqual(counter.count, 1)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func testEventTargetIsWeakAndDeliversOnMain() {
|
|
178
|
+
let event = expectation(description: "event")
|
|
179
|
+
let target = ReactEventTarget()
|
|
180
|
+
var recorder: EventRecorder? = EventRecorder(expectation: event)
|
|
181
|
+
target.setDelegate(recorder)
|
|
182
|
+
|
|
183
|
+
target.emit("onTest", body: .object(["value": .string("ok")]))
|
|
184
|
+
wait(for: [event], timeout: 1)
|
|
185
|
+
|
|
186
|
+
let snapshot = recorder?.snapshot()
|
|
187
|
+
XCTAssertEqual(snapshot?.0, "onTest")
|
|
188
|
+
XCTAssertEqual(snapshot?.1?["value"] as? String, "ok")
|
|
189
|
+
XCTAssertEqual(snapshot?.2, true)
|
|
190
|
+
|
|
191
|
+
weak let weakRecorder = recorder
|
|
192
|
+
recorder = nil
|
|
193
|
+
XCTAssertNil(weakRecorder)
|
|
194
|
+
XCTAssertNil(target.currentDelegate)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func testAuthStoreCloseRejectsPendingAndIgnoresLateCompletion() async throws {
|
|
198
|
+
let store = AuthRequestStore()
|
|
199
|
+
store.activate()
|
|
200
|
+
var pendingTask: Task<String, Error>?
|
|
201
|
+
let inserted = await withCheckedContinuation { insertion in
|
|
202
|
+
pendingTask = Task {
|
|
203
|
+
try await pendingAuthRequest(store, id: "request", insertion: insertion)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
XCTAssertTrue(inserted)
|
|
207
|
+
XCTAssertEqual(store.count, 1)
|
|
208
|
+
let task = try XCTUnwrap(pendingTask)
|
|
209
|
+
|
|
210
|
+
store.cancelAll()
|
|
211
|
+
store.resolve("request", token: "late")
|
|
212
|
+
|
|
213
|
+
do {
|
|
214
|
+
_ = try await task.value
|
|
215
|
+
XCTFail("Expected close to reject the request")
|
|
216
|
+
} catch {
|
|
217
|
+
XCTAssertEqual((error as NSError).domain, "SynchroModule")
|
|
218
|
+
}
|
|
219
|
+
XCTAssertEqual(store.count, 0)
|
|
220
|
+
}
|
|
66
221
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trainstar/synchro-react-native",
|
|
3
|
-
"version": "0.4.0-rc.
|
|
3
|
+
"version": "0.4.0-rc.4",
|
|
4
4
|
"description": "React Native bridge for Synchro sync SDK",
|
|
5
5
|
"source": "./src/index.ts",
|
|
6
6
|
"main": "./lib/module/index.js",
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
"./tooling-patches/cli-config-android-20.2.0.patch": "./tooling-patches/cli-config-android-20.2.0.patch",
|
|
21
21
|
"./tooling-patches/cli-clean-20.2.0.patch": "./tooling-patches/cli-clean-20.2.0.patch",
|
|
22
22
|
"./tooling-patches/react-native-builder-bob-0.40.18.patch": "./tooling-patches/react-native-builder-bob-0.40.18.patch",
|
|
23
|
+
"./tooling-patches/react-native-gradle-plugin-0.82.1-kotlin-2.4.21.patch": "./tooling-patches/react-native-gradle-plugin-0.82.1-kotlin-2.4.21.patch",
|
|
24
|
+
"./tooling-patches/react-native-gradle-plugin-0.88.0-kotlin-2.4.21.patch": "./tooling-patches/react-native-gradle-plugin-0.88.0-kotlin-2.4.21.patch",
|
|
23
25
|
"./tooling-patches/stream-json-1.9.1.patch": "./tooling-patches/stream-json-1.9.1.patch",
|
|
24
26
|
"./package.json": "./package.json"
|
|
25
27
|
},
|
|
@@ -103,6 +105,7 @@
|
|
|
103
105
|
"resolutions": {
|
|
104
106
|
"@react-native-community/cli-config-android@npm:20.2.0": "patch:@react-native-community/cli-config-android@npm%3A20.2.0#./tooling-patches/cli-config-android-20.2.0.patch",
|
|
105
107
|
"@react-native-community/cli-clean@npm:20.2.0": "patch:@react-native-community/cli-clean@npm%3A20.2.0#./tooling-patches/cli-clean-20.2.0.patch",
|
|
108
|
+
"@react-native/gradle-plugin@npm:0.88.0": "patch:@react-native/gradle-plugin@npm%3A0.88.0#./tooling-patches/react-native-gradle-plugin-0.88.0-kotlin-2.4.21.patch",
|
|
106
109
|
"react-native-builder-bob@npm:^0.40.18": "patch:react-native-builder-bob@npm%3A0.40.18#./tooling-patches/react-native-builder-bob-0.40.18.patch",
|
|
107
110
|
"stream-json@npm:^1.7.4": "patch:stream-json@npm%3A1.9.1#./tooling-patches/stream-json-1.9.1.patch",
|
|
108
111
|
"stream-json@npm:^1.7.5": "patch:stream-json@npm%3A1.9.1#./tooling-patches/stream-json-1.9.1.patch"
|