@trainstar/synchro-react-native 0.3.1 → 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.
- package/README.md +58 -4
- package/SynchroReactNative.podspec +6 -0
- package/android/build.gradle +5 -1
- package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/SynchroModuleTransactionTest.kt +591 -0
- package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/TransactionSessionTest.kt +314 -0
- package/android/src/main/kotlin/com/trainstar/synchro/rn/SynchroModule.kt +307 -110
- package/ios/SynchroModule.mm +27 -4
- package/ios/SynchroModule.swift +228 -84
- package/ios-tests/SynchroModuleTransactionTests.swift +707 -0
- package/ios-tests/TransactionSessionTests.swift +66 -0
- package/lib/module/NativeSynchro.js.map +1 -1
- package/lib/module/SynchroClient.js +116 -3
- package/lib/module/SynchroClient.js.map +1 -1
- package/lib/module/errors.js +15 -1
- package/lib/module/errors.js.map +1 -1
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/inspection.js +31 -3
- package/lib/module/inspection.js.map +1 -1
- package/lib/module/types.js +18 -0
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/example/e2e/corpus-harness.d.ts.map +1 -1
- package/lib/typescript/example/src/conformance/runner.d.ts +1 -0
- package/lib/typescript/example/src/conformance/runner.d.ts.map +1 -1
- package/lib/typescript/example/src/timeouts.d.ts +2 -0
- package/lib/typescript/example/src/timeouts.d.ts.map +1 -0
- package/lib/typescript/src/NativeSynchro.d.ts +7 -1
- package/lib/typescript/src/NativeSynchro.d.ts.map +1 -1
- package/lib/typescript/src/SynchroClient.d.ts +29 -1
- package/lib/typescript/src/SynchroClient.d.ts.map +1 -1
- package/lib/typescript/src/errors.d.ts +7 -1
- package/lib/typescript/src/errors.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/inspection.d.ts +13 -2
- package/lib/typescript/src/inspection.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +45 -2
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/NativeSynchro.ts +6 -1
- package/src/SynchroClient.ts +129 -1
- package/src/errors.ts +30 -0
- package/src/index.ts +7 -0
- package/src/inspection.ts +44 -2
- package/src/types.ts +51 -2
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import SQLite3
|
|
2
|
+
import Synchro
|
|
3
|
+
@testable import SynchroReactNative
|
|
4
|
+
import XCTest
|
|
5
|
+
|
|
6
|
+
private let settlementTimeout: TimeInterval = 60
|
|
7
|
+
private let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
8
|
+
private let columnsJSON = #"[{"name":"id","type":"TEXT","primaryKey":true},{"name":"name","type":"TEXT"}]"#
|
|
9
|
+
private let insertSQL = "INSERT INTO bridge_items (id, name) VALUES (?, ?)"
|
|
10
|
+
private let selectNameSQL = "SELECT name FROM bridge_items WHERE id = ?"
|
|
11
|
+
|
|
12
|
+
/// Records every settlement of one bridge promise, including a settlement after the first.
|
|
13
|
+
private final class PromiseRecorder: @unchecked Sendable {
|
|
14
|
+
let name: String
|
|
15
|
+
let firstSettlement: XCTestExpectation
|
|
16
|
+
private let lock = NSLock()
|
|
17
|
+
private var resolvedValues: [Any?] = []
|
|
18
|
+
private var rejectedCodes: [String?] = []
|
|
19
|
+
private var rejectedErrors: [Error?] = []
|
|
20
|
+
|
|
21
|
+
init(_ name: String) {
|
|
22
|
+
self.name = name
|
|
23
|
+
firstSettlement = XCTestExpectation(description: "\(name) settles")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
var resolve: (Any?) -> Void {
|
|
27
|
+
{ [self] value in record { resolvedValues.append(value) } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
var reject: (String?, String?, Error?) -> Void {
|
|
31
|
+
{ [self] code, _, error in
|
|
32
|
+
record {
|
|
33
|
+
rejectedCodes.append(code)
|
|
34
|
+
rejectedErrors.append(error)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
var resolutions: [Any?] { lock.withLock { resolvedValues } }
|
|
40
|
+
var rejections: [String?] { lock.withLock { rejectedCodes } }
|
|
41
|
+
var rejectionErrors: [Error?] { lock.withLock { rejectedErrors } }
|
|
42
|
+
var settlementCount: Int { lock.withLock { resolvedValues.count + rejectedCodes.count } }
|
|
43
|
+
|
|
44
|
+
private func record(_ change: () -> Void) {
|
|
45
|
+
let first = lock.withLock { () -> Bool in
|
|
46
|
+
let first = resolvedValues.isEmpty && rejectedCodes.isEmpty
|
|
47
|
+
change()
|
|
48
|
+
return first
|
|
49
|
+
}
|
|
50
|
+
if first {
|
|
51
|
+
firstSettlement.fulfill()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Records every module event that reaches the existing event delegate.
|
|
57
|
+
private final class EventRecorder: NSObject, SynchroEventEmitting {
|
|
58
|
+
private let lock = NSLock()
|
|
59
|
+
private var recorded: [String] = []
|
|
60
|
+
|
|
61
|
+
func emitEvent(_ name: String, body: NSDictionary) {
|
|
62
|
+
lock.withLock { recorded.append(name) }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
var names: [String] { lock.withLock { recorded } }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Records a cleanup failure so teardown keeps the database files for diagnosis.
|
|
69
|
+
private final class CleanupState: @unchecked Sendable {
|
|
70
|
+
private let lock = NSLock()
|
|
71
|
+
private var failed = false
|
|
72
|
+
|
|
73
|
+
func recordFailure() {
|
|
74
|
+
lock.withLock { failed = true }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
var hasFailure: Bool { lock.withLock { failed } }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Holds the first native change callback until the test releases it. Later callbacks pass.
|
|
81
|
+
private final class ChangeHold: @unchecked Sendable {
|
|
82
|
+
let entered = XCTestExpectation(description: "native change callback runs")
|
|
83
|
+
private let condition = NSCondition()
|
|
84
|
+
private var entries = 0
|
|
85
|
+
private var released = false
|
|
86
|
+
private var returnedAfterRelease = false
|
|
87
|
+
|
|
88
|
+
func enter() {
|
|
89
|
+
condition.lock()
|
|
90
|
+
entries += 1
|
|
91
|
+
let first = entries == 1
|
|
92
|
+
condition.unlock()
|
|
93
|
+
guard first else { return }
|
|
94
|
+
entered.fulfill()
|
|
95
|
+
condition.lock()
|
|
96
|
+
let deadline = Date().addingTimeInterval(settlementTimeout)
|
|
97
|
+
while !released && condition.wait(until: deadline) {}
|
|
98
|
+
returnedAfterRelease = released
|
|
99
|
+
condition.unlock()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func release() {
|
|
103
|
+
condition.lock()
|
|
104
|
+
released = true
|
|
105
|
+
condition.broadcast()
|
|
106
|
+
condition.unlock()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
var entryCount: Int {
|
|
110
|
+
condition.lock()
|
|
111
|
+
defer { condition.unlock() }
|
|
112
|
+
return entries
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
var heldUntilRelease: Bool {
|
|
116
|
+
condition.lock()
|
|
117
|
+
defer { condition.unlock() }
|
|
118
|
+
return returnedAfterRelease
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private struct SQLiteFailure: Error, CustomStringConvertible {
|
|
123
|
+
let code: Int32
|
|
124
|
+
let message: String
|
|
125
|
+
|
|
126
|
+
var description: String { "SQLite error \(code): \(message)" }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private struct CleanupFailures: Error, CustomStringConvertible {
|
|
130
|
+
let failures: [SQLiteFailure]
|
|
131
|
+
|
|
132
|
+
var description: String { failures.map(\.description).joined(separator: "; ") }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// A test-owned SQLite connection to the module database file.
|
|
136
|
+
private final class ExternalConnection {
|
|
137
|
+
private var handle: OpaquePointer?
|
|
138
|
+
private var holdsWriteLock = false
|
|
139
|
+
|
|
140
|
+
init(path: String, flags: Int32 = SQLITE_OPEN_READWRITE) throws {
|
|
141
|
+
let code = sqlite3_open_v2(path, &handle, flags, nil)
|
|
142
|
+
guard code == SQLITE_OK else {
|
|
143
|
+
let failure = SQLiteFailure(code: code, message: Self.message(handle))
|
|
144
|
+
let closeCode = sqlite3_close(handle)
|
|
145
|
+
guard closeCode == SQLITE_OK else {
|
|
146
|
+
throw CleanupFailures(failures: [failure, SQLiteFailure(code: closeCode, message: "close after failed open")])
|
|
147
|
+
}
|
|
148
|
+
handle = nil
|
|
149
|
+
throw failure
|
|
150
|
+
}
|
|
151
|
+
// A zero timeout makes a write-lock attempt report whether another connection writes now.
|
|
152
|
+
sqlite3_busy_timeout(handle, 0)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func acquireWriteLock() throws {
|
|
156
|
+
try execute("BEGIN IMMEDIATE")
|
|
157
|
+
holdsWriteLock = true
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func releaseWriteLock() throws {
|
|
161
|
+
guard holdsWriteLock else { return }
|
|
162
|
+
try execute("ROLLBACK")
|
|
163
|
+
holdsWriteLock = false
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func names(id: String) throws -> [String] {
|
|
167
|
+
try strings(selectNameSQL, binding: id)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func rowCount() throws -> Int {
|
|
171
|
+
let values = try strings("SELECT count(*) FROM bridge_items", binding: nil)
|
|
172
|
+
guard values.count == 1, let count = Int(values[0]) else {
|
|
173
|
+
throw SQLiteFailure(code: SQLITE_MISMATCH, message: "row count query returned \(values)")
|
|
174
|
+
}
|
|
175
|
+
return count
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// Attempts rollback and close. A failed close keeps the handle and the lock state.
|
|
179
|
+
func close() throws {
|
|
180
|
+
guard let handle else { return }
|
|
181
|
+
var failures: [SQLiteFailure] = []
|
|
182
|
+
if holdsWriteLock {
|
|
183
|
+
let code = sqlite3_exec(handle, "ROLLBACK", nil, nil, nil)
|
|
184
|
+
if code == SQLITE_OK {
|
|
185
|
+
holdsWriteLock = false
|
|
186
|
+
} else {
|
|
187
|
+
failures.append(SQLiteFailure(code: code, message: Self.message(handle)))
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
let code = sqlite3_close(handle)
|
|
191
|
+
if code == SQLITE_OK {
|
|
192
|
+
// Closing a connection also rolls back its open transaction.
|
|
193
|
+
self.handle = nil
|
|
194
|
+
holdsWriteLock = false
|
|
195
|
+
} else {
|
|
196
|
+
failures.append(SQLiteFailure(code: code, message: Self.message(handle)))
|
|
197
|
+
}
|
|
198
|
+
if !failures.isEmpty {
|
|
199
|
+
throw CleanupFailures(failures: failures)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func execute(_ sql: String) throws {
|
|
204
|
+
let code = sqlite3_exec(handle, sql, nil, nil, nil)
|
|
205
|
+
guard code == SQLITE_OK else {
|
|
206
|
+
throw SQLiteFailure(code: code, message: Self.message(handle))
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func strings(_ sql: String, binding value: String?) throws -> [String] {
|
|
211
|
+
var statement: OpaquePointer?
|
|
212
|
+
let prepared = sqlite3_prepare_v2(handle, sql, -1, &statement, nil)
|
|
213
|
+
guard prepared == SQLITE_OK else {
|
|
214
|
+
throw SQLiteFailure(code: prepared, message: Self.message(handle))
|
|
215
|
+
}
|
|
216
|
+
defer { sqlite3_finalize(statement) }
|
|
217
|
+
if let value {
|
|
218
|
+
let bound = sqlite3_bind_text(statement, 1, value, -1, sqliteTransient)
|
|
219
|
+
guard bound == SQLITE_OK else {
|
|
220
|
+
throw SQLiteFailure(code: bound, message: Self.message(handle))
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
var values: [String] = []
|
|
224
|
+
while true {
|
|
225
|
+
let code = sqlite3_step(statement)
|
|
226
|
+
if code == SQLITE_DONE {
|
|
227
|
+
return values
|
|
228
|
+
}
|
|
229
|
+
guard code == SQLITE_ROW, let text = sqlite3_column_text(statement, 0) else {
|
|
230
|
+
throw SQLiteFailure(code: code, message: Self.message(handle))
|
|
231
|
+
}
|
|
232
|
+
values.append(String(cString: text))
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private static func message(_ handle: OpaquePointer?) -> String {
|
|
237
|
+
handle.flatMap { sqlite3_errmsg($0) }.map { String(cString: $0) } ?? "no connection"
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
final class SynchroModuleTransactionTests: XCTestCase {
|
|
242
|
+
private var module: SynchroModuleImpl!
|
|
243
|
+
private var events: EventRecorder!
|
|
244
|
+
private var cleanup: CleanupState!
|
|
245
|
+
private var databasePath: String!
|
|
246
|
+
|
|
247
|
+
override func setUpWithError() throws {
|
|
248
|
+
try super.setUpWithError()
|
|
249
|
+
let module = SynchroModuleImpl()
|
|
250
|
+
let events = EventRecorder()
|
|
251
|
+
let cleanup = CleanupState()
|
|
252
|
+
let databasePath = (NSTemporaryDirectory() as NSString)
|
|
253
|
+
.appendingPathComponent("synchro-bridge-\(UUID().uuidString).sqlite")
|
|
254
|
+
module.eventDelegate = events
|
|
255
|
+
self.module = module
|
|
256
|
+
self.events = events
|
|
257
|
+
self.cleanup = cleanup
|
|
258
|
+
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
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
func testAcquisitionFailureRejectsBeginOnce() throws {
|
|
288
|
+
try initializeModule()
|
|
289
|
+
let external = try openExternalConnection()
|
|
290
|
+
try external.acquireWriteLock()
|
|
291
|
+
|
|
292
|
+
let begin = PromiseRecorder("begin")
|
|
293
|
+
module.beginWriteTransaction(begin.resolve, reject: begin.reject)
|
|
294
|
+
// The held write lock keeps the transaction callback from starting. Close waits for the
|
|
295
|
+
// registered transaction to finish before it closes the client, so the counters are final.
|
|
296
|
+
let close = try closeModule()
|
|
297
|
+
|
|
298
|
+
XCTAssertEqual(begin.resolutions.count, 0)
|
|
299
|
+
XCTAssertEqual(begin.rejections, ["UNKNOWN"])
|
|
300
|
+
let rejection = try XCTUnwrap(begin.rejectionErrors.first ?? nil) as NSError
|
|
301
|
+
XCTAssertEqual(rejection.domain, "GRDB.DatabaseError")
|
|
302
|
+
XCTAssertEqual(rejection.code & 0xFF, Int(SQLITE_BUSY))
|
|
303
|
+
XCTAssertEqual(close.resolutions.count, 1)
|
|
304
|
+
XCTAssertEqual(close.rejections, [])
|
|
305
|
+
try external.releaseWriteLock()
|
|
306
|
+
XCTAssertEqual(try external.rowCount(), 0)
|
|
307
|
+
|
|
308
|
+
try initializeModule()
|
|
309
|
+
let id = UUID().uuidString
|
|
310
|
+
let transaction = try commitRow(id: id, name: "after-acquisition-failure")
|
|
311
|
+
let reopenedClose = try closeModule()
|
|
312
|
+
requireSettledOnce(transaction + [reopenedClose])
|
|
313
|
+
XCTAssertEqual(try external.names(id: id), ["after-acquisition-failure"])
|
|
314
|
+
XCTAssertEqual(events.names, [])
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
func testCommitResolvesAfterDurableCompletion() throws {
|
|
318
|
+
try initializeModule()
|
|
319
|
+
let external = try openExternalConnection()
|
|
320
|
+
let id = UUID().uuidString
|
|
321
|
+
|
|
322
|
+
let transaction = try commitRow(id: id, name: "committed")
|
|
323
|
+
// The commit settled, so another connection sees the row and the write lock is free.
|
|
324
|
+
XCTAssertEqual(try external.names(id: id), ["committed"])
|
|
325
|
+
try external.acquireWriteLock()
|
|
326
|
+
try external.releaseWriteLock()
|
|
327
|
+
|
|
328
|
+
let close = try closeModule()
|
|
329
|
+
requireSettledOnce(transaction + [close])
|
|
330
|
+
XCTAssertEqual(try external.names(id: id), ["committed"])
|
|
331
|
+
XCTAssertEqual(events.names, [])
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
func testRollbackResolvesAfterRollback() throws {
|
|
335
|
+
try initializeModule()
|
|
336
|
+
let external = try openExternalConnection()
|
|
337
|
+
let id = UUID().uuidString
|
|
338
|
+
|
|
339
|
+
let begin = settle("begin") { module.beginWriteTransaction($0.resolve, reject: $0.reject) }
|
|
340
|
+
let txID = try XCTUnwrap(try resolvedValue(begin) as? String)
|
|
341
|
+
let insert = settle("insert") {
|
|
342
|
+
module.txExecute(txID, sql: insertSQL, params: [id, "rolled-back"], resolve: $0.resolve, reject: $0.reject)
|
|
343
|
+
}
|
|
344
|
+
let inserted = try XCTUnwrap(try resolvedValue(insert) as? [String: Any])
|
|
345
|
+
XCTAssertEqual(inserted["rowsAffected"] as? Int, 1)
|
|
346
|
+
let observe = settle("observe") {
|
|
347
|
+
module.txQueryOne(txID, sql: selectNameSQL, params: [id], resolve: $0.resolve, reject: $0.reject)
|
|
348
|
+
}
|
|
349
|
+
let observed = try XCTUnwrap(try resolvedValue(observe) as? [String: Any])
|
|
350
|
+
XCTAssertEqual(observed["name"] as? String, "rolled-back")
|
|
351
|
+
|
|
352
|
+
let rollback = settle("rollback") { module.rollbackTransaction(txID, resolve: $0.resolve, reject: $0.reject) }
|
|
353
|
+
_ = try resolvedValue(rollback)
|
|
354
|
+
// The rollback settled, so the module no longer holds the write lock.
|
|
355
|
+
try external.acquireWriteLock()
|
|
356
|
+
try external.releaseWriteLock()
|
|
357
|
+
XCTAssertEqual(try external.names(id: id), [])
|
|
358
|
+
let after = settle("query after rollback") {
|
|
359
|
+
module.queryOne(selectNameSQL, params: [id], resolve: $0.resolve, reject: $0.reject)
|
|
360
|
+
}
|
|
361
|
+
XCTAssertTrue(try resolvedValue(after) is NSNull)
|
|
362
|
+
|
|
363
|
+
let close = try closeModule()
|
|
364
|
+
requireSettledOnce([begin, insert, observe, rollback, after, close])
|
|
365
|
+
XCTAssertEqual(events.names, [])
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/// Observes an accepted commit after its durable write and before its SDK call returns.
|
|
369
|
+
/// This is not a window inside SQLite COMMIT.
|
|
370
|
+
func testCloseWaitsForAcceptedCommitHeldAfterDurableWrite() throws {
|
|
371
|
+
try initializeModule()
|
|
372
|
+
let external = try openExternalConnection()
|
|
373
|
+
let client = try XCTUnwrap(module.client)
|
|
374
|
+
let hold = ChangeHold()
|
|
375
|
+
let subscription = client.onChange(tables: ["bridge_items"]) { hold.enter() }
|
|
376
|
+
// Registered after the module-close teardown, so it releases the callback first.
|
|
377
|
+
addTeardownBlock {
|
|
378
|
+
hold.release()
|
|
379
|
+
subscription.cancel()
|
|
380
|
+
}
|
|
381
|
+
let id = UUID().uuidString
|
|
382
|
+
|
|
383
|
+
let begin = settle("begin") { module.beginWriteTransaction($0.resolve, reject: $0.reject) }
|
|
384
|
+
let txID = try XCTUnwrap(try resolvedValue(begin) as? String)
|
|
385
|
+
let insert = settle("insert") {
|
|
386
|
+
module.txExecute(txID, sql: insertSQL, params: [id, "held"], resolve: $0.resolve, reject: $0.reject)
|
|
387
|
+
}
|
|
388
|
+
XCTAssertEqual(try XCTUnwrap(try resolvedValue(insert) as? [String: Any])["rowsAffected"] as? Int, 1)
|
|
389
|
+
let commit = PromiseRecorder("commit")
|
|
390
|
+
module.commitTransaction(txID, resolve: commit.resolve, reject: commit.reject)
|
|
391
|
+
XCTAssertEqual(XCTWaiter().wait(for: [hold.entered], timeout: settlementTimeout), .completed)
|
|
392
|
+
|
|
393
|
+
// The write is durable, but the SDK call has not returned, so the promise is pending.
|
|
394
|
+
XCTAssertEqual(try external.names(id: id), ["held"])
|
|
395
|
+
XCTAssertEqual(commit.settlementCount, 0)
|
|
396
|
+
XCTAssertFalse(try registrySnapshot().isEmpty)
|
|
397
|
+
|
|
398
|
+
let close = PromiseRecorder("close")
|
|
399
|
+
module.close(close.resolve, reject: close.reject)
|
|
400
|
+
XCTAssertTrue(try waitUntilCloseDetachesSessions())
|
|
401
|
+
XCTAssertEqual(close.settlementCount, 0)
|
|
402
|
+
XCTAssertEqual(commit.settlementCount, 0)
|
|
403
|
+
|
|
404
|
+
hold.release()
|
|
405
|
+
XCTAssertEqual(XCTWaiter().wait(for: [commit.firstSettlement, close.firstSettlement], timeout: settlementTimeout), .completed)
|
|
406
|
+
XCTAssertTrue(hold.heldUntilRelease)
|
|
407
|
+
XCTAssertEqual(hold.entryCount, 1)
|
|
408
|
+
subscription.cancel()
|
|
409
|
+
requireSettledOnce([begin, insert, commit, close])
|
|
410
|
+
XCTAssertEqual(try external.names(id: id), ["held"])
|
|
411
|
+
|
|
412
|
+
try initializeModule()
|
|
413
|
+
let laterID = UUID().uuidString
|
|
414
|
+
let later = try commitRow(id: laterID, name: "after-held-commit")
|
|
415
|
+
let laterClose = try closeModule()
|
|
416
|
+
requireSettledOnce(later + [laterClose])
|
|
417
|
+
XCTAssertEqual(try external.names(id: laterID), ["after-held-commit"])
|
|
418
|
+
XCTAssertEqual(events.names, [])
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
func testReinitializationKeepsCommittedRowsAndAcceptsWork() throws {
|
|
422
|
+
try initializeModule()
|
|
423
|
+
let firstID = UUID().uuidString
|
|
424
|
+
let first = try commitRow(id: firstID, name: "before-close")
|
|
425
|
+
let firstClose = try closeModule()
|
|
426
|
+
|
|
427
|
+
try initializeModule()
|
|
428
|
+
let read = settle("query after reinitialize") {
|
|
429
|
+
module.queryOne(selectNameSQL, params: [firstID], resolve: $0.resolve, reject: $0.reject)
|
|
430
|
+
}
|
|
431
|
+
let row = try XCTUnwrap(try resolvedValue(read) as? [String: Any])
|
|
432
|
+
XCTAssertEqual(row["name"] as? String, "before-close")
|
|
433
|
+
let secondID = UUID().uuidString
|
|
434
|
+
let second = try commitRow(id: secondID, name: "after-reinitialize")
|
|
435
|
+
let external = try openExternalConnection()
|
|
436
|
+
let secondClose = try closeModule()
|
|
437
|
+
|
|
438
|
+
requireSettledOnce(first + [firstClose, read] + second + [secondClose])
|
|
439
|
+
XCTAssertEqual(try external.rowCount(), 2)
|
|
440
|
+
XCTAssertEqual(try external.names(id: firstID), ["before-close"])
|
|
441
|
+
XCTAssertEqual(try external.names(id: secondID), ["after-reinitialize"])
|
|
442
|
+
XCTAssertEqual(events.names, [])
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// MARK: - Inspection snapshot
|
|
446
|
+
|
|
447
|
+
func testClientStateSnapshotReadsRequestedRowsInsideReadOnlySnapshot() throws {
|
|
448
|
+
try initializeModule()
|
|
449
|
+
let firstID = UUID().uuidString
|
|
450
|
+
let secondID = UUID().uuidString
|
|
451
|
+
let commits = try commitRow(id: firstID, name: "first") + commitRow(id: secondID, name: "second")
|
|
452
|
+
|
|
453
|
+
let snapshot = settle("snapshot") {
|
|
454
|
+
module.inspectClientStateSnapshot(
|
|
455
|
+
[["sql": "SELECT id, name FROM bridge_items WHERE id = ?", "params": [firstID]]],
|
|
456
|
+
resolve: $0.resolve,
|
|
457
|
+
reject: $0.reject
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
let result = try XCTUnwrap(try resolvedValue(snapshot) as? [String: Any])
|
|
461
|
+
XCTAssertEqual(Set(result.keys), ["inspection", "applicationRows"])
|
|
462
|
+
let rows = try XCTUnwrap(result["applicationRows"] as? [[String: Any]])
|
|
463
|
+
XCTAssertEqual(rows.count, 1)
|
|
464
|
+
XCTAssertEqual(rows.first?["id"] as? String, firstID)
|
|
465
|
+
XCTAssertEqual(rows.first?["name"] as? String, "first")
|
|
466
|
+
let json = try XCTUnwrap((result["inspection"] as? String)?.data(using: .utf8))
|
|
467
|
+
let inspection = try XCTUnwrap(try JSONSerialization.jsonObject(with: json) as? [String: Any])
|
|
468
|
+
XCTAssertEqual(
|
|
469
|
+
Set(inspection.keys),
|
|
470
|
+
["client_state", "retained_mutations", "rejected_mutations"]
|
|
471
|
+
)
|
|
472
|
+
// bridge_items is a local table, so the mutation ledger stays empty.
|
|
473
|
+
for member in ["retained_mutations", "rejected_mutations"] {
|
|
474
|
+
XCTAssertEqual((inspection[member] as? [Any])?.count, 0, member)
|
|
475
|
+
}
|
|
476
|
+
let clientState = try XCTUnwrap(inspection["client_state"] as? [String: Any])
|
|
477
|
+
XCTAssertEqual(clientState["mutation_ledger_count"] as? Int, 0)
|
|
478
|
+
XCTAssertEqual(clientState["rejected_mutation_count"] as? Int, 0)
|
|
479
|
+
|
|
480
|
+
let write = settle("snapshot write") {
|
|
481
|
+
module.inspectClientStateSnapshot(
|
|
482
|
+
[["sql": "DELETE FROM bridge_items WHERE id = ?", "params": [firstID]]],
|
|
483
|
+
resolve: $0.resolve,
|
|
484
|
+
reject: $0.reject
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
XCTAssertEqual(write.resolutions.count, 0)
|
|
488
|
+
XCTAssertEqual(write.rejections.count, 1)
|
|
489
|
+
let external = try openExternalConnection()
|
|
490
|
+
let close = try closeModule()
|
|
491
|
+
|
|
492
|
+
requireSettledOnce(commits + [snapshot, close])
|
|
493
|
+
XCTAssertEqual(try external.names(id: firstID), ["first"])
|
|
494
|
+
XCTAssertEqual(try external.rowCount(), 2)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
func testRejectedInspectionMapsLegacyRejectionWithStoredFieldsOnly() throws {
|
|
498
|
+
try initializeModule()
|
|
499
|
+
let external = try openExternalConnection()
|
|
500
|
+
// A rejection stored before the mutation ledger has no exact mutation or rejection JSON.
|
|
501
|
+
try external.execute("""
|
|
502
|
+
INSERT INTO _synchro_rejected_mutations
|
|
503
|
+
(mutation_id, table_name, record_id, status, code, message, server_row_json, server_version, created_at, updated_at)
|
|
504
|
+
VALUES ('m1', 'orders', 'r0', 'rejected_terminal', 'policy_rejected', 'blocked', '{"id":"r0"}', 'server-v7',
|
|
505
|
+
'2026-01-01T00:00:00.000000Z', '2026-01-01T00:00:00.000000Z')
|
|
506
|
+
""")
|
|
507
|
+
let expected: NSDictionary = [
|
|
508
|
+
"representation": "legacy",
|
|
509
|
+
"mutationID": "m1",
|
|
510
|
+
"tableName": "orders",
|
|
511
|
+
"recordID": "r0",
|
|
512
|
+
"status": "rejected_terminal",
|
|
513
|
+
"code": "policy_rejected",
|
|
514
|
+
"message": "blocked",
|
|
515
|
+
"serverRowJSON": #"{"id":"r0"}"#,
|
|
516
|
+
"serverVersion": "server-v7",
|
|
517
|
+
"createdAt": "2026-01-01T00:00:00.000000Z",
|
|
518
|
+
"updatedAt": "2026-01-01T00:00:00.000000Z",
|
|
519
|
+
]
|
|
520
|
+
|
|
521
|
+
let inspect = settle("inspect rejected records") {
|
|
522
|
+
module.inspectRejectedMutationRecords($0.resolve, reject: $0.reject)
|
|
523
|
+
}
|
|
524
|
+
let records = try XCTUnwrap((try resolvedValue(inspect) as? String)?.data(using: .utf8))
|
|
525
|
+
XCTAssertEqual(try JSONSerialization.jsonObject(with: records) as? NSArray, [expected])
|
|
526
|
+
// The deprecated method keeps its published result: a legacy rejection cannot be inspected.
|
|
527
|
+
let deprecated = settle("inspect rejected") {
|
|
528
|
+
module.inspectRejectedMutations($0.resolve, reject: $0.reject)
|
|
529
|
+
}
|
|
530
|
+
XCTAssertEqual(deprecated.resolutions.count, 0)
|
|
531
|
+
XCTAssertEqual(deprecated.rejections.count, 1)
|
|
532
|
+
let snapshot = settle("snapshot") {
|
|
533
|
+
module.inspectClientStateSnapshot([], resolve: $0.resolve, reject: $0.reject)
|
|
534
|
+
}
|
|
535
|
+
let result = try XCTUnwrap(try resolvedValue(snapshot) as? [String: Any])
|
|
536
|
+
let json = try XCTUnwrap((result["inspection"] as? String)?.data(using: .utf8))
|
|
537
|
+
let inspection = try XCTUnwrap(try JSONSerialization.jsonObject(with: json) as? [String: Any])
|
|
538
|
+
XCTAssertEqual(inspection["rejected_mutations"] as? NSArray, [expected])
|
|
539
|
+
let close = try closeModule()
|
|
540
|
+
|
|
541
|
+
requireSettledOnce([inspect, snapshot, close])
|
|
542
|
+
XCTAssertEqual(deprecated.settlementCount, 1)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
func testRetainedInspectionMapsAcceptedLegacyImportWithStoredFieldsOnly() throws {
|
|
546
|
+
// A version-five queue row stored no mutation identity, binding, or field values.
|
|
547
|
+
let legacy = try ExternalConnection(path: databasePath, flags: SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE)
|
|
548
|
+
try legacy.execute("""
|
|
549
|
+
CREATE TABLE _grdb_migrations (identifier TEXT NOT NULL PRIMARY KEY);
|
|
550
|
+
INSERT INTO _grdb_migrations (identifier) VALUES ('synchro_v1'), ('synchro_v2_buckets'), ('synchro_v3_scopes'),
|
|
551
|
+
('synchro_v4_scope_integrity'), ('synchro_v5_rejected_mutations');
|
|
552
|
+
CREATE TABLE _synchro_pending_changes (record_id TEXT NOT NULL, table_name TEXT NOT NULL, operation TEXT NOT NULL,
|
|
553
|
+
base_updated_at TEXT, client_updated_at TEXT NOT NULL, PRIMARY KEY (table_name, record_id));
|
|
554
|
+
CREATE TABLE _synchro_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
555
|
+
INSERT INTO _synchro_meta (key, value) VALUES ('sync_lock', '0'), ('checkpoint', '0');
|
|
556
|
+
CREATE TABLE _synchro_scopes (scope_id TEXT PRIMARY KEY, cursor TEXT, checksum TEXT,
|
|
557
|
+
generation INTEGER NOT NULL DEFAULT 0, local_checksum INTEGER NOT NULL DEFAULT 0);
|
|
558
|
+
CREATE TABLE _synchro_scope_rows (scope_id TEXT NOT NULL, table_name TEXT NOT NULL, record_id TEXT NOT NULL,
|
|
559
|
+
checksum INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (scope_id, table_name, record_id));
|
|
560
|
+
CREATE TABLE _synchro_rejected_mutations (mutation_id TEXT PRIMARY KEY, table_name TEXT NOT NULL, record_id TEXT NOT NULL,
|
|
561
|
+
status TEXT NOT NULL, code TEXT NOT NULL, message TEXT, server_row_json TEXT, server_version TEXT,
|
|
562
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
563
|
+
CREATE TABLE _synchro_bucket_members (bucket_id TEXT NOT NULL, table_name TEXT NOT NULL, record_id TEXT NOT NULL,
|
|
564
|
+
checksum INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (bucket_id, table_name, record_id));
|
|
565
|
+
CREATE TABLE _synchro_bucket_checkpoints (bucket_id TEXT PRIMARY KEY, checkpoint INTEGER NOT NULL DEFAULT 0);
|
|
566
|
+
INSERT INTO _synchro_pending_changes VALUES ('r1', 'orders', 'create', NULL, '2026-01-01T00:00:00.000000Z');
|
|
567
|
+
""")
|
|
568
|
+
try legacy.close()
|
|
569
|
+
try initializeModule()
|
|
570
|
+
let external = try openExternalConnection()
|
|
571
|
+
// The import assigns the mutation identity, so the test reads the stored value.
|
|
572
|
+
let mutationIDs = try external.strings("SELECT mutation_id FROM _synchro_pending_changes WHERE record_id = 'r1'", binding: nil)
|
|
573
|
+
let expected: NSDictionary = [
|
|
574
|
+
"representation": "legacy",
|
|
575
|
+
"mutationID": try XCTUnwrap(mutationIDs.count == 1 ? mutationIDs.first : nil),
|
|
576
|
+
"localOrder": 1,
|
|
577
|
+
"tableName": "orders",
|
|
578
|
+
"recordID": "r1",
|
|
579
|
+
"operation": "insert",
|
|
580
|
+
"baseVersion": NSNull(),
|
|
581
|
+
"clientVersion": "2026-01-01T00:00:00.000000Z",
|
|
582
|
+
"status": "blocked_by_predecessor",
|
|
583
|
+
"sourceKind": "legacy_import",
|
|
584
|
+
]
|
|
585
|
+
|
|
586
|
+
let inspect = settle("inspect retained records") {
|
|
587
|
+
module.inspectRetainedMutationRecords($0.resolve, reject: $0.reject)
|
|
588
|
+
}
|
|
589
|
+
let records = try XCTUnwrap((try resolvedValue(inspect) as? String)?.data(using: .utf8))
|
|
590
|
+
XCTAssertEqual(try JSONSerialization.jsonObject(with: records) as? NSArray, [expected])
|
|
591
|
+
let snapshot = settle("snapshot") {
|
|
592
|
+
module.inspectClientStateSnapshot([], resolve: $0.resolve, reject: $0.reject)
|
|
593
|
+
}
|
|
594
|
+
let result = try XCTUnwrap(try resolvedValue(snapshot) as? [String: Any])
|
|
595
|
+
let json = try XCTUnwrap((result["inspection"] as? String)?.data(using: .utf8))
|
|
596
|
+
let inspection = try XCTUnwrap(try JSONSerialization.jsonObject(with: json) as? [String: Any])
|
|
597
|
+
XCTAssertEqual(inspection["retained_mutations"] as? NSArray, [expected])
|
|
598
|
+
let close = try closeModule()
|
|
599
|
+
|
|
600
|
+
requireSettledOnce([inspect, snapshot, close])
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// MARK: - Bridge calls
|
|
604
|
+
|
|
605
|
+
private func initializeModule() throws {
|
|
606
|
+
let config: NSDictionary = [
|
|
607
|
+
"dbPath": databasePath!,
|
|
608
|
+
"serverURL": "https://synchro.invalid",
|
|
609
|
+
"clientID": UUID().uuidString,
|
|
610
|
+
"platform": "ios",
|
|
611
|
+
"appVersion": "1.0.0",
|
|
612
|
+
]
|
|
613
|
+
let initialize = settle("initialize") { module.initialize(config, resolve: $0.resolve, reject: $0.reject) }
|
|
614
|
+
_ = try resolvedValue(initialize)
|
|
615
|
+
let createTable = settle("create table") {
|
|
616
|
+
module.createTable("bridge_items", columnsJson: columnsJSON, optionsJson: nil, resolve: $0.resolve, reject: $0.reject)
|
|
617
|
+
}
|
|
618
|
+
_ = try resolvedValue(createTable)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private func openExternalConnection() throws -> ExternalConnection {
|
|
622
|
+
let getPath = settle("get path") { module.getPath($0.resolve, reject: $0.reject) }
|
|
623
|
+
let connection = try ExternalConnection(path: try XCTUnwrap(try resolvedValue(getPath) as? String))
|
|
624
|
+
let cleanup = self.cleanup!
|
|
625
|
+
addTeardownBlock {
|
|
626
|
+
do {
|
|
627
|
+
try connection.close()
|
|
628
|
+
} catch {
|
|
629
|
+
cleanup.recordFailure()
|
|
630
|
+
XCTFail("close external connection: \(error)")
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return connection
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private func commitRow(id: String, name: String) throws -> [PromiseRecorder] {
|
|
637
|
+
let begin = settle("begin") { module.beginWriteTransaction($0.resolve, reject: $0.reject) }
|
|
638
|
+
let txID = try XCTUnwrap(try resolvedValue(begin) as? String)
|
|
639
|
+
let insert = settle("insert") {
|
|
640
|
+
module.txExecute(txID, sql: insertSQL, params: [id, name], resolve: $0.resolve, reject: $0.reject)
|
|
641
|
+
}
|
|
642
|
+
let inserted = try XCTUnwrap(try resolvedValue(insert) as? [String: Any])
|
|
643
|
+
XCTAssertEqual(inserted["rowsAffected"] as? Int, 1)
|
|
644
|
+
let commit = settle("commit") { module.commitTransaction(txID, resolve: $0.resolve, reject: $0.reject) }
|
|
645
|
+
_ = try resolvedValue(commit)
|
|
646
|
+
return [begin, insert, commit]
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private func closeModule() throws -> PromiseRecorder {
|
|
650
|
+
let close = settle("close") { module.close($0.resolve, reject: $0.reject) }
|
|
651
|
+
_ = try resolvedValue(close)
|
|
652
|
+
return close
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// MARK: - Registry observation
|
|
656
|
+
|
|
657
|
+
private func registrySnapshot() throws -> [String] {
|
|
658
|
+
let module = try XCTUnwrap(self.module)
|
|
659
|
+
module.sessionsLock.lock()
|
|
660
|
+
defer { module.sessionsLock.unlock() }
|
|
661
|
+
return Array(module.sessions.keys)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/// Close removes every registered session under the registry lock before it waits for them.
|
|
665
|
+
/// The held session cannot remove itself, so an empty registry shows that close detached it.
|
|
666
|
+
private func waitUntilCloseDetachesSessions() throws -> Bool {
|
|
667
|
+
let deadline = Date().addingTimeInterval(settlementTimeout)
|
|
668
|
+
while Date() < deadline {
|
|
669
|
+
if try registrySnapshot().isEmpty {
|
|
670
|
+
return true
|
|
671
|
+
}
|
|
672
|
+
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
|
|
673
|
+
}
|
|
674
|
+
return false
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// MARK: - Settlement
|
|
678
|
+
|
|
679
|
+
private func settle(_ name: String, _ call: (PromiseRecorder) -> Void) -> PromiseRecorder {
|
|
680
|
+
let recorder = PromiseRecorder(name)
|
|
681
|
+
call(recorder)
|
|
682
|
+
let result = XCTWaiter().wait(for: [recorder.firstSettlement], timeout: settlementTimeout)
|
|
683
|
+
XCTAssertEqual(result, .completed, "\(name) did not settle")
|
|
684
|
+
return recorder
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private func resolvedValue(
|
|
688
|
+
_ recorder: PromiseRecorder,
|
|
689
|
+
file: StaticString = #filePath,
|
|
690
|
+
line: UInt = #line
|
|
691
|
+
) throws -> Any? {
|
|
692
|
+
XCTAssertEqual(recorder.rejections, [], "\(recorder.name) rejected", file: file, line: line)
|
|
693
|
+
return try XCTUnwrap(recorder.resolutions.first, "\(recorder.name) did not resolve", file: file, line: line)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/// Checks the counters after a close that joined every registered transaction.
|
|
697
|
+
private func requireSettledOnce(
|
|
698
|
+
_ recorders: [PromiseRecorder],
|
|
699
|
+
file: StaticString = #filePath,
|
|
700
|
+
line: UInt = #line
|
|
701
|
+
) {
|
|
702
|
+
for recorder in recorders {
|
|
703
|
+
XCTAssertEqual(recorder.resolutions.count, 1, "\(recorder.name) resolutions", file: file, line: line)
|
|
704
|
+
XCTAssertEqual(recorder.rejections, [], "\(recorder.name) rejections", file: file, line: line)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|