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/README.md +35 -2
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/init.js +13 -1
- package/lib/commonjs/init.js.map +1 -1
- package/lib/commonjs/links.js +561 -61
- package/lib/commonjs/links.js.map +1 -1
- package/lib/commonjs/track.js +198 -75
- package/lib/commonjs/track.js.map +1 -1
- package/lib/module/config.js.map +1 -1
- package/lib/module/init.js +14 -2
- package/lib/module/init.js.map +1 -1
- package/lib/module/links.js +561 -61
- package/lib/module/links.js.map +1 -1
- package/lib/module/track.js +197 -75
- package/lib/module/track.js.map +1 -1
- package/lib/typescript/config.d.ts +6 -0
- package/lib/typescript/config.d.ts.map +1 -1
- package/lib/typescript/init.d.ts +1 -0
- package/lib/typescript/init.d.ts.map +1 -1
- package/lib/typescript/links.d.ts +49 -0
- package/lib/typescript/links.d.ts.map +1 -1
- package/lib/typescript/track.d.ts +17 -6
- package/lib/typescript/track.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +6 -0
- package/src/init.ts +16 -1
- package/src/links.ts +810 -40
- package/src/track.ts +283 -81
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* after
|
|
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:
|
|
327
|
+
// Rate limited or briefly down: the batch never left the durable queue.
|
|
226
328
|
if (requestGeneration === trackingGeneration && options === opts) {
|
|
227
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
399
|
+
function markBatchForRetry(batch: QueuedEvent[], status?: number): void {
|
|
296
400
|
if (!options) return;
|
|
297
401
|
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
298
|
-
const
|
|
402
|
+
const batchIds = new Set(batch.map((event) => event.id));
|
|
299
403
|
let dropped = 0;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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' &&
|
|
390
|
-
|
|
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():
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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();
|