@repliqo/sdk-react-native 0.3.8 → 0.4.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.
Files changed (42) hide show
  1. package/INTEGRATION_GUIDE.md +1384 -1312
  2. package/android/src/main/java/com/repliqo/screencapture/MultiWindowCapture.java +242 -242
  3. package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +94 -94
  4. package/dist/components/RepliqoFlatList.d.ts +5 -0
  5. package/dist/components/RepliqoFlatList.js +39 -0
  6. package/dist/components/RepliqoScrollView.d.ts +3 -0
  7. package/dist/components/RepliqoScrollView.js +16 -265
  8. package/dist/components/useRepliqoScrollTracking.d.ts +33 -0
  9. package/dist/components/useRepliqoScrollTracking.js +304 -0
  10. package/dist/core/client.d.ts +52 -0
  11. package/dist/core/client.js +304 -16
  12. package/dist/core/config.d.ts +2 -2
  13. package/dist/core/config.js +8 -2
  14. package/dist/core/storage.d.ts +29 -0
  15. package/dist/core/storage.js +33 -0
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.js +3 -1
  18. package/dist/snapshot/capture.d.ts +7 -0
  19. package/dist/snapshot/capture.js +17 -5
  20. package/dist/trackers/error.tracker.d.ts +25 -0
  21. package/dist/trackers/error.tracker.js +114 -37
  22. package/dist/trackers/navigation.tracker.js +11 -0
  23. package/dist/transport/api.client.d.ts +34 -1
  24. package/dist/transport/api.client.js +102 -19
  25. package/dist/transport/batch-queue.d.ts +53 -1
  26. package/dist/transport/batch-queue.js +126 -19
  27. package/dist/types/events.d.ts +12 -0
  28. package/package.json +68 -64
  29. package/src/components/RepliqoFlatList.tsx +64 -0
  30. package/src/components/RepliqoScrollView.tsx +53 -302
  31. package/src/components/useRepliqoScrollTracking.ts +366 -0
  32. package/src/core/client.ts +953 -628
  33. package/src/core/config.ts +40 -30
  34. package/src/core/storage.ts +51 -0
  35. package/src/index.ts +54 -50
  36. package/src/snapshot/NativeScreenCapture.ts +62 -62
  37. package/src/snapshot/capture.ts +154 -142
  38. package/src/trackers/error.tracker.ts +211 -136
  39. package/src/trackers/navigation.tracker.ts +107 -98
  40. package/src/transport/api.client.ts +430 -327
  41. package/src/transport/batch-queue.ts +212 -95
  42. package/src/types/events.ts +72 -60
@@ -6,7 +6,32 @@ class ErrorTracker {
6
6
  this.sessionId = null;
7
7
  this.currentScreen = null;
8
8
  this.originalHandler = null;
9
+ this.wrapperHandler = null;
9
10
  this.isActive = false;
11
+ /**
12
+ * Handle an unhandled promise rejection. Guarded on isActive because the
13
+ * underlying trackers can't always be unregistered.
14
+ */
15
+ this.handleUnhandledRejection = (_id, error) => {
16
+ if (!this.isActive)
17
+ return;
18
+ try {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ const stack = error instanceof Error ? error.stack : undefined;
21
+ const crash = {
22
+ sessionId: this.sessionId || undefined,
23
+ type: 'unhandled_rejection',
24
+ message,
25
+ stackTrace: stack || undefined,
26
+ screenName: this.currentScreen || undefined,
27
+ timestamp: new Date().toISOString(),
28
+ };
29
+ this.onCrash(crash);
30
+ }
31
+ catch (_e) {
32
+ // Never crash the app due to error reporting
33
+ }
34
+ };
10
35
  this.onCrash = onCrash;
11
36
  }
12
37
  setSessionId(id) {
@@ -25,8 +50,11 @@ class ErrorTracker {
25
50
  stop() {
26
51
  if (!this.isActive)
27
52
  return;
53
+ // Flip the flag FIRST — the rejection trackers can't always be
54
+ // unregistered (Hermes), so their callbacks guard on isActive.
28
55
  this.isActive = false;
29
56
  this.restoreGlobalErrorHandler();
57
+ this.teardownUnhandledRejectionHandler();
30
58
  }
31
59
  /**
32
60
  * Manual error reporting - allows consumers to report caught errors.
@@ -53,66 +81,115 @@ class ErrorTracker {
53
81
  const ErrorUtils = global.ErrorUtils;
54
82
  if (ErrorUtils) {
55
83
  this.originalHandler = ErrorUtils.getGlobalHandler();
56
- ErrorUtils.setGlobalHandler((error, isFatal) => {
57
- try {
58
- const crash = {
59
- sessionId: this.sessionId || undefined,
60
- type: isFatal ? 'native_crash' : 'js_error',
61
- message: error.message,
62
- stackTrace: error.stack || undefined,
63
- screenName: this.currentScreen || undefined,
64
- metadata: { isFatal },
65
- timestamp: new Date().toISOString(),
66
- };
67
- this.onCrash(crash);
68
- }
69
- catch (_e) {
70
- // Never crash the app due to error reporting
84
+ this.wrapperHandler = (error, isFatal) => {
85
+ if (this.isActive) {
86
+ try {
87
+ const crash = {
88
+ sessionId: this.sessionId || undefined,
89
+ type: isFatal ? 'native_crash' : 'js_error',
90
+ message: error.message,
91
+ stackTrace: error.stack || undefined,
92
+ screenName: this.currentScreen || undefined,
93
+ metadata: { isFatal },
94
+ timestamp: new Date().toISOString(),
95
+ };
96
+ this.onCrash(crash);
97
+ }
98
+ catch (_e) {
99
+ // Never crash the app due to error reporting
100
+ }
71
101
  }
72
102
  // Always call original handler so RN can handle the error
73
103
  if (this.originalHandler) {
74
104
  this.originalHandler(error, isFatal);
75
105
  }
76
- });
106
+ };
107
+ ErrorUtils.setGlobalHandler(this.wrapperHandler);
77
108
  }
78
109
  }
79
110
  catch (_e) {
80
111
  // Never crash the app due to error handler setup
81
112
  }
82
113
  }
114
+ /**
115
+ * Register with the mechanisms React Native ACTUALLY uses to surface
116
+ * unhandled promise rejections:
117
+ *
118
+ * 1. Hermes' native promise rejection tracker (Hermes engine).
119
+ * 2. The `promise` polyfill's rejection-tracking module (JSC and older
120
+ * RN versions).
121
+ *
122
+ * A bare global callback (the old approach) is never invoked by RN — it
123
+ * silently captured nothing.
124
+ *
125
+ * KNOWN LIMITATION: these trackers hold a single handler — registering
126
+ * REPLACES whatever was installed before (RN's dev LogBox warning, or
127
+ * another SDK like Sentry if it initialized first), and there is no
128
+ * unregister API, so after stop() an inert handler remains installed.
129
+ * If you run multiple error-tracking SDKs, initialize Repliqo FIRST so
130
+ * the other SDK's handler wins, or disable enableCrashTracking.
131
+ */
83
132
  setupUnhandledRejectionHandler() {
133
+ // Kept for manual invocation / test hooks.
84
134
  try {
85
- if (typeof global !== 'undefined') {
86
- global.__analyticsUnhandledRejection = (_id, error) => {
87
- try {
88
- const message = error instanceof Error ? error.message : String(error);
89
- const stack = error instanceof Error ? error.stack : undefined;
90
- const crash = {
91
- sessionId: this.sessionId || undefined,
92
- type: 'unhandled_rejection',
93
- message,
94
- stackTrace: stack || undefined,
95
- screenName: this.currentScreen || undefined,
96
- timestamp: new Date().toISOString(),
97
- };
98
- this.onCrash(crash);
99
- }
100
- catch (_e) {
101
- // Never crash the app due to error reporting
102
- }
103
- };
135
+ global.__analyticsUnhandledRejection =
136
+ this.handleUnhandledRejection;
137
+ }
138
+ catch (_e) {
139
+ /* ignore */
140
+ }
141
+ // Hermes: native promise rejection tracking.
142
+ try {
143
+ const hermes = global.HermesInternal;
144
+ if (hermes?.hasPromise?.() && hermes?.enablePromiseRejectionTracker) {
145
+ hermes.enablePromiseRejectionTracker({
146
+ allRejections: true,
147
+ onUnhandled: this.handleUnhandledRejection,
148
+ onHandled: () => { },
149
+ });
150
+ return;
104
151
  }
105
152
  }
106
153
  catch (_e) {
107
- // Never crash the app due to rejection handler setup
154
+ /* ignore fall through to the polyfill */
155
+ }
156
+ // JSC / promise polyfill path.
157
+ try {
158
+ const rejectionTracking = require('promise/setimmediate/rejection-tracking');
159
+ rejectionTracking.enable({
160
+ allRejections: true,
161
+ onUnhandled: this.handleUnhandledRejection,
162
+ onHandled: () => { },
163
+ });
164
+ }
165
+ catch (_e) {
166
+ // Module unavailable (e.g. jest without the polyfill) — the global
167
+ // hook above still allows manual reporting.
168
+ }
169
+ }
170
+ teardownUnhandledRejectionHandler() {
171
+ try {
172
+ delete global.__analyticsUnhandledRejection;
173
+ }
174
+ catch (_e) {
175
+ /* ignore */
108
176
  }
177
+ // Hermes offers no unregister API and disabling the polyfill tracker
178
+ // could clobber another library's handler — the isActive guard in
179
+ // handleUnhandledRejection makes lingering registrations inert.
109
180
  }
110
181
  restoreGlobalErrorHandler() {
111
182
  try {
112
183
  const ErrorUtils = global.ErrorUtils;
113
184
  if (ErrorUtils && this.originalHandler) {
114
- ErrorUtils.setGlobalHandler(this.originalHandler);
185
+ // Only restore if WE are still the installed handler — another
186
+ // library may have wrapped us after start(), and restoring would
187
+ // clobber its handler.
188
+ if (ErrorUtils.getGlobalHandler() === this.wrapperHandler) {
189
+ ErrorUtils.setGlobalHandler(this.originalHandler);
190
+ }
115
191
  this.originalHandler = null;
192
+ this.wrapperHandler = null;
116
193
  }
117
194
  }
118
195
  catch (_e) {
@@ -57,6 +57,17 @@ function useNavigationTracker() {
57
57
  // AppAnalytics not initialized, silently ignore
58
58
  }
59
59
  }
60
+ else if (currentRouteName && !previousRouteName) {
61
+ // First route seen without onReady having fired (some apps only wire
62
+ // onStateChange). Without this, currentScreen stays null until the
63
+ // SECOND navigation and early touches/visits go unattributed.
64
+ try {
65
+ client_1.AppAnalytics.getInstance().onScreenEnter(currentRouteName);
66
+ }
67
+ catch {
68
+ // AppAnalytics not initialized, silently ignore
69
+ }
70
+ }
60
71
  routeNameRef.current = currentRouteName;
61
72
  }, []);
62
73
  return {
@@ -11,10 +11,39 @@ export declare class ApiClient {
11
11
  private logger;
12
12
  constructor(baseUrl: string, apiKey: string, logger: Logger);
13
13
  private getHeaders;
14
+ /**
15
+ * fetch with a hard timeout. Without it, a single stalled request keeps
16
+ * the BatchQueue's in-flight lock held indefinitely — no flush can run,
17
+ * the buffer fills up and starts evicting events (silent data loss).
18
+ */
19
+ private fetchWithTimeout;
20
+ /**
21
+ * Classify a non-ok batch response. Permanent PAYLOAD errors (the batch
22
+ * itself will never be accepted: 400 malformed, 413 too large, 422
23
+ * unprocessable, 404 gone) surface as NonRetryableFlushError so the
24
+ * queue drops the poison batch instead of retrying it forever.
25
+ *
26
+ * 401/403 are NOT the payload's fault — they mean the API key is wrong
27
+ * or was rotated. Treat them as transient (retry with backoff) so a key
28
+ * rotation doesn't silently destroy every live SDK's buffered data.
29
+ * 408/429 are transient by definition.
30
+ */
31
+ private batchError;
32
+ /**
33
+ * Parse a 2xx response body, tolerating malformed JSON. The server
34
+ * already accepted the data — throwing here would make the queue retry
35
+ * an already-persisted batch and create duplicates.
36
+ */
37
+ private safeJson;
14
38
  startSession(deviceId: string, deviceInfo?: DeviceInfo): Promise<{
15
39
  id: string;
16
40
  }>;
17
41
  endSession(sessionId: string): Promise<void>;
42
+ /**
43
+ * Attach the host app's user identity to a session.
44
+ * Fire-and-forget: errors are logged but never thrown.
45
+ */
46
+ identifySession(sessionId: string, userId: string, traits?: Record<string, unknown>): Promise<void>;
18
47
  sendEventsBatch(sessionId: string, events: AnalyticsEvent[]): Promise<{
19
48
  count: number;
20
49
  }>;
@@ -22,7 +51,11 @@ export declare class ApiClient {
22
51
  sendSnapshotsBatch(snapshots: SnapshotPayload[]): Promise<{
23
52
  count: number;
24
53
  }>;
25
- sendCrashReport(crash: CrashReport): Promise<void>;
54
+ /**
55
+ * Send a single crash report. Never throws; returns true when the
56
+ * server accepted it (used to clear the persisted pending copy).
57
+ */
58
+ sendCrashReport(crash: CrashReport): Promise<boolean>;
26
59
  sendCrashesBatch(crashes: CrashReport[]): Promise<{
27
60
  count: number;
28
61
  }>;
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ApiClient = void 0;
4
+ const batch_queue_1 = require("./batch-queue");
5
+ /** Abort any request that hasn't completed within this window. */
6
+ const FETCH_TIMEOUT_MS = 15000;
4
7
  class ApiClient {
5
8
  constructor(baseUrl, apiKey, logger) {
6
9
  this.baseUrl = baseUrl.replace(/\/+$/, '');
@@ -13,9 +16,61 @@ class ApiClient {
13
16
  'x-api-key': this.apiKey,
14
17
  };
15
18
  }
19
+ /**
20
+ * fetch with a hard timeout. Without it, a single stalled request keeps
21
+ * the BatchQueue's in-flight lock held indefinitely — no flush can run,
22
+ * the buffer fills up and starts evicting events (silent data loss).
23
+ */
24
+ async fetchWithTimeout(url, init) {
25
+ const controller = new AbortController();
26
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
27
+ try {
28
+ return await fetch(url, { ...init, signal: controller.signal });
29
+ }
30
+ finally {
31
+ clearTimeout(timer);
32
+ }
33
+ }
34
+ /**
35
+ * Classify a non-ok batch response. Permanent PAYLOAD errors (the batch
36
+ * itself will never be accepted: 400 malformed, 413 too large, 422
37
+ * unprocessable, 404 gone) surface as NonRetryableFlushError so the
38
+ * queue drops the poison batch instead of retrying it forever.
39
+ *
40
+ * 401/403 are NOT the payload's fault — they mean the API key is wrong
41
+ * or was rotated. Treat them as transient (retry with backoff) so a key
42
+ * rotation doesn't silently destroy every live SDK's buffered data.
43
+ * 408/429 are transient by definition.
44
+ */
45
+ batchError(context, status) {
46
+ const message = `${context}: ${status}`;
47
+ const isPermanent = status >= 400 &&
48
+ status < 500 &&
49
+ status !== 401 &&
50
+ status !== 403 &&
51
+ status !== 408 &&
52
+ status !== 429;
53
+ return isPermanent
54
+ ? new batch_queue_1.NonRetryableFlushError(message)
55
+ : new Error(message);
56
+ }
57
+ /**
58
+ * Parse a 2xx response body, tolerating malformed JSON. The server
59
+ * already accepted the data — throwing here would make the queue retry
60
+ * an already-persisted batch and create duplicates.
61
+ */
62
+ async safeJson(response, fallback) {
63
+ try {
64
+ return (await response.json());
65
+ }
66
+ catch {
67
+ this.logger.warn('Response body was not valid JSON; treating as success');
68
+ return fallback;
69
+ }
70
+ }
16
71
  async startSession(deviceId, deviceInfo) {
17
72
  try {
18
- const response = await fetch(`${this.baseUrl}/api/sessions/start`, {
73
+ const response = await this.fetchWithTimeout(`${this.baseUrl}/api/sessions/start`, {
19
74
  method: 'POST',
20
75
  headers: this.getHeaders(),
21
76
  body: JSON.stringify({ deviceId, deviceInfo }),
@@ -36,7 +91,7 @@ class ApiClient {
36
91
  }
37
92
  async endSession(sessionId) {
38
93
  try {
39
- const response = await fetch(`${this.baseUrl}/api/sessions/${sessionId}/end`, {
94
+ const response = await this.fetchWithTimeout(`${this.baseUrl}/api/sessions/${sessionId}/end`, {
40
95
  method: 'PATCH',
41
96
  headers: this.getHeaders(),
42
97
  });
@@ -51,19 +106,41 @@ class ApiClient {
51
106
  this.logger.error('Error ending session:', error);
52
107
  }
53
108
  }
109
+ /**
110
+ * Attach the host app's user identity to a session.
111
+ * Fire-and-forget: errors are logged but never thrown.
112
+ */
113
+ async identifySession(sessionId, userId, traits) {
114
+ try {
115
+ const response = await this.fetchWithTimeout(`${this.baseUrl}/api/sessions/${sessionId}/identify`, {
116
+ method: 'PATCH',
117
+ headers: this.getHeaders(),
118
+ body: JSON.stringify({ userId, traits }),
119
+ });
120
+ if (!response.ok) {
121
+ const text = await response.text();
122
+ this.logger.warn('Identify failed:', response.status, text?.substring(0, 200));
123
+ return;
124
+ }
125
+ this.logger.log('Session identified as user:', userId);
126
+ }
127
+ catch (error) {
128
+ this.logger.warn('Error identifying session:', error);
129
+ }
130
+ }
54
131
  async sendEventsBatch(sessionId, events) {
55
132
  try {
56
- const response = await fetch(`${this.baseUrl}/api/events/batch`, {
133
+ const response = await this.fetchWithTimeout(`${this.baseUrl}/api/events/batch`, {
57
134
  method: 'POST',
58
135
  headers: this.getHeaders(),
59
136
  body: JSON.stringify({ sessionId, events }),
60
137
  });
61
138
  if (!response.ok) {
62
- const text = await response.text();
139
+ const text = await response.text().catch(() => '');
63
140
  this.logger.error('Failed to send events batch:', response.status, text);
64
- throw new Error(`Failed to send events batch: ${response.status}`);
141
+ throw this.batchError('Failed to send events batch', response.status);
65
142
  }
66
- const data = await response.json();
143
+ const data = await this.safeJson(response, { count: events.length });
67
144
  this.logger.log('Events batch sent, count:', data.count);
68
145
  return { count: data.count };
69
146
  }
@@ -74,15 +151,15 @@ class ApiClient {
74
151
  }
75
152
  async sendScreenVisitsBatch(visits) {
76
153
  try {
77
- const response = await fetch(`${this.baseUrl}/api/screens/visit/batch`, {
154
+ const response = await this.fetchWithTimeout(`${this.baseUrl}/api/screens/visit/batch`, {
78
155
  method: 'POST',
79
156
  headers: this.getHeaders(),
80
157
  body: JSON.stringify({ visits }),
81
158
  });
82
159
  if (!response.ok) {
83
- const text = await response.text();
160
+ const text = await response.text().catch(() => '');
84
161
  this.logger.error('Failed to send screen visits batch:', response.status, text);
85
- throw new Error(`Failed to send screen visits batch: ${response.status}`);
162
+ throw this.batchError('Failed to send screen visits batch', response.status);
86
163
  }
87
164
  this.logger.log('Screen visits batch sent, count:', visits.length);
88
165
  }
@@ -93,17 +170,17 @@ class ApiClient {
93
170
  }
94
171
  async sendSnapshotsBatch(snapshots) {
95
172
  try {
96
- const response = await fetch(`${this.baseUrl}/api/snapshots/batch`, {
173
+ const response = await this.fetchWithTimeout(`${this.baseUrl}/api/snapshots/batch`, {
97
174
  method: 'POST',
98
175
  headers: this.getHeaders(),
99
176
  body: JSON.stringify({ snapshots }),
100
177
  });
101
178
  if (!response.ok) {
102
- const text = await response.text();
179
+ const text = await response.text().catch(() => '');
103
180
  this.logger.error('Failed to send snapshots batch:', response.status, text);
104
- throw new Error(`Failed to send snapshots batch: ${response.status}`);
181
+ throw this.batchError('Failed to send snapshots batch', response.status);
105
182
  }
106
- const data = await response.json();
183
+ const data = await this.safeJson(response, { count: snapshots.length });
107
184
  this.logger.log('Snapshots batch sent, count:', data.count);
108
185
  return { count: data.count };
109
186
  }
@@ -112,28 +189,34 @@ class ApiClient {
112
189
  throw error;
113
190
  }
114
191
  }
192
+ /**
193
+ * Send a single crash report. Never throws; returns true when the
194
+ * server accepted it (used to clear the persisted pending copy).
195
+ */
115
196
  async sendCrashReport(crash) {
116
197
  try {
117
- const response = await fetch(`${this.baseUrl}/api/crashes`, {
198
+ const response = await this.fetchWithTimeout(`${this.baseUrl}/api/crashes`, {
118
199
  method: 'POST',
119
200
  headers: this.getHeaders(),
120
201
  body: JSON.stringify(crash),
121
202
  });
122
203
  if (!response.ok) {
123
- const text = await response.text();
204
+ const text = await response.text().catch(() => '');
124
205
  this.logger.error('Failed to send crash report:', response.status, text);
125
- return;
206
+ return false;
126
207
  }
127
208
  this.logger.log('Crash report sent:', crash.type, crash.message);
209
+ return true;
128
210
  }
129
211
  catch (error) {
130
212
  // Fire-and-forget: catch errors silently, never throw
131
213
  this.logger.error('Error sending crash report:', error);
214
+ return false;
132
215
  }
133
216
  }
134
217
  async sendCrashesBatch(crashes) {
135
218
  try {
136
- const response = await fetch(`${this.baseUrl}/api/crashes/batch`, {
219
+ const response = await this.fetchWithTimeout(`${this.baseUrl}/api/crashes/batch`, {
137
220
  method: 'POST',
138
221
  headers: this.getHeaders(),
139
222
  body: JSON.stringify({ crashes }),
@@ -184,7 +267,7 @@ class ApiClient {
184
267
  if (typeof windowHeight === 'number' && Number.isFinite(windowHeight)) {
185
268
  body.windowHeight = Math.round(windowHeight);
186
269
  }
187
- const response = await fetch(`${this.baseUrl}/api/screen-tiles`, {
270
+ const response = await this.fetchWithTimeout(`${this.baseUrl}/api/screen-tiles`, {
188
271
  method: 'POST',
189
272
  headers: this.getHeaders(),
190
273
  body: JSON.stringify(body),
@@ -200,7 +283,7 @@ class ApiClient {
200
283
  }
201
284
  async uploadScreenCapture(screenName, image, width, height, isFullContent) {
202
285
  try {
203
- const response = await fetch(`${this.baseUrl}/api/screen-captures`, {
286
+ const response = await this.fetchWithTimeout(`${this.baseUrl}/api/screen-captures`, {
204
287
  method: 'POST',
205
288
  headers: this.getHeaders(),
206
289
  body: JSON.stringify({
@@ -1,3 +1,30 @@
1
+ /**
2
+ * Thrown by an onFlush callback to signal that the batch was PERMANENTLY
3
+ * rejected (e.g. HTTP 400/413 — malformed or oversized payload). The queue
4
+ * drops the batch instead of retrying it forever, which would otherwise
5
+ * block the queue and evict everything behind it.
6
+ */
7
+ export declare class NonRetryableFlushError extends Error {
8
+ constructor(message: string);
9
+ }
10
+ /**
11
+ * Thrown by an onFlush callback that delivers items in MULTIPLE requests
12
+ * (e.g. one events batch per session) when only a PREFIX of the items was
13
+ * consumed. Without it the queue would treat the flush as all-or-nothing:
14
+ * a transient failure after a successful sub-batch would RE-SEND the
15
+ * delivered items on retry (duplicates), and a poison sub-batch would
16
+ * DROP items belonging to never-attempted sub-batches.
17
+ *
18
+ * `consumedCount` = items delivered or permanently dropped, counted from
19
+ * the FRONT of the flushed array (sub-batches must be processed in order).
20
+ * `transient` = whether the stop reason was retryable (applies backoff and
21
+ * keeps the remaining items for retry).
22
+ */
23
+ export declare class PartialFlushError extends Error {
24
+ readonly consumedCount: number;
25
+ readonly transient: boolean;
26
+ constructor(consumedCount: number, transient: boolean, message?: string);
27
+ }
1
28
  export declare class BatchQueue<T> {
2
29
  private buffer;
3
30
  private maxSize;
@@ -5,14 +32,39 @@ export declare class BatchQueue<T> {
5
32
  private flushIntervalMs;
6
33
  private onFlush;
7
34
  private intervalId;
8
- private flushing;
35
+ /**
36
+ * In-flight flush promise. flush() returns THIS promise when a flush is
37
+ * already running (instead of resolving immediately), so callers like
38
+ * endSession() that `await flush()` actually wait for delivery.
39
+ */
40
+ private inFlight;
41
+ /**
42
+ * Items evicted from the FRONT of the buffer while a flush was in flight.
43
+ * Evicted items are the oldest — i.e. part of the in-flight batch — so
44
+ * after a successful flush we must remove (sent - alreadyEvicted) items,
45
+ * or we'd delete unsent NEW items that shifted to the front.
46
+ */
47
+ private evictedDuringFlush;
48
+ /** Number of items captured by the in-flight flush (0 when idle). */
49
+ private inFlightCount;
9
50
  private consecutiveFailures;
10
51
  private ticksToSkip;
11
52
  private static readonly MAX_BACKOFF_TICKS;
12
53
  constructor(maxSize: number | undefined, flushIntervalMs: number | undefined, onFlush: (items: T[]) => Promise<void>, maxBufferSize?: number);
13
54
  add(item: T): void;
55
+ /**
56
+ * Flush the buffer. If a flush is already in flight, returns its promise
57
+ * (never a silent no-op). Never rejects — failures are kept in the buffer
58
+ * and retried with backoff by the auto-flush timer.
59
+ */
14
60
  flush(): Promise<void>;
61
+ private doFlush;
62
+ private applyBackoff;
15
63
  startAutoFlush(): void;
16
64
  stopAutoFlush(): void;
17
65
  get pending(): number;
66
+ /** Read-only snapshot of the buffered items (for persistence). */
67
+ peekAll(): readonly T[];
68
+ /** Remove every buffered item (after persisting, or on session discard). */
69
+ clear(): void;
18
70
  }