@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.
- package/README.md +40 -10
- package/SynchroReactNative.podspec +3 -1
- package/android/build.gradle +8 -7
- package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/SynchroModuleTransactionTest.kt +7 -10
- package/ios/SynchroModule.swift +953 -713
- 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 +320 -35
- package/ios-tests/TransactionSessionTests.swift +167 -12
- package/lib/module/NativeSynchro.js.map +1 -1
- package/lib/typescript/src/NativeSynchro.d.ts +13 -14
- package/lib/typescript/src/NativeSynchro.d.ts.map +1 -1
- package/package.json +12 -8
- package/src/NativeSynchro.ts +13 -17
- 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
|
+
}
|
|
@@ -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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|