@trainstar/synchro-react-native 0.4.0-rc.2 → 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.
@@ -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
+ }
@@ -39,10 +39,3 @@
39
39
  }
40
40
 
41
41
  @end
42
-
43
- int main(int argc, char *argv[])
44
- {
45
- @autoreleasepool {
46
- return UIApplicationMain(argc, argv, nil, NSStringFromClass([SynchroTestHostAppDelegate class]));
47
- }
48
- }
@@ -0,0 +1,12 @@
1
+ import Darwin
2
+ import UIKit
3
+
4
+ let status = autoreleasepool {
5
+ UIApplicationMain(
6
+ CommandLine.argc,
7
+ CommandLine.unsafeArgv,
8
+ nil,
9
+ "SynchroTestHostAppDelegate"
10
+ )
11
+ }
12
+ exit(status)
@@ -4,6 +4,7 @@ import Synchro
4
4
  import XCTest
5
5
 
6
6
  private let settlementTimeout: TimeInterval = 60
7
+ private let promptDispatchTimeout: TimeInterval = 4
7
8
  private let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
8
9
  private let columnsJSON = #"[{"name":"id","type":"TEXT","primaryKey":true},{"name":"name","type":"TEXT"}]"#
9
10
  private let insertSQL = "INSERT INTO bridge_items (id, name) VALUES (?, ?)"
@@ -243,6 +244,7 @@ final class SynchroModuleTransactionTests: XCTestCase {
243
244
  private var events: EventRecorder!
244
245
  private var cleanup: CleanupState!
245
246
  private var databasePath: String!
247
+ private var externalConnections: [ExternalConnection] = []
246
248
 
247
249
  override func setUpWithError() throws {
248
250
  try super.setUpWithError()
@@ -256,32 +258,51 @@ final class SynchroModuleTransactionTests: XCTestCase {
256
258
  self.events = events
257
259
  self.cleanup = cleanup
258
260
  self.databasePath = databasePath
259
- // Teardown blocks run in reverse order. This block is first, so every gate a test
260
- // registers later is released before close joins the registered transactions.
261
- addTeardownBlock {
262
- withExtendedLifetime(events) {
263
- let close = PromiseRecorder("teardown close")
264
- module.close(close.resolve, reject: close.reject)
265
- let settled = XCTWaiter().wait(for: [close.firstSettlement], timeout: settlementTimeout) == .completed
266
- guard settled, close.resolutions.count == 1, close.rejections.isEmpty else {
267
- XCTFail("teardown close did not resolve once (settled: \(settled), resolutions: \(close.resolutions.count), rejections: \(close.rejections)). Kept \(databasePath).")
268
- return
269
- }
270
- guard !cleanup.hasFailure else {
271
- XCTFail("a test-owned resource did not close. Kept \(databasePath).")
272
- return
273
- }
274
- for suffix in ["", "-wal", "-shm"] {
275
- let path = databasePath + suffix
276
- guard FileManager.default.fileExists(atPath: path) else { continue }
277
- do {
278
- try FileManager.default.removeItem(atPath: path)
279
- } catch {
280
- XCTFail("remove \(path): \(error)")
281
- }
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)")
282
298
  }
283
299
  }
284
300
  }
301
+ module = nil
302
+ events = nil
303
+ cleanup = nil
304
+ databasePath = nil
305
+ try super.tearDownWithError()
285
306
  }
286
307
 
287
308
  func testAcquisitionFailureRejectsBeginOnce() throws {
@@ -365,6 +386,213 @@ final class SynchroModuleTransactionTests: XCTestCase {
365
386
  XCTAssertEqual(events.names, [])
366
387
  }
367
388
 
389
+ func testFourReadersLeaveCapacityForOrdinaryQuery() throws {
390
+ try initializeModule()
391
+ let held = try holdReadTransactions(count: 4)
392
+ let ordinary = settle("ordinary query with four readers") {
393
+ module.query("SELECT 1", params: [], resolve: $0.resolve, reject: $0.reject)
394
+ }
395
+ XCTAssertEqual((try resolvedValue(ordinary) as? [[String: Any]])?.count, 1)
396
+
397
+ let commits = try held.ids.enumerated().map { index, txID in
398
+ let commit = settle("commit reader \(index)") {
399
+ module.commitTransaction(txID, resolve: $0.resolve, reject: $0.reject)
400
+ }
401
+ _ = try resolvedValue(commit)
402
+ return commit
403
+ }
404
+ let close = try closeModule()
405
+
406
+ requireSettledOnce(held.recorders + [ordinary] + commits + [close])
407
+ XCTAssertEqual(events.names, [])
408
+ }
409
+
410
+ func testFiveReadersDoNotBlockOrdinaryQueryAheadOfCommits() throws {
411
+ try initializeModule()
412
+ let held = try holdReadTransactions(count: 5)
413
+ let ordinary = PromiseRecorder("ordinary query with five readers")
414
+ let commits = held.ids.indices.map { PromiseRecorder("commit reader \($0)") }
415
+ let module = try XCTUnwrap(self.module)
416
+
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)
422
+ }
423
+
424
+ waitForSettlements([ordinary] + commits, timeout: promptDispatchTimeout)
425
+ XCTAssertEqual((try resolvedValue(ordinary) as? [[String: Any]])?.count, 1)
426
+ let close = try closeModule()
427
+
428
+ requireSettledOnce(held.recorders + [ordinary] + commits + [close])
429
+ XCTAssertEqual(events.names, [])
430
+ }
431
+
432
+ func testFiveReadersDoNotBlockOrdinaryQueryOneAheadOfRollbacks() throws {
433
+ try initializeModule()
434
+ let held = try holdReadTransactions(count: 5)
435
+ let ordinary = PromiseRecorder("ordinary query one with five readers")
436
+ let rollbacks = held.ids.indices.map { PromiseRecorder("rollback reader \($0)") }
437
+ let module = try XCTUnwrap(self.module)
438
+
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)
444
+ }
445
+
446
+ waitForSettlements([ordinary] + rollbacks, timeout: promptDispatchTimeout)
447
+ XCTAssertEqual((try resolvedValue(ordinary) as? [String: Any])?.count, 1)
448
+ let close = try closeModule()
449
+
450
+ requireSettledOnce(held.recorders + [ordinary] + rollbacks + [close])
451
+ XCTAssertEqual(events.names, [])
452
+ }
453
+
454
+ func testOrdinaryWriterSurfacesDoNotBlockWriteTransactionCommands() throws {
455
+ try initializeModule()
456
+ let module = try XCTUnwrap(self.module)
457
+ let ordinaryCalls: [(String, (PromiseRecorder) -> Void)] = [
458
+ ("execute", { recorder in
459
+ module.execute("SELECT 1", params: [], resolve: recorder.resolve, reject: recorder.reject)
460
+ }),
461
+ ("authored write", { recorder in
462
+ module.executeAuthoredWrite(
463
+ "bridge_items",
464
+ operation: "update",
465
+ columnNames: ["name"],
466
+ sql: "SELECT 1",
467
+ values: [],
468
+ resolve: recorder.resolve,
469
+ reject: recorder.reject
470
+ )
471
+ }),
472
+ ("batch", { recorder in
473
+ module.executeBatch(
474
+ [["sql": "SELECT 1", "params": []]],
475
+ resolve: recorder.resolve,
476
+ reject: recorder.reject
477
+ )
478
+ }),
479
+ ]
480
+ var recorders: [PromiseRecorder] = []
481
+
482
+ for (name, call) in ordinaryCalls {
483
+ let begin = settle("begin before \(name)") {
484
+ module.beginWriteTransaction($0.resolve, reject: $0.reject)
485
+ }
486
+ let txID = try XCTUnwrap(try resolvedValue(begin) as? String)
487
+ let probe = settle("transaction query before \(name)") {
488
+ module.txQuery(txID, sql: "SELECT 1", params: [], resolve: $0.resolve, reject: $0.reject)
489
+ }
490
+ let ordinary = PromiseRecorder(name)
491
+ let commit = PromiseRecorder("commit after \(name)")
492
+ let invocationStarted = Date()
493
+ call(ordinary)
494
+ XCTAssertLessThan(Date().timeIntervalSince(invocationStarted), promptDispatchTimeout, name)
495
+ module.commitTransaction(txID, resolve: commit.resolve, reject: commit.reject)
496
+
497
+ waitForSettlements([ordinary, commit])
498
+ recorders += [begin, probe, ordinary, commit]
499
+ }
500
+
501
+ let close = try closeModule()
502
+ requireSettledOnce(recorders + [close])
503
+ XCTAssertEqual(events.names, [])
504
+ }
505
+
506
+ func testCloseDrainsAdmittedQueryAfterAbortingSaturatedReaders() throws {
507
+ try initializeModule()
508
+ let held = try holdReadTransactions(count: 5)
509
+ let pendingBegin = PromiseRecorder("pending sixth reader")
510
+ module.beginReadTransaction(pendingBegin.resolve, reject: pendingBegin.reject)
511
+ let query = PromiseRecorder("admitted query")
512
+ module.query("SELECT 1", params: [], resolve: query.resolve, reject: query.reject)
513
+
514
+ let close = PromiseRecorder("close with admitted query")
515
+ let recordClose = close.resolve
516
+ module.close({ value in
517
+ XCTAssertEqual(pendingBegin.rejections, ["NOT_CONNECTED"])
518
+ XCTAssertEqual(pendingBegin.settlementCount, 1)
519
+ XCTAssertEqual(query.settlementCount, 1)
520
+ recordClose(value)
521
+ }, reject: close.reject)
522
+ waitForSettlements([query, pendingBegin, close], timeout: promptDispatchTimeout)
523
+
524
+ XCTAssertEqual(query.settlementCount, 1)
525
+ XCTAssertEqual((try resolvedValue(query) as? [[String: Any]])?.count, 1)
526
+ requireSettledOnce(held.recorders + [query, close])
527
+ XCTAssertEqual(pendingBegin.resolutions.count, 0)
528
+ XCTAssertEqual(pendingBegin.rejections, ["NOT_CONNECTED"])
529
+ XCTAssertEqual(pendingBegin.settlementCount, 1)
530
+
531
+ let afterClose = settle("query after close") {
532
+ module.query("SELECT 1", params: [], resolve: $0.resolve, reject: $0.reject)
533
+ }
534
+ XCTAssertEqual(afterClose.resolutions.count, 0)
535
+ XCTAssertEqual(afterClose.rejections, ["NOT_CONNECTED"])
536
+ XCTAssertEqual(afterClose.settlementCount, 1)
537
+ XCTAssertEqual(events.names, [])
538
+ }
539
+
540
+ func testCloseDrainsQueuedOrdinaryCallsBeforeClosingClient() throws {
541
+ try initializeModule()
542
+ let external = try openExternalConnection()
543
+ let client = try XCTUnwrap(module.client)
544
+ let hold = ChangeHold()
545
+ let subscription = client.onChange(tables: ["bridge_items"]) { hold.enter() }
546
+ addTeardownBlock {
547
+ hold.release()
548
+ subscription.cancel()
549
+ }
550
+ let activeReader = try holdReadTransactions(count: 1)
551
+ let id = UUID().uuidString
552
+
553
+ let write = PromiseRecorder("held ordinary write")
554
+ module.execute(insertSQL, params: [id, "ordinary-held"], resolve: write.resolve, reject: write.reject)
555
+ XCTAssertEqual(XCTWaiter().wait(for: [hold.entered], timeout: settlementTimeout), .completed)
556
+ XCTAssertEqual(try external.names(id: id), ["ordinary-held"])
557
+ XCTAssertEqual(write.settlementCount, 0)
558
+
559
+ let read = PromiseRecorder("queued ordinary read")
560
+ module.queryOne(selectNameSQL, params: [id], resolve: read.resolve, reject: read.reject)
561
+ let close = PromiseRecorder("close draining ordinary calls")
562
+ let recordClose = close.resolve
563
+ module.close({ value in
564
+ XCTAssertEqual(write.settlementCount, 1)
565
+ XCTAssertEqual(read.settlementCount, 1)
566
+ recordClose(value)
567
+ }, reject: close.reject)
568
+
569
+ XCTAssertTrue(try waitUntilCloseDetachesSessions())
570
+ XCTAssertEqual(write.settlementCount, 0)
571
+ XCTAssertEqual(read.settlementCount, 0)
572
+ XCTAssertEqual(close.settlementCount, 0)
573
+
574
+ let rejected = PromiseRecorder("ordinary call after close admission stops")
575
+ module.query("SELECT 1", params: [], resolve: rejected.resolve, reject: rejected.reject)
576
+ waitForSettlements([rejected], timeout: promptDispatchTimeout)
577
+ XCTAssertEqual(rejected.resolutions.count, 0)
578
+ XCTAssertEqual(rejected.rejections, ["NOT_CONNECTED"])
579
+ XCTAssertEqual(rejected.settlementCount, 1)
580
+ XCTAssertEqual(write.settlementCount, 0)
581
+ XCTAssertEqual(read.settlementCount, 0)
582
+ XCTAssertEqual(close.settlementCount, 0)
583
+
584
+ hold.release()
585
+ waitForSettlements([write, read, close], timeout: promptDispatchTimeout)
586
+ XCTAssertTrue(hold.heldUntilRelease)
587
+ XCTAssertEqual(hold.entryCount, 1)
588
+ subscription.cancel()
589
+ let row = try XCTUnwrap(try resolvedValue(read) as? [String: Any])
590
+ XCTAssertEqual(row["name"] as? String, "ordinary-held")
591
+ requireSettledOnce(activeReader.recorders + [write, read, close])
592
+ XCTAssertEqual(try external.names(id: id), ["ordinary-held"])
593
+ XCTAssertEqual(events.names, [])
594
+ }
595
+
368
596
  /// Observes an accepted commit after its durable write and before its SDK call returns.
369
597
  /// This is not a window inside SQLite COMMIT.
370
598
  func testCloseWaitsForAcceptedCommitHeldAfterDurableWrite() throws {
@@ -442,6 +670,40 @@ final class SynchroModuleTransactionTests: XCTestCase {
442
670
  XCTAssertEqual(events.names, [])
443
671
  }
444
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
+
445
707
  // MARK: - Inspection snapshot
446
708
 
447
709
  func testClientStateSnapshotReadsRequestedRowsInsideReadOnlySnapshot() throws {
@@ -602,6 +864,26 @@ final class SynchroModuleTransactionTests: XCTestCase {
602
864
  requireSettledOnce([inspect, snapshot, close])
603
865
  }
604
866
 
867
+ // MARK: - Transaction capacity
868
+
869
+ private func holdReadTransactions(count: Int) throws -> (ids: [String], recorders: [PromiseRecorder]) {
870
+ var ids: [String] = []
871
+ var recorders: [PromiseRecorder] = []
872
+ for index in 0..<count {
873
+ let begin = settle("begin reader \(index)") {
874
+ module.beginReadTransaction($0.resolve, reject: $0.reject)
875
+ }
876
+ let txID = try XCTUnwrap(try resolvedValue(begin) as? String)
877
+ let query = settle("query reader \(index)") {
878
+ module.txQuery(txID, sql: "SELECT 1", params: [], resolve: $0.resolve, reject: $0.reject)
879
+ }
880
+ XCTAssertEqual((try resolvedValue(query) as? [[String: Any]])?.count, 1)
881
+ ids.append(txID)
882
+ recorders += [begin, query]
883
+ }
884
+ return (ids, recorders)
885
+ }
886
+
605
887
  // MARK: - Bridge calls
606
888
 
607
889
  private func initializeModule() throws {
@@ -623,15 +905,7 @@ final class SynchroModuleTransactionTests: XCTestCase {
623
905
  private func openExternalConnection() throws -> ExternalConnection {
624
906
  let getPath = settle("get path") { module.getPath($0.resolve, reject: $0.reject) }
625
907
  let connection = try ExternalConnection(path: try XCTUnwrap(try resolvedValue(getPath) as? String))
626
- let cleanup = self.cleanup!
627
- addTeardownBlock {
628
- do {
629
- try connection.close()
630
- } catch {
631
- cleanup.recordFailure()
632
- XCTFail("close external connection: \(error)")
633
- }
634
- }
908
+ externalConnections.append(connection)
635
909
  return connection
636
910
  }
637
911
 
@@ -658,9 +932,7 @@ final class SynchroModuleTransactionTests: XCTestCase {
658
932
 
659
933
  private func registrySnapshot() throws -> [String] {
660
934
  let module = try XCTUnwrap(self.module)
661
- module.sessionsLock.lock()
662
- defer { module.sessionsLock.unlock() }
663
- return Array(module.sessions.keys)
935
+ return module.activeTransactionIDs
664
936
  }
665
937
 
666
938
  /// Close removes every registered session under the registry lock before it waits for them.
@@ -686,6 +958,19 @@ final class SynchroModuleTransactionTests: XCTestCase {
686
958
  return recorder
687
959
  }
688
960
 
961
+ private func waitForSettlements(
962
+ _ recorders: [PromiseRecorder],
963
+ timeout: TimeInterval = settlementTimeout,
964
+ file: StaticString = #filePath,
965
+ line: UInt = #line
966
+ ) {
967
+ let result = XCTWaiter().wait(
968
+ for: recorders.map(\.firstSettlement),
969
+ timeout: timeout
970
+ )
971
+ XCTAssertEqual(result, .completed, "promises did not settle", file: file, line: line)
972
+ }
973
+
689
974
  private func resolvedValue(
690
975
  _ recorder: PromiseRecorder,
691
976
  file: StaticString = #filePath,