pulse-updates 1.3.7 → 1.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.
package/src/track.ts CHANGED
@@ -52,7 +52,7 @@ export interface TrackOptions {
52
52
  /** Events per request. The server refuses more than 100. */
53
53
  maxBatch?: number;
54
54
 
55
- /** Events held before the oldest are dropped. Default 500. */
55
+ /** Best-effort events held before the oldest are dropped. Durable idempotent rows are never evicted. Default 500. */
56
56
  maxQueue?: number;
57
57
 
58
58
  /** Persists the outbox across process death. Use the same storage passed to initPulse. */
@@ -64,10 +64,13 @@ export interface TrackOptions {
64
64
  /** Per-request timeout. Default 10s. */
65
65
  timeoutMs?: number;
66
66
 
67
- /** Maximum delivery attempts before an event is dropped. Default 10. */
67
+ /** Maximum delivery attempts before a best-effort event is dropped. Durable idempotent events retry until server ACK. Default 10. */
68
68
  maxAttempts?: number;
69
69
 
70
- /** Return false until analytics consent exists. No event is retained before consent. */
70
+ /**
71
+ * Return false until analytics consent exists. No event is retained before consent. Consent is
72
+ * sampled at enqueue; an event already durably accepted remains eligible for later delivery.
73
+ */
71
74
  hasConsent?: () => boolean;
72
75
 
73
76
  /** Only these property keys may leave the process. */
@@ -99,6 +102,8 @@ interface QueuedEvent {
99
102
  time: string;
100
103
  props: Record<string, string>;
101
104
  attempts: number;
105
+ /** Caller-owned events are retained until the server ACKs their stable id. */
106
+ durable?: true;
102
107
  }
103
108
 
104
109
  const DEFAULT_FLUSH_MS = 10_000;
@@ -118,6 +123,7 @@ let sending = false;
118
123
  let nextAttemptAt = 0;
119
124
  let lifecycleSubscription: { remove: () => void } | null = null;
120
125
  let trackingGeneration = 0;
126
+ const asynchronousStorageAdapters = new WeakSet<object>();
121
127
 
122
128
  /** Turns a config url into the track url; leaves an explicit track url alone. */
123
129
  function trackUrl(url: string): string {
@@ -147,10 +153,52 @@ export function configureTracking(opts: TrackOptions): void {
147
153
  * caller has to wrap.
148
154
  */
149
155
  export function track(event: string, props?: TrackedEventInput['props'], time?: Date): void {
150
- if (!options || !event) return;
151
- if (options.hasConsent?.() === false) {
156
+ void enqueueEvent(randomEventId(), event, props, time, false);
157
+ }
158
+
159
+ /**
160
+ * Durably enqueue one logical event with a caller-owned UUID. Replaying the same id is safe:
161
+ * Pulse's server/read model deduplicates it, while the local outbox avoids duplicate rows that
162
+ * are still pending. `true` means the event is present in persistent storage, not delivered yet;
163
+ * once acknowledged, Track keeps it in that outbox through retries and process restarts until a
164
+ * successful server response removes it transactionally.
165
+ */
166
+ export function trackIdempotent(
167
+ eventId: string,
168
+ event: string,
169
+ props?: TrackedEventInput['props'],
170
+ time?: Date,
171
+ ): boolean {
172
+ const normalizedId = eventId.trim().toLowerCase();
173
+ if (!UUID_PATTERN.test(normalizedId)) return false;
174
+ return enqueueEvent(normalizedId, event, props, time, true);
175
+ }
176
+
177
+ function enqueueEvent(
178
+ eventId: string,
179
+ event: string,
180
+ props: TrackedEventInput['props'],
181
+ time: Date | undefined,
182
+ requirePersistentAck: boolean,
183
+ ): boolean {
184
+ if (!options || !event) return false;
185
+ try {
186
+ if (options.hasConsent?.() === false) {
187
+ options.onDropped?.(1, 'no-consent');
188
+ return false;
189
+ }
190
+ } catch {
152
191
  options.onDropped?.(1, 'no-consent');
153
- return;
192
+ return false;
193
+ }
194
+
195
+ if (requirePersistentAck) {
196
+ // Probe with the unchanged queue before accepting caller-owned data. This discovers custom
197
+ // adapters that secretly return a Promise without ever placing the terminal event in Track;
198
+ // the upstream Links outbox remains the single source of truth.
199
+ if (!options.storage
200
+ || !supportsDurableSyncWrites(options.storage)
201
+ || !persistQueue()) return false;
154
202
  }
155
203
 
156
204
  const flat: Record<string, string> = {};
@@ -162,25 +210,81 @@ export function track(event: string, props?: TrackedEventInput['props'], time?:
162
210
  flat[key] = redact.has(key) ? '[REDACTED]' : (typeof value === 'string' ? value : String(value));
163
211
  }
164
212
 
165
- queue.push({ id: randomEventId(), event, time: (time ?? new Date()).toISOString(), props: flat, attempts: 0 });
213
+ let occurredAt: string;
214
+ try {
215
+ occurredAt = (time ?? new Date()).toISOString();
216
+ } catch {
217
+ return false;
218
+ }
219
+
220
+ const existing = queue.find((queued) => queued.id === eventId);
221
+ if (existing) {
222
+ const sameLogicalEvent = existing.event === event
223
+ && existing.time === occurredAt
224
+ && JSON.stringify(existing.props) === JSON.stringify(flat);
225
+ if (!sameLogicalEvent) return false;
226
+ if (requirePersistentAck && existing.durable !== true) {
227
+ existing.durable = true;
228
+ if (!persistQueue()) {
229
+ delete existing.durable;
230
+ persistQueue();
231
+ return false;
232
+ }
233
+ }
234
+ return true;
235
+ }
166
236
 
167
- const maxQueue = options.maxQueue ?? DEFAULT_MAX_QUEUE;
168
- if (queue.length > maxQueue) {
169
- const dropped = queue.length - maxQueue;
170
- queue = queue.slice(dropped);
171
- options.onDropped?.(dropped, 'queue-full');
237
+ const queueBeforeDurableEnqueue = requirePersistentAck ? [...queue] : null;
238
+ queue.push({
239
+ id: eventId,
240
+ event,
241
+ time: occurredAt,
242
+ props: flat,
243
+ attempts: 0,
244
+ ...(requirePersistentAck ? { durable: true as const } : {}),
245
+ });
246
+
247
+ const maxQueue = Math.max(0, options.maxQueue ?? DEFAULT_MAX_QUEUE);
248
+ let dropped = 0;
249
+ while (queue.length > maxQueue) {
250
+ // Never evict a previously acknowledged durable event to make room. When every queued row
251
+ // is durable, the newest enqueue is refused and its upstream outbox remains authoritative.
252
+ const bestEffortIndex = queue.findIndex((queued) => queued.durable !== true);
253
+ if (bestEffortIndex < 0) {
254
+ // The queue can already exceed a newly lowered max after restart. Refuse only the newest
255
+ // durable enqueue; previously acknowledged records must still reach the server.
256
+ const newestIndex = queue.findIndex((queued) => queued.id === eventId);
257
+ if (newestIndex >= 0) {
258
+ queue.splice(newestIndex, 1);
259
+ dropped += 1;
260
+ }
261
+ break;
262
+ }
263
+ const index = bestEffortIndex;
264
+ queue.splice(index, 1);
265
+ dropped += 1;
172
266
  }
173
- persistQueue();
267
+ const retained = queue.some((queued) => queued.id === eventId);
268
+ const persisted = persistQueue();
269
+ if (requirePersistentAck && (!retained || !persisted)) {
270
+ queue = queueBeforeDurableEnqueue ?? queue;
271
+ // If an adapter revealed itself as asynchronous only on the second write, schedule a
272
+ // compensating best-effort mirror without the caller-owned event.
273
+ persistQueue();
274
+ return false;
275
+ }
276
+ if (dropped > 0) options.onDropped?.(dropped, 'queue-full');
174
277
 
175
278
  if (queue.length >= (options.maxBatch ?? DEFAULT_MAX_BATCH)) void flushEvents();
279
+ return retained && (!requirePersistentAck || persisted);
176
280
  }
177
281
 
178
282
  /**
179
283
  * Send what is queued. Returns how many events the server accepted.
180
284
  *
181
- * Failures put the batch back at the front of the queue rather than dropping it: the
182
- * usual cause is a network that is about to come back, and the usual moment is right
183
- * after launch, when the first events of a session are the ones a funnel needs most.
285
+ * The batch remains in the durable queue while HTTP is in flight and is removed only after a
286
+ * successful response is also persisted locally. This intentionally permits duplicate replay
287
+ * after a crash, while a caller-owned event id lets the server/read model deduplicate it.
184
288
  */
185
289
  export async function flushEvents(): Promise<number> {
186
290
  if (!options || sending || queue.length === 0 || Date.now() < nextAttemptAt) return 0;
@@ -196,8 +300,6 @@ export async function flushEvents(): Promise<number> {
196
300
  const requestDeviceId = anonymousInstallation ? analyticsInstallationId : ctx.deviceId;
197
301
 
198
302
  const batch = queue.slice(0, opts.maxBatch ?? DEFAULT_MAX_BATCH);
199
- queue = queue.slice(batch.length);
200
- persistQueue();
201
303
  sending = true;
202
304
 
203
305
  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
@@ -222,11 +324,9 @@ export async function flushEvents(): Promise<number> {
222
324
  });
223
325
 
224
326
  if (!response.ok) {
225
- // Rate limited or briefly down: keep the events, try on the next tick.
327
+ // Rate limited or briefly down: the batch never left the durable queue.
226
328
  if (requestGeneration === trackingGeneration && options === opts) {
227
- requeueWithBackoff(batch, response.status);
228
- } else {
229
- restoreStaleBatch(opts, batch, response.status);
329
+ markBatchForRetry(batch, response.status);
230
330
  }
231
331
  if (response.status !== 429) opts.onError?.(new Error(`Pulse track failed: ${response.status}`));
232
332
  return 0;
@@ -236,11 +336,15 @@ export async function flushEvents(): Promise<number> {
236
336
  if (requestGeneration !== trackingGeneration || options !== opts) return body.accepted ?? 0;
237
337
  if (body.ignored && body.ignored.length > 0) opts.onIgnored?.(body.ignored);
238
338
  nextAttemptAt = 0;
239
- persistQueue();
339
+ if (!acknowledgeBatch(opts, batch)) {
340
+ // The server may already have accepted the ids. Keep and replay them rather than creating
341
+ // the loss window between remote ACK and local deletion.
342
+ nextAttemptAt = Date.now() + retryDelayFor(batch);
343
+ opts.onError?.(new Error('Pulse track acknowledgement was not persisted'));
344
+ }
240
345
  return body.accepted ?? 0;
241
346
  } catch (error) {
242
- if (requestGeneration === trackingGeneration && options === opts) requeueWithBackoff(batch);
243
- else restoreStaleBatch(opts, batch);
347
+ if (requestGeneration === trackingGeneration && options === opts) markBatchForRetry(batch);
244
348
  opts.onError?.(error);
245
349
  return 0;
246
350
  } finally {
@@ -292,59 +396,93 @@ function startTimer(): void {
292
396
  (timer as unknown as { unref?: () => void }).unref?.();
293
397
  }
294
398
 
295
- function requeueWithBackoff(batch: QueuedEvent[], status?: number): void {
399
+ function markBatchForRetry(batch: QueuedEvent[], status?: number): void {
296
400
  if (!options) return;
297
401
  const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
298
- const retryable: QueuedEvent[] = [];
402
+ const batchIds = new Set(batch.map((event) => event.id));
299
403
  let dropped = 0;
300
- for (const event of batch) {
301
- const next = { ...event, attempts: event.attempts + 1 };
302
- if (next.attempts >= maxAttempts && status !== 429) dropped++;
303
- else retryable.push(next);
304
- }
305
- queue = [...retryable, ...queue];
404
+ queue = queue.flatMap((event) => {
405
+ if (!batchIds.has(event.id)) return [event];
406
+ const next = { ...event, attempts: Math.min(event.attempts + 1, 100_000) };
407
+ if (next.durable !== true && next.attempts >= maxAttempts && status !== 429) {
408
+ dropped += 1;
409
+ return [];
410
+ }
411
+ return [next];
412
+ });
306
413
  if (dropped > 0) options.onDropped?.(dropped, 'max-attempts');
307
- const attempts = retryable.reduce((max, event) => Math.max(max, event.attempts), 1);
308
- const base = Math.min(60_000, 1_000 * 2 ** Math.min(attempts - 1, 6));
309
- nextAttemptAt = Date.now() + base + Math.floor(Math.random() * Math.max(1, base / 4));
414
+ nextAttemptAt = Date.now() + retryDelayFor(batch.map((event) => ({
415
+ ...event,
416
+ attempts: Math.min(event.attempts + 1, 100_000),
417
+ })));
310
418
  persistQueue();
311
419
  }
312
420
 
313
- /**
314
- * An old request may fail after another app has been configured. Its rows belong in
315
- * the old app's outbox, never in the new app's in-memory queue. When the same app was
316
- * merely reconfigured, merge them back into the live queue so it need not restart to
317
- * see them; otherwise persist directly under the captured app-scoped key.
318
- */
319
- function restoreStaleBatch(opts: TrackOptions, batch: QueuedEvent[], status?: number): void {
320
- const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
321
- const retryable: QueuedEvent[] = [];
322
- let dropped = 0;
323
- for (const event of batch) {
324
- const next = { ...event, attempts: event.attempts + 1 };
325
- if (next.attempts >= maxAttempts && status !== 429) dropped++;
326
- else retryable.push(next);
421
+ function acknowledgeBatch(opts: TrackOptions, batch: QueuedEvent[]): boolean {
422
+ const acknowledgedIds = new Set(batch.map((event) => event.id));
423
+ const remaining = queue.filter((event) => !acknowledgedIds.has(event.id));
424
+ if (!opts.storage) {
425
+ queue = remaining;
426
+ return true;
327
427
  }
328
- if (dropped > 0) opts.onDropped?.(dropped, 'max-attempts');
329
- if (retryable.length === 0) return;
330
-
331
- const currentOptions = options;
332
- const sameOutbox = currentOptions?.storage === opts.storage &&
333
- currentOptions !== null && storageKey(currentOptions) === storageKey(opts);
334
- if (sameOutbox) {
335
- queue = [...retryable, ...queue].slice(-(options?.maxQueue ?? DEFAULT_MAX_QUEUE));
336
- persistQueue();
337
- return;
428
+ const includesDurable = batch.some((event) => event.durable === true);
429
+ // Never schedule a destructive outbox write through an adapter already known to be async.
430
+ // The server may have accepted this batch, but retaining it for a deduplicated replay is safer
431
+ // than losing it during the gap before an asynchronous deletion reaches disk.
432
+ if (!supportsDurableSyncWrites(opts.storage)) {
433
+ if (includesDurable) return false;
434
+ queue = remaining;
435
+ // Best-effort events keep the historical AsyncStorage behavior: the in-memory batch drains
436
+ // after 2xx and the mirror may replay after a crash, but it cannot block or resend forever.
437
+ writeStorageValue(
438
+ opts.storage,
439
+ storageKey(opts),
440
+ JSON.stringify(remaining),
441
+ opts.onError,
442
+ true,
443
+ );
444
+ return true;
338
445
  }
339
-
340
- if (!opts.storage) return;
341
- try {
342
- const stored = readQueue(opts);
343
- const restored = [...retryable, ...stored].slice(-(opts.maxQueue ?? DEFAULT_MAX_QUEUE));
344
- opts.storage.set(storageKey(opts), JSON.stringify(restored));
345
- } catch {
346
- // Best effort only; a telemetry recovery must not affect the new app session.
446
+ const persisted = writeStorageValue(
447
+ opts.storage,
448
+ storageKey(opts),
449
+ JSON.stringify(remaining),
450
+ opts.onError,
451
+ false,
452
+ );
453
+ if (!persisted) {
454
+ if (!includesDurable && !supportsDurableSyncWrites(opts.storage)) {
455
+ queue = remaining;
456
+ writeStorageValue(
457
+ opts.storage,
458
+ storageKey(opts),
459
+ JSON.stringify(remaining),
460
+ opts.onError,
461
+ true,
462
+ );
463
+ return true;
464
+ }
465
+ if (!supportsDurableSyncWrites(opts.storage)) {
466
+ // A conditional thenable may already have scheduled deletion. Immediately schedule the
467
+ // unchanged durable outbox behind it so a normal ordered async adapter restores the record.
468
+ writeStorageValue(
469
+ opts.storage,
470
+ storageKey(opts),
471
+ JSON.stringify(queue),
472
+ opts.onError,
473
+ true,
474
+ );
475
+ }
476
+ return false;
347
477
  }
478
+ queue = remaining;
479
+ return true;
480
+ }
481
+
482
+ function retryDelayFor(batch: QueuedEvent[]): number {
483
+ const attempts = batch.reduce((max, event) => Math.max(max, event.attempts), 1);
484
+ const base = Math.min(60_000, 1_000 * 2 ** Math.min(Math.max(attempts - 1, 0), 6));
485
+ return base + Math.floor(Math.random() * Math.max(1, base / 4));
348
486
  }
349
487
 
350
488
  function storageKey(opts: TrackOptions): string {
@@ -367,11 +505,15 @@ function resolveAnalyticsInstallationId(opts: TrackOptions): string {
367
505
 
368
506
  const generated = randomAnalyticsInstallationId();
369
507
  if (opts.storage) {
370
- try {
371
- opts.storage.set(analyticsInstallationIdStorageKey(opts), generated);
372
- } catch {
373
- // A telemetry identifier can stay memory-only when storage is unavailable.
374
- }
508
+ // This identifier does not acknowledge delivery, so asynchronous best-effort persistence is
509
+ // acceptable. The helper still observes rejections and marks accidental thenable adapters.
510
+ writeStorageValue(
511
+ opts.storage,
512
+ analyticsInstallationIdStorageKey(opts),
513
+ generated,
514
+ opts.onError,
515
+ true,
516
+ );
375
517
  }
376
518
  return generated;
377
519
  }
@@ -383,25 +525,85 @@ function readQueue(opts: TrackOptions): QueuedEvent[] {
383
525
  if (!raw) return [];
384
526
  const parsed = JSON.parse(raw) as unknown;
385
527
  if (!Array.isArray(parsed)) return [];
386
- return parsed.filter((item): item is QueuedEvent => Boolean(
528
+ const restored = parsed.filter((item): item is QueuedEvent => Boolean(
387
529
  item && typeof item === 'object' && typeof item.id === 'string' &&
388
530
  typeof item.event === 'string' && typeof item.time === 'string' &&
389
- typeof item.props === 'object' && typeof item.attempts === 'number',
390
- )).slice(-(opts.maxQueue ?? DEFAULT_MAX_QUEUE));
531
+ item.props && typeof item.props === 'object' && !Array.isArray(item.props) &&
532
+ typeof item.attempts === 'number' && Number.isSafeInteger(item.attempts) &&
533
+ item.attempts >= 0 && item.attempts <= 100_000 &&
534
+ ((item as QueuedEvent).durable === undefined || (item as QueuedEvent).durable === true),
535
+ ));
536
+ const maxQueue = Math.max(0, opts.maxQueue ?? DEFAULT_MAX_QUEUE);
537
+ if (restored.length <= maxQueue) return restored;
538
+ const selected = new Set<number>();
539
+ restored.forEach((event, index) => {
540
+ if (event.durable === true) selected.add(index);
541
+ });
542
+ // A lower max in a later app version may not erase already-ACKed durable rows. Fill any
543
+ // remaining capacity with the newest best-effort events, retaining original send order.
544
+ for (let index = restored.length - 1; index >= 0 && selected.size < maxQueue; index -= 1) {
545
+ if (restored[index]?.durable !== true) selected.add(index);
546
+ }
547
+ return restored.filter((_event, index) => selected.has(index));
391
548
  } catch {
392
549
  return [];
393
550
  }
394
551
  }
395
552
 
396
- function persistQueue(): void {
397
- if (!options?.storage) return;
553
+ function persistQueue(): boolean {
554
+ if (!options?.storage) return false;
555
+ // Best-effort track() still mirrors through AsyncStorage, but the return value stays false so
556
+ // trackIdempotent() cannot claim synchronous durability that the adapter has not provided.
557
+ return writeStorageValue(
558
+ options.storage,
559
+ storageKey(options),
560
+ JSON.stringify(queue),
561
+ options.onError,
562
+ true,
563
+ );
564
+ }
565
+
566
+ function supportsDurableSyncWrites(storage: ConfigStorage): boolean {
567
+ return storage.supportsDurableSyncWrites !== false
568
+ && !asynchronousStorageAdapters.has(storage as object);
569
+ }
570
+
571
+ function writeStorageValue(
572
+ storage: ConfigStorage,
573
+ key: string,
574
+ value: string,
575
+ onError: ((error: unknown) => void) | undefined,
576
+ allowKnownAsyncBestEffort: boolean,
577
+ ): boolean {
578
+ const declaredOrObservedAsync = !supportsDurableSyncWrites(storage);
579
+ if (declaredOrObservedAsync && !allowKnownAsyncBestEffort) return false;
398
580
  try {
399
- options.storage.set(storageKey(options), JSON.stringify(queue));
400
- } catch {
401
- // Telemetry persistence must never become an app failure.
581
+ const result = (storage.set as unknown as (storageKey: string, stored: string) => unknown)(key, value);
582
+ if (isThenable(result)) {
583
+ asynchronousStorageAdapters.add(storage as object);
584
+ void Promise.resolve(result).catch((error) => reportStorageError(onError, error));
585
+ return false;
586
+ }
587
+ return !declaredOrObservedAsync;
588
+ } catch (error) {
589
+ reportStorageError(onError, error);
590
+ return false;
402
591
  }
403
592
  }
404
593
 
594
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
595
+ return (typeof value === 'object' && value !== null) || typeof value === 'function'
596
+ ? typeof (value as { then?: unknown }).then === 'function'
597
+ : false;
598
+ }
599
+
600
+ function reportStorageError(
601
+ onError: ((error: unknown) => void) | undefined,
602
+ error: unknown,
603
+ ): void {
604
+ try { onError?.(error); } catch { /* telemetry diagnostics never become an app failure */ }
605
+ }
606
+
405
607
  function randomEventId(): string {
406
608
  const cryptoLike = globalThis.crypto as { randomUUID?: () => string } | undefined;
407
609
  if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID();