@apex-inc/capacitor-plugin 0.3.6 → 0.3.8

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.
@@ -67,6 +67,12 @@ public final class NativeBatchSender {
67
67
  /// Drains the queue, one batch at a time, with exponential backoff
68
68
  /// on retryable failures. Safe to call concurrently — only one
69
69
  /// flush actually runs at a time; concurrent callers no-op.
70
+ ///
71
+ /// Accumulator is passed by value through the recursion (no inout).
72
+ /// The previous inout-based design tripped Swift's exclusivity
73
+ /// runtime check at line 85 because the same `totalFlushed` local
74
+ /// was both captured by the `done` closure and passed inout — a
75
+ /// classic simultaneous-access violation that traps in debug builds.
70
76
  public func flush(queue: NativeOfflineQueue, completion: @escaping (Int, Int) -> Void) {
71
77
  serial.async { [weak self] in
72
78
  guard let self = self else { completion(0, 0); return }
@@ -77,54 +83,56 @@ public final class NativeBatchSender {
77
83
  return
78
84
  }
79
85
  self.inFlight = true
80
-
81
- var totalFlushed = 0
82
- self.drainLoop(queue: queue, flushed: &totalFlushed) { [weak self] in
83
- self?.inFlight = false
84
- let remaining = (try? queue.size()) ?? 0
85
- completion(totalFlushed, remaining)
86
+ self.drainLoop(queue: queue, accumulated: 0) { [weak self] flushed in
87
+ self?.serial.async {
88
+ self?.inFlight = false
89
+ let remaining = (try? queue.size()) ?? 0
90
+ completion(flushed, remaining)
91
+ }
86
92
  }
87
93
  }
88
94
  }
89
95
 
96
+ /// Recursive drain. `accumulated` is the running total of successfully
97
+ /// flushed events; `done` is invoked exactly once with the final count.
90
98
  private func drainLoop(
91
99
  queue: NativeOfflineQueue,
92
- flushed: inout Int,
93
- done: @escaping () -> Void
100
+ accumulated: Int,
101
+ done: @escaping (Int) -> Void
94
102
  ) {
95
103
  let batch: [NativeQueuedEvent]
96
104
  do {
97
105
  batch = try queue.peek(batchSize: batchSize)
98
106
  } catch {
99
107
  if debug { print("[apex-capacitor] peek failed: \(error)") }
100
- done()
108
+ done(accumulated)
101
109
  return
102
110
  }
103
111
  if batch.isEmpty {
104
- done()
112
+ done(accumulated)
105
113
  return
106
114
  }
107
115
 
108
- let localFlushed = flushed
109
116
  sendBatchWithRetry(batch, attempt: 0) { [weak self] outcome in
110
- guard let self = self else { done(); return }
117
+ guard let self = self else { done(accumulated); return }
111
118
  self.serial.async {
112
119
  let ids = batch.map { $0.id }
113
120
  switch outcome {
114
121
  case .success:
115
122
  try? queue.markSent(ids: ids)
116
123
  if self.debug { print("[apex-capacitor] flushed batch of \(batch.count)") }
117
- var nextFlushed = localFlushed + batch.count
118
- self.drainLoop(queue: queue, flushed: &nextFlushed, done: done)
119
- // After the recursive call returns the outer flushed
120
- // is no longer relevant — drain handles its own.
124
+ self.drainLoop(
125
+ queue: queue,
126
+ accumulated: accumulated + batch.count,
127
+ done: done
128
+ )
121
129
  case .nonRetryable(let status):
122
130
  if self.debug { print("[apex-capacitor] non-retryable HTTP \(status); leaving events queued") }
123
- done()
131
+ done(accumulated)
124
132
  case .retryableExhausted(let err):
125
133
  try? queue.markFailed(ids: ids)
126
134
  if self.debug { print("[apex-capacitor] retries exhausted: \(err)") }
127
- done()
135
+ done(accumulated)
128
136
  }
129
137
  }
130
138
  }
@@ -76,6 +76,11 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
76
76
  // reached the server. See friction-log F6.
77
77
  private var batchSender: NativeBatchSender?
78
78
  private var flushTimer: Timer?
79
+ // MMP-207 — debounced flush. Without this, every `track()` call
80
+ // kicked a POST, so 8 quick taps = 8 separate single-event HTTP
81
+ // requests. With debounce, rapid-fire events coalesce into one
82
+ // batch on the next tick.
83
+ private var flushDebounceWork: DispatchWorkItem?
79
84
 
80
85
  public override func load() {
81
86
  let defaults = UserDefaults.standard
@@ -220,16 +225,29 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
220
225
  }
221
226
  }
222
227
 
223
- /// Fire-and-forget flush. Safe to call freely; the sender's serial
224
- /// queue ensures only one drain runs at a time.
228
+ /// Fire-and-forget flush. Track-triggered calls are debounced
229
+ /// 250ms so rapid tap sessions coalesce into a single batch POST.
230
+ /// Timer + foreground + initialize callers bypass the debounce
231
+ /// since they're already paced.
225
232
  private func kickFlush(reason: String) {
226
233
  guard let queue = offlineQueue, let sender = batchSender else { return }
227
- if debug { print("[apex-capacitor] flush kicked (\(reason))") }
228
- sender.flush(queue: queue) { [weak self] flushed, remaining in
229
- if self?.debug == true && (flushed > 0 || remaining > 0) {
230
- print("[apex-capacitor] flush done sent \(flushed), remaining \(remaining)")
234
+ let runFlush: () -> Void = { [weak self] in
235
+ guard let self = self else { return }
236
+ if self.debug { print("[apex-capacitor] flush kicked (\(reason))") }
237
+ sender.flush(queue: queue) { [weak self] flushed, remaining in
238
+ if self?.debug == true && (flushed > 0 || remaining > 0) {
239
+ print("[apex-capacitor] flush done — sent \(flushed), remaining \(remaining)")
240
+ }
231
241
  }
232
242
  }
243
+ if reason == "track" {
244
+ flushDebounceWork?.cancel()
245
+ let work = DispatchWorkItem(block: runFlush)
246
+ flushDebounceWork = work
247
+ DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(250), execute: work)
248
+ } else {
249
+ runFlush()
250
+ }
233
251
  }
234
252
 
235
253
  // MARK: - ATT
@@ -424,6 +442,14 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
424
442
  }
425
443
  codablePayload["platform"] = AnyCodable("ios")
426
444
  codablePayload["timestamp"] = AnyCodable(ISO8601DateFormatter().string(from: Date()))
445
+ // MMP-207 — stamp visitorId. Without this, /api/events accepts
446
+ // the event into the SDK firehose store but its second pass
447
+ // bails at `if (!visitorId) continue;` so the event never
448
+ // reaches the TESTEVT# / EVT# stores the debug dashboard reads.
449
+ // Net result before this fix: events arrived but the dashboard
450
+ // showed nothing. The visitor id is the stable per-install id
451
+ // we mint in load() and persist to UserDefaults.
452
+ codablePayload["visitorId"] = AnyCodable(visitorId)
427
453
  let event = NativeQueuedEvent(
428
454
  id: id,
429
455
  payload: codablePayload,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apex-inc/capacitor-plugin",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Apex Capacitor plugin — iOS/Android attribution, events, deep linking, SKAN, and offline-tolerant tracking for Capacitor apps.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/esm/index.js",