@trainstar/synchro-react-native 0.3.2 → 0.4.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +112 -7
  2. package/SynchroReactNative.podspec +18 -1
  3. package/android/build.gradle +5 -1
  4. package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/SynchroModuleTransactionTest.kt +593 -0
  5. package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/TransactionSessionTest.kt +314 -0
  6. package/android/src/main/kotlin/com/trainstar/synchro/rn/SynchroModule.kt +328 -112
  7. package/ios/SynchroModule.mm +27 -4
  8. package/ios/SynchroModule.swift +251 -89
  9. package/ios-test-host/main.m +48 -0
  10. package/ios-tests/SynchroModuleTransactionTests.swift +709 -0
  11. package/ios-tests/TransactionSessionTests.swift +66 -0
  12. package/lib/module/NativeSynchro.js.map +1 -1
  13. package/lib/module/SynchroClient.js +141 -2
  14. package/lib/module/SynchroClient.js.map +1 -1
  15. package/lib/module/errors.js +15 -1
  16. package/lib/module/errors.js.map +1 -1
  17. package/lib/module/index.js +1 -1
  18. package/lib/module/index.js.map +1 -1
  19. package/lib/module/inspection.js +99 -8
  20. package/lib/module/inspection.js.map +1 -1
  21. package/lib/module/types.js +18 -0
  22. package/lib/module/types.js.map +1 -1
  23. package/lib/typescript/example/e2e/schema-check.test.d.ts +2 -0
  24. package/lib/typescript/example/e2e/schema-check.test.d.ts.map +1 -0
  25. package/lib/typescript/example/src/conformance/runner.d.ts +17 -2
  26. package/lib/typescript/example/src/conformance/runner.d.ts.map +1 -1
  27. package/lib/typescript/src/NativeSynchro.d.ts +7 -1
  28. package/lib/typescript/src/NativeSynchro.d.ts.map +1 -1
  29. package/lib/typescript/src/SynchroClient.d.ts +29 -1
  30. package/lib/typescript/src/SynchroClient.d.ts.map +1 -1
  31. package/lib/typescript/src/errors.d.ts +7 -1
  32. package/lib/typescript/src/errors.d.ts.map +1 -1
  33. package/lib/typescript/src/index.d.ts +3 -3
  34. package/lib/typescript/src/index.d.ts.map +1 -1
  35. package/lib/typescript/src/inspection.d.ts +23 -5
  36. package/lib/typescript/src/inspection.d.ts.map +1 -1
  37. package/lib/typescript/src/types.d.ts +62 -1
  38. package/lib/typescript/src/types.d.ts.map +1 -1
  39. package/package.json +21 -6
  40. package/src/NativeSynchro.ts +6 -1
  41. package/src/SynchroClient.ts +160 -1
  42. package/src/errors.ts +30 -0
  43. package/src/index.ts +7 -0
  44. package/src/inspection.ts +138 -9
  45. package/src/types.ts +70 -1
  46. package/tooling-patches/apply.cjs +163 -0
  47. package/tooling-patches/cli-clean-20.2.0.patch +21 -0
  48. package/tooling-patches/cli-config-android-20.2.0.patch +13 -0
  49. package/tooling-patches/react-native-builder-bob-0.40.18.patch +74 -0
  50. package/tooling-patches/stream-json-1.9.1.patch +40 -0
@@ -0,0 +1,709 @@
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", "migration_journal",
471
+ "migration_journal_truncated", "physical_schema", "physical_schema_truncated",
472
+ "accepted_mutation_outcomes", "accepted_mutation_outcomes_truncated"]
473
+ )
474
+ // bridge_items is a local table, so the mutation ledger stays empty.
475
+ for member in ["retained_mutations", "rejected_mutations"] {
476
+ XCTAssertEqual((inspection[member] as? [Any])?.count, 0, member)
477
+ }
478
+ let clientState = try XCTUnwrap(inspection["client_state"] as? [String: Any])
479
+ XCTAssertEqual(clientState["mutation_ledger_count"] as? Int, 0)
480
+ XCTAssertEqual(clientState["rejected_mutation_count"] as? Int, 0)
481
+
482
+ let write = settle("snapshot write") {
483
+ module.inspectClientStateSnapshot(
484
+ [["sql": "DELETE FROM bridge_items WHERE id = ?", "params": [firstID]]],
485
+ resolve: $0.resolve,
486
+ reject: $0.reject
487
+ )
488
+ }
489
+ XCTAssertEqual(write.resolutions.count, 0)
490
+ XCTAssertEqual(write.rejections.count, 1)
491
+ let external = try openExternalConnection()
492
+ let close = try closeModule()
493
+
494
+ requireSettledOnce(commits + [snapshot, close])
495
+ XCTAssertEqual(try external.names(id: firstID), ["first"])
496
+ XCTAssertEqual(try external.rowCount(), 2)
497
+ }
498
+
499
+ func testRejectedInspectionMapsLegacyRejectionWithStoredFieldsOnly() throws {
500
+ try initializeModule()
501
+ let external = try openExternalConnection()
502
+ // A rejection stored before the mutation ledger has no exact mutation or rejection JSON.
503
+ try external.execute("""
504
+ INSERT INTO _synchro_rejected_mutations
505
+ (mutation_id, table_name, record_id, status, code, message, server_row_json, server_version, created_at, updated_at)
506
+ VALUES ('m1', 'orders', 'r0', 'rejected_terminal', 'policy_rejected', 'blocked', '{"id":"r0"}', 'server-v7',
507
+ '2026-01-01T00:00:00.000000Z', '2026-01-01T00:00:00.000000Z')
508
+ """)
509
+ let expected: NSDictionary = [
510
+ "representation": "legacy",
511
+ "mutationID": "m1",
512
+ "tableName": "orders",
513
+ "recordID": "r0",
514
+ "status": "rejected_terminal",
515
+ "code": "policy_rejected",
516
+ "message": "blocked",
517
+ "serverRowJSON": #"{"id":"r0"}"#,
518
+ "serverVersion": "server-v7",
519
+ "createdAt": "2026-01-01T00:00:00.000000Z",
520
+ "updatedAt": "2026-01-01T00:00:00.000000Z",
521
+ ]
522
+
523
+ let inspect = settle("inspect rejected records") {
524
+ module.inspectRejectedMutationRecords($0.resolve, reject: $0.reject)
525
+ }
526
+ let records = try XCTUnwrap((try resolvedValue(inspect) as? String)?.data(using: .utf8))
527
+ XCTAssertEqual(try JSONSerialization.jsonObject(with: records) as? NSArray, [expected])
528
+ // The deprecated method keeps its published result: a legacy rejection cannot be inspected.
529
+ let deprecated = settle("inspect rejected") {
530
+ module.inspectRejectedMutations($0.resolve, reject: $0.reject)
531
+ }
532
+ XCTAssertEqual(deprecated.resolutions.count, 0)
533
+ XCTAssertEqual(deprecated.rejections.count, 1)
534
+ let snapshot = settle("snapshot") {
535
+ module.inspectClientStateSnapshot([], resolve: $0.resolve, reject: $0.reject)
536
+ }
537
+ let result = try XCTUnwrap(try resolvedValue(snapshot) as? [String: Any])
538
+ let json = try XCTUnwrap((result["inspection"] as? String)?.data(using: .utf8))
539
+ let inspection = try XCTUnwrap(try JSONSerialization.jsonObject(with: json) as? [String: Any])
540
+ XCTAssertEqual(inspection["rejected_mutations"] as? NSArray, [expected])
541
+ let close = try closeModule()
542
+
543
+ requireSettledOnce([inspect, snapshot, close])
544
+ XCTAssertEqual(deprecated.settlementCount, 1)
545
+ }
546
+
547
+ func testRetainedInspectionMapsAcceptedLegacyImportWithStoredFieldsOnly() throws {
548
+ // A version-five queue row stored no mutation identity, binding, or field values.
549
+ let legacy = try ExternalConnection(path: databasePath, flags: SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE)
550
+ try legacy.execute("""
551
+ CREATE TABLE _grdb_migrations (identifier TEXT NOT NULL PRIMARY KEY);
552
+ INSERT INTO _grdb_migrations (identifier) VALUES ('synchro_v1'), ('synchro_v2_buckets'), ('synchro_v3_scopes'),
553
+ ('synchro_v4_scope_integrity'), ('synchro_v5_rejected_mutations');
554
+ CREATE TABLE _synchro_pending_changes (record_id TEXT NOT NULL, table_name TEXT NOT NULL, operation TEXT NOT NULL,
555
+ base_updated_at TEXT, client_updated_at TEXT NOT NULL, PRIMARY KEY (table_name, record_id));
556
+ CREATE TABLE _synchro_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
557
+ INSERT INTO _synchro_meta (key, value) VALUES ('sync_lock', '0'), ('checkpoint', '0');
558
+ CREATE TABLE _synchro_scopes (scope_id TEXT PRIMARY KEY, cursor TEXT, checksum TEXT,
559
+ generation INTEGER NOT NULL DEFAULT 0, local_checksum INTEGER NOT NULL DEFAULT 0);
560
+ CREATE TABLE _synchro_scope_rows (scope_id TEXT NOT NULL, table_name TEXT NOT NULL, record_id TEXT NOT NULL,
561
+ checksum INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (scope_id, table_name, record_id));
562
+ CREATE TABLE _synchro_rejected_mutations (mutation_id TEXT PRIMARY KEY, table_name TEXT NOT NULL, record_id TEXT NOT NULL,
563
+ status TEXT NOT NULL, code TEXT NOT NULL, message TEXT, server_row_json TEXT, server_version TEXT,
564
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
565
+ CREATE TABLE _synchro_bucket_members (bucket_id TEXT NOT NULL, table_name TEXT NOT NULL, record_id TEXT NOT NULL,
566
+ checksum INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (bucket_id, table_name, record_id));
567
+ CREATE TABLE _synchro_bucket_checkpoints (bucket_id TEXT PRIMARY KEY, checkpoint INTEGER NOT NULL DEFAULT 0);
568
+ INSERT INTO _synchro_pending_changes VALUES ('r1', 'orders', 'create', NULL, '2026-01-01T00:00:00.000000Z');
569
+ """)
570
+ try legacy.close()
571
+ try initializeModule()
572
+ let external = try openExternalConnection()
573
+ // The import assigns the mutation identity, so the test reads the stored value.
574
+ let mutationIDs = try external.strings("SELECT mutation_id FROM _synchro_pending_changes WHERE record_id = 'r1'", binding: nil)
575
+ let expected: NSDictionary = [
576
+ "representation": "legacy",
577
+ "mutationID": try XCTUnwrap(mutationIDs.count == 1 ? mutationIDs.first : nil),
578
+ "localOrder": 1,
579
+ "tableName": "orders",
580
+ "recordID": "r1",
581
+ "operation": "insert",
582
+ "baseVersion": NSNull(),
583
+ "clientVersion": "2026-01-01T00:00:00.000000Z",
584
+ "status": "blocked_by_predecessor",
585
+ "sourceKind": "legacy_import",
586
+ ]
587
+
588
+ let inspect = settle("inspect retained records") {
589
+ module.inspectRetainedMutationRecords($0.resolve, reject: $0.reject)
590
+ }
591
+ let records = try XCTUnwrap((try resolvedValue(inspect) as? String)?.data(using: .utf8))
592
+ XCTAssertEqual(try JSONSerialization.jsonObject(with: records) as? NSArray, [expected])
593
+ let snapshot = settle("snapshot") {
594
+ module.inspectClientStateSnapshot([], resolve: $0.resolve, reject: $0.reject)
595
+ }
596
+ let result = try XCTUnwrap(try resolvedValue(snapshot) as? [String: Any])
597
+ let json = try XCTUnwrap((result["inspection"] as? String)?.data(using: .utf8))
598
+ let inspection = try XCTUnwrap(try JSONSerialization.jsonObject(with: json) as? [String: Any])
599
+ XCTAssertEqual(inspection["retained_mutations"] as? NSArray, [expected])
600
+ let close = try closeModule()
601
+
602
+ requireSettledOnce([inspect, snapshot, close])
603
+ }
604
+
605
+ // MARK: - Bridge calls
606
+
607
+ private func initializeModule() throws {
608
+ let config: NSDictionary = [
609
+ "dbPath": databasePath!,
610
+ "serverURL": "https://synchro.invalid",
611
+ "clientID": UUID().uuidString,
612
+ "platform": "ios",
613
+ "appVersion": "1.0.0",
614
+ ]
615
+ let initialize = settle("initialize") { module.initialize(config, resolve: $0.resolve, reject: $0.reject) }
616
+ _ = try resolvedValue(initialize)
617
+ let createTable = settle("create table") {
618
+ module.createTable("bridge_items", columnsJson: columnsJSON, optionsJson: nil, resolve: $0.resolve, reject: $0.reject)
619
+ }
620
+ _ = try resolvedValue(createTable)
621
+ }
622
+
623
+ private func openExternalConnection() throws -> ExternalConnection {
624
+ let getPath = settle("get path") { module.getPath($0.resolve, reject: $0.reject) }
625
+ 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
+ }
635
+ return connection
636
+ }
637
+
638
+ private func commitRow(id: String, name: String) throws -> [PromiseRecorder] {
639
+ let begin = settle("begin") { module.beginWriteTransaction($0.resolve, reject: $0.reject) }
640
+ let txID = try XCTUnwrap(try resolvedValue(begin) as? String)
641
+ let insert = settle("insert") {
642
+ module.txExecute(txID, sql: insertSQL, params: [id, name], resolve: $0.resolve, reject: $0.reject)
643
+ }
644
+ let inserted = try XCTUnwrap(try resolvedValue(insert) as? [String: Any])
645
+ XCTAssertEqual(inserted["rowsAffected"] as? Int, 1)
646
+ let commit = settle("commit") { module.commitTransaction(txID, resolve: $0.resolve, reject: $0.reject) }
647
+ _ = try resolvedValue(commit)
648
+ return [begin, insert, commit]
649
+ }
650
+
651
+ private func closeModule() throws -> PromiseRecorder {
652
+ let close = settle("close") { module.close($0.resolve, reject: $0.reject) }
653
+ _ = try resolvedValue(close)
654
+ return close
655
+ }
656
+
657
+ // MARK: - Registry observation
658
+
659
+ private func registrySnapshot() throws -> [String] {
660
+ let module = try XCTUnwrap(self.module)
661
+ module.sessionsLock.lock()
662
+ defer { module.sessionsLock.unlock() }
663
+ return Array(module.sessions.keys)
664
+ }
665
+
666
+ /// Close removes every registered session under the registry lock before it waits for them.
667
+ /// The held session cannot remove itself, so an empty registry shows that close detached it.
668
+ private func waitUntilCloseDetachesSessions() throws -> Bool {
669
+ let deadline = Date().addingTimeInterval(settlementTimeout)
670
+ while Date() < deadline {
671
+ if try registrySnapshot().isEmpty {
672
+ return true
673
+ }
674
+ RunLoop.current.run(until: Date().addingTimeInterval(0.01))
675
+ }
676
+ return false
677
+ }
678
+
679
+ // MARK: - Settlement
680
+
681
+ private func settle(_ name: String, _ call: (PromiseRecorder) -> Void) -> PromiseRecorder {
682
+ let recorder = PromiseRecorder(name)
683
+ call(recorder)
684
+ let result = XCTWaiter().wait(for: [recorder.firstSettlement], timeout: settlementTimeout)
685
+ XCTAssertEqual(result, .completed, "\(name) did not settle")
686
+ return recorder
687
+ }
688
+
689
+ private func resolvedValue(
690
+ _ recorder: PromiseRecorder,
691
+ file: StaticString = #filePath,
692
+ line: UInt = #line
693
+ ) throws -> Any? {
694
+ XCTAssertEqual(recorder.rejections, [], "\(recorder.name) rejected", file: file, line: line)
695
+ return try XCTUnwrap(recorder.resolutions.first, "\(recorder.name) did not resolve", file: file, line: line)
696
+ }
697
+
698
+ /// Checks the counters after a close that joined every registered transaction.
699
+ private func requireSettledOnce(
700
+ _ recorders: [PromiseRecorder],
701
+ file: StaticString = #filePath,
702
+ line: UInt = #line
703
+ ) {
704
+ for recorder in recorders {
705
+ XCTAssertEqual(recorder.resolutions.count, 1, "\(recorder.name) resolutions", file: file, line: line)
706
+ XCTAssertEqual(recorder.rejections, [], "\(recorder.name) rejections", file: file, line: line)
707
+ }
708
+ }
709
+ }