@usergist/feedback-react-native 0.2.0-beta.0 → 0.2.0-beta.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 (83) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +65 -3
  3. package/dist/Provider.d.ts.map +1 -1
  4. package/dist/Provider.js +51 -23
  5. package/dist/Provider.js.map +1 -1
  6. package/dist/Push.d.ts +2 -0
  7. package/dist/Push.d.ts.map +1 -1
  8. package/dist/Push.js +41 -2
  9. package/dist/Push.js.map +1 -1
  10. package/dist/UserGist.d.ts +17 -2
  11. package/dist/UserGist.d.ts.map +1 -1
  12. package/dist/UserGist.js +431 -139
  13. package/dist/UserGist.js.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/internal/context.d.ts +1 -1
  17. package/dist/internal/context.js +1 -1
  18. package/dist/internal/engine.d.ts +16 -2
  19. package/dist/internal/engine.d.ts.map +1 -1
  20. package/dist/internal/engine.js +278 -76
  21. package/dist/internal/engine.js.map +1 -1
  22. package/dist/internal/events.d.ts +6 -1
  23. package/dist/internal/events.d.ts.map +1 -1
  24. package/dist/internal/events.js +39 -18
  25. package/dist/internal/events.js.map +1 -1
  26. package/dist/internal/identity-lifecycle.d.ts +16 -0
  27. package/dist/internal/identity-lifecycle.d.ts.map +1 -0
  28. package/dist/internal/identity-lifecycle.js +96 -0
  29. package/dist/internal/identity-lifecycle.js.map +1 -0
  30. package/dist/internal/inapp-rules-cache.d.ts +1 -0
  31. package/dist/internal/inapp-rules-cache.d.ts.map +1 -1
  32. package/dist/internal/inapp-rules-cache.js +7 -0
  33. package/dist/internal/inapp-rules-cache.js.map +1 -1
  34. package/dist/internal/mutation-queue.d.ts +2 -2
  35. package/dist/internal/mutation-queue.d.ts.map +1 -1
  36. package/dist/internal/mutation-queue.js +43 -5
  37. package/dist/internal/mutation-queue.js.map +1 -1
  38. package/dist/internal/push-subscription.d.ts +11 -0
  39. package/dist/internal/push-subscription.d.ts.map +1 -0
  40. package/dist/internal/push-subscription.js +20 -0
  41. package/dist/internal/push-subscription.js.map +1 -0
  42. package/dist/internal/rules-cache.d.ts +1 -0
  43. package/dist/internal/rules-cache.d.ts.map +1 -1
  44. package/dist/internal/rules-cache.js +7 -0
  45. package/dist/internal/rules-cache.js.map +1 -1
  46. package/dist/internal/session-revocations.d.ts +5 -0
  47. package/dist/internal/session-revocations.d.ts.map +1 -0
  48. package/dist/internal/session-revocations.js +58 -0
  49. package/dist/internal/session-revocations.js.map +1 -0
  50. package/dist/internal/storage.d.ts +2 -0
  51. package/dist/internal/storage.d.ts.map +1 -1
  52. package/dist/internal/storage.js +75 -60
  53. package/dist/internal/storage.js.map +1 -1
  54. package/dist/internal/survey-invitations.d.ts +25 -0
  55. package/dist/internal/survey-invitations.d.ts.map +1 -0
  56. package/dist/internal/survey-invitations.js +54 -0
  57. package/dist/internal/survey-invitations.js.map +1 -0
  58. package/dist/internal/survey-matcher.d.ts +1 -0
  59. package/dist/internal/survey-matcher.d.ts.map +1 -1
  60. package/dist/internal/survey-matcher.js +3 -0
  61. package/dist/internal/survey-matcher.js.map +1 -1
  62. package/dist/internal/survey-rules-cache.d.ts +1 -0
  63. package/dist/internal/survey-rules-cache.d.ts.map +1 -1
  64. package/dist/internal/survey-rules-cache.js +7 -0
  65. package/dist/internal/survey-rules-cache.js.map +1 -1
  66. package/dist/internal/survey-store.d.ts +9 -2
  67. package/dist/internal/survey-store.d.ts.map +1 -1
  68. package/dist/internal/survey-store.js +37 -16
  69. package/dist/internal/survey-store.js.map +1 -1
  70. package/dist/internal/transport.d.ts +17 -4
  71. package/dist/internal/transport.d.ts.map +1 -1
  72. package/dist/internal/transport.js +40 -19
  73. package/dist/internal/transport.js.map +1 -1
  74. package/dist/internal/types.d.ts +2 -0
  75. package/dist/internal/types.d.ts.map +1 -1
  76. package/dist/internal/user-state.d.ts +2 -1
  77. package/dist/internal/user-state.d.ts.map +1 -1
  78. package/dist/internal/user-state.js +3 -2
  79. package/dist/internal/user-state.js.map +1 -1
  80. package/dist/ui/SurveyView.d.ts.map +1 -1
  81. package/dist/ui/SurveyView.js +5 -5
  82. package/dist/ui/SurveyView.js.map +1 -1
  83. package/package.json +2 -2
@@ -1,18 +1,20 @@
1
- // The engine owns all singleton state and orchestrates the modules.
2
- // `UserGist.ts` wraps these functions in a thin, try/catch-guarded public API.
3
- import { APP_OPEN_EVENT_NAME, APP_VERSION_CHANGED_EVENT_NAME } from '@usergist/sdk-core/mobile';
1
+ import { rememberRevocation, drainRevocations } from './session-revocations.js';
2
+ import { identityChanged, recoverIdentity } from './identity-lifecycle.js';
3
+ import { PresentationGate } from '@usergist/sdk-core/mobile';
4
+ import { APP_OPEN_EVENT_NAME, APP_VERSION_CHANGED_EVENT_NAME, } from '@usergist/sdk-core/mobile';
4
5
  import { STORAGE_KEYS, createStorageScope, } from './storage.js';
5
- import { createIdentityManager, generateEventId } from './identity.js';
6
+ import { createIdentityManager, generateEventId, } from './identity.js';
6
7
  import { createConsentManager } from './consent.js';
7
8
  import { createEventQueue } from './queue.js';
8
- import { createTransport, PermanentHttpError } from './transport.js';
9
+ import { createTransport, PermanentHttpError, } from './transport.js';
9
10
  import { createRulesCache } from './rules-cache.js';
10
11
  import { createSurveyRulesCache, } from './survey-rules-cache.js';
11
- import { createFrequencyCapManager } from './frequency-cap.js';
12
+ import { createFrequencyCapManager, } from './frequency-cap.js';
12
13
  import { createUserStateStore } from './user-state.js';
13
14
  import { createTriggerMatcher } from './trigger-matcher.js';
14
15
  import { createSurveyMatcher } from './survey-matcher.js';
15
- import { createInAppRulesCache } from './inapp-rules-cache.js';
16
+ import { createSurveyInvitations } from './survey-invitations.js';
17
+ import { createInAppRulesCache, } from './inapp-rules-cache.js';
16
18
  import { createInAppMatcher } from './inapp-matcher.js';
17
19
  import { createLifecycleManager } from './lifecycle.js';
18
20
  import { createContextProvider } from './context.js';
@@ -37,7 +39,9 @@ const DEFAULTS = {
37
39
  debug: false,
38
40
  };
39
41
  export function resolveConfig(config) {
40
- if (!config || typeof config.writeKey !== 'string' || config.writeKey.length === 0) {
42
+ if (!config ||
43
+ typeof config.writeKey !== 'string' ||
44
+ config.writeKey.length === 0) {
41
45
  throw new Error('UserGist.init requires a writeKey');
42
46
  }
43
47
  const environment = config.environment ?? DEFAULTS.environment;
@@ -57,22 +61,28 @@ export function resolveConfig(config) {
57
61
  appVersion: config.appVersion?.trim() || null,
58
62
  };
59
63
  }
60
- export function createEngine(config) {
64
+ export function createEngine(config, prepareSurveys) {
61
65
  const resolved = resolveConfig(config);
62
66
  setDebugEnabled(resolved.debug);
63
67
  const storage = createStorageScope(resolved.writeKey);
64
68
  const identity = createIdentityManager(storage);
69
+ const surveyInvitations = createSurveyInvitations(storage, () => identity.get());
65
70
  const consent = createConsentManager(storage);
66
71
  const queue = createEventQueue(storage, resolved.maxQueueSize);
67
72
  const mutations = createMutationQueue(storage);
68
73
  const localInstructionDedupe = createLocalInstructionDedupe(storage);
69
- const transport = createTransport({ writeKey: resolved.writeKey, apiUrl: resolved.apiUrl });
74
+ const transport = createTransport({
75
+ writeKey: resolved.writeKey,
76
+ apiUrl: resolved.apiUrl,
77
+ prepareSurveys,
78
+ });
70
79
  const rules = createRulesCache(storage, transport, resolved.triggerSyncIntervalMs);
71
80
  const surveyRules = createSurveyRulesCache(storage, transport, resolved.triggerSyncIntervalMs);
72
81
  const inAppRules = createInAppRulesCache(storage, transport, resolved.triggerSyncIntervalMs);
73
82
  const caps = createFrequencyCapManager(storage);
74
83
  const userState = createUserStateStore(storage);
75
- const events = createEventBus();
84
+ const presentation = new PresentationGate(config.presentationPaused ?? false);
85
+ const events = createEventBus(presentation);
76
86
  const matcher = createTriggerMatcher({
77
87
  rulesCache: rules,
78
88
  frequencyCaps: caps,
@@ -117,6 +127,7 @@ export function createEngine(config) {
117
127
  },
118
128
  onSyncTick: () => {
119
129
  const e = eng();
130
+ void drainRevocations(e).catch(error => reportError('session cleanup deferred', error));
120
131
  void refreshTargetingRules(e);
121
132
  void flushMutations(e);
122
133
  void pollSurveyOffers(e);
@@ -139,10 +150,12 @@ export function createEngine(config) {
139
150
  userState,
140
151
  matcher,
141
152
  surveyMatcher,
153
+ surveyInvitations,
142
154
  inAppMatcher,
143
155
  lifecycle,
144
156
  context,
145
157
  events,
158
+ presentation,
146
159
  hydrated: false,
147
160
  hydratingPromise: null,
148
161
  flushTimer: null,
@@ -151,6 +164,7 @@ export function createEngine(config) {
151
164
  subjectToken: null,
152
165
  sessionPromise: null,
153
166
  instructionPollPromise: null,
167
+ instructionDispatchPromise: null,
154
168
  resetGeneration: 0,
155
169
  resetting: false,
156
170
  localInstructionDedupe,
@@ -161,6 +175,10 @@ export function createEngine(config) {
161
175
  lastPushRegistrationAt: 0,
162
176
  };
163
177
  ref.value = engine;
178
+ transport.setAuthenticationHandler(() => { void recoverIdentity(engine).then(ok => { if (ok) {
179
+ void flushNow(engine);
180
+ void flushMutations(engine);
181
+ } }); });
164
182
  queue.onOverflow((dropped) => {
165
183
  debugLog('queue overflow', { dropped, queueSize: queue.size() });
166
184
  });
@@ -168,8 +186,10 @@ export function createEngine(config) {
168
186
  matcher.recordShown(payload.promptId, payload.shownAt);
169
187
  });
170
188
  consent.subscribe((s) => {
171
- if (!s.analytics)
189
+ if (!s.analytics) {
172
190
  engine.queue.removePurpose('analytics');
191
+ void engine.mutations.removePurpose('analytics');
192
+ }
173
193
  if (!s.feedback) {
174
194
  engine.queue.removePurpose('feedback');
175
195
  void engine.mutations.removePurpose('feedback');
@@ -177,6 +197,7 @@ export function createEngine(config) {
177
197
  }
178
198
  if (!s.survey) {
179
199
  void engine.mutations.removePurpose('survey');
200
+ void engine.surveyInvitations.clear().catch((error) => reportError('clear survey invitations failed', error));
180
201
  engine.surveyMatcher.resetPending();
181
202
  }
182
203
  if (s.feedback || s.analytics)
@@ -202,8 +223,10 @@ export async function ensureHydrated(engine) {
202
223
  engine.surveyRules.hydrate(),
203
224
  engine.inAppRules.hydrate(),
204
225
  engine.localInstructionDedupe.hydrate(),
205
- engine.storage.getJson(STORAGE_KEYS.pushToken).then(token => { if (typeof token === 'string')
206
- engine.lastPushToken = token; }),
226
+ engine.storage.getJson(STORAGE_KEYS.pushToken).then((token) => {
227
+ if (typeof token === 'string')
228
+ engine.lastPushToken = token;
229
+ }),
207
230
  ]);
208
231
  await ensureSubjectSession(engine);
209
232
  engine.hydrated = true;
@@ -222,10 +245,14 @@ export async function ensureHydrated(engine) {
222
245
  return engine.hydratingPromise;
223
246
  }
224
247
  export async function ensureSubjectSession(engine) {
248
+ if (engine.resetting)
249
+ return;
250
+ void drainRevocations(engine).catch(error => reportError('session cleanup deferred', error));
225
251
  if (engine.subjectToken)
226
252
  return;
227
253
  if (engine.sessionPromise)
228
254
  return engine.sessionPromise;
255
+ const generation = engine.resetGeneration;
229
256
  engine.sessionPromise = (async () => {
230
257
  const id = engine.identity.get();
231
258
  const persisted = await engine.storage.getJson(STORAGE_KEYS.subjectToken);
@@ -234,13 +261,25 @@ export async function ensureSubjectSession(engine) {
234
261
  anonymousId: id.anonymousId,
235
262
  ...(persisted ? { currentToken: persisted } : {}),
236
263
  });
264
+ if (generation !== engine.resetGeneration)
265
+ return;
266
+ identityChanged(engine, id.externalId ? 'identified' : 'anonymous');
267
+ if (generation !== engine.resetGeneration)
268
+ return;
237
269
  engine.subjectToken = session.subjectToken;
238
270
  engine.transport.setSubjectToken(session.subjectToken);
239
271
  await engine.storage.setJson(STORAGE_KEYS.subjectToken, session.subjectToken);
240
272
  }
241
273
  catch (error) {
274
+ if (generation !== engine.resetGeneration)
275
+ return;
242
276
  const mayRotate = error instanceof PermanentHttpError &&
243
277
  (error.status === 401 || error.status === 403 || error.status === 409);
278
+ if (id.externalId) {
279
+ if (await recoverIdentity(engine))
280
+ return;
281
+ throw error;
282
+ }
244
283
  if (!mayRotate)
245
284
  throw error;
246
285
  // A lost/expired credential must never be replaced for the same known
@@ -248,8 +287,14 @@ export async function ensureSubjectSession(engine) {
248
287
  // new server-bound subject instead.
249
288
  if (persisted)
250
289
  await engine.storage.remove(STORAGE_KEYS.subjectToken);
290
+ if (generation !== engine.resetGeneration)
291
+ return;
251
292
  const rotated = await engine.identity.rotate();
252
- const session = await engine.transport.session({ anonymousId: rotated.anonymousId });
293
+ const session = await engine.transport.session({
294
+ anonymousId: rotated.anonymousId,
295
+ });
296
+ if (generation !== engine.resetGeneration)
297
+ return;
253
298
  engine.subjectToken = session.subjectToken;
254
299
  engine.transport.setSubjectToken(session.subjectToken);
255
300
  await engine.storage.setJson(STORAGE_KEYS.subjectToken, session.subjectToken);
@@ -337,6 +382,11 @@ function instructionKey(type, refId, eventId) {
337
382
  function rememberLocalInstruction(engine, key) {
338
383
  engine.localInstructionDedupe.remember(key);
339
384
  }
385
+ function needsImmediateDelivery(engine, eventName) {
386
+ return !!(engine.rules?.needsServer?.(eventName) ||
387
+ engine.surveyRules?.needsServer?.(eventName) ||
388
+ engine.inAppRules?.needsServer?.(eventName));
389
+ }
340
390
  export function scheduleFlush(engine) {
341
391
  if (engine.flushTimer)
342
392
  return;
@@ -356,11 +406,17 @@ export async function flushNow(engine) {
356
406
  return engine.flushPromise;
357
407
  }
358
408
  async function performFlush(engine) {
409
+ void engine.retryPushRegistration?.().catch(error => reportError('push registration deferred', error));
410
+ let requestedImmediateDelivery = false;
359
411
  try {
360
412
  await ensureHydrated(engine);
361
- while (engine.queue.size() > 0) {
413
+ while (!engine.resetting && engine.queue.size() > 0) {
362
414
  const consent = engine.consent.get();
363
- const allowed = engine.queue.snapshot().filter((event) => event.purpose === 'analytics' ? consent.analytics : consent.feedback);
415
+ const allowed = engine.queue
416
+ .snapshot()
417
+ .filter((event) => event.purpose === 'analytics'
418
+ ? consent.analytics
419
+ : consent.feedback);
364
420
  const firstAllowed = allowed[0];
365
421
  if (!firstAllowed)
366
422
  break;
@@ -368,8 +424,15 @@ async function performFlush(engine) {
368
424
  // post-identify events. The API deliberately rejects a batch whose
369
425
  // event identities differ from its context, so drain one identity at a
370
426
  // time while retaining FIFO order within that identity.
371
- const batch = allowed.filter((event) => event.anonymousId === firstAllowed.anonymousId &&
372
- event.externalId === firstAllowed.externalId).slice(0, engine.config.flushBatchSize);
427
+ const candidates = allowed
428
+ .filter((event) => event.anonymousId === firstAllowed.anonymousId &&
429
+ event.externalId === firstAllowed.externalId)
430
+ .slice(0, engine.config.flushBatchSize);
431
+ let deliveryCount = 0;
432
+ const overflow = candidates.findIndex(event => needsImmediateDelivery(engine, event.name) && ++deliveryCount > 20);
433
+ // Limit authorization work, not ordinary analytics rows. Keep earlier
434
+ // watch/property events in order without shrinking every batch to 20.
435
+ const batch = overflow < 0 ? candidates : candidates.slice(0, overflow);
373
436
  if (batch.length === 0)
374
437
  break;
375
438
  const ingestEvents = batch.map((e) => ({
@@ -383,7 +446,16 @@ async function performFlush(engine) {
383
446
  sdkVersion: engine.context.sdkVersion(),
384
447
  platform: engine.context.platform(),
385
448
  }));
449
+ const deliveryEventIds = batch
450
+ .filter((event) => needsImmediateDelivery(engine, event.name))
451
+ .map((event) => event.eventId);
452
+ requestedImmediateDelivery ||= deliveryEventIds.length > 0;
453
+ const deliveryGeneration = engine.resetGeneration;
454
+ const deliverySubjectToken = engine.subjectToken;
386
455
  const payload = {
456
+ ...(deliveryEventIds.length
457
+ ? { delivery: { eventIds: deliveryEventIds } }
458
+ : {}),
387
459
  events: ingestEvents,
388
460
  context: engine.context.build({
389
461
  anonymousId: batch[0].anonymousId,
@@ -391,11 +463,18 @@ async function performFlush(engine) {
391
463
  }),
392
464
  };
393
465
  try {
394
- await engine.transport.ingest(payload);
466
+ const result = await engine.transport.ingest(payload);
467
+ if (engine.resetting ||
468
+ engine.resetGeneration !== deliveryGeneration ||
469
+ engine.subjectToken !== deliverySubjectToken)
470
+ return;
395
471
  engine.queue.remove(batch.map((event) => event.eventId));
472
+ if (result.instructions?.length) {
473
+ await consumeInstructions(engine, result.instructions, false, deliveryGeneration, deliverySubjectToken);
474
+ }
396
475
  }
397
476
  catch (e) {
398
- if (e instanceof PermanentHttpError) {
477
+ if (e instanceof PermanentHttpError && e.status !== 401) {
399
478
  if (batch.length === 1) {
400
479
  engine.queue.remove([batch[0].eventId]);
401
480
  reportError('ingest event quarantined after permanent rejection', {
@@ -418,7 +497,7 @@ async function performFlush(engine) {
418
497
  engine.queue.remove([first.eventId]);
419
498
  }
420
499
  catch (singleError) {
421
- if (singleError instanceof PermanentHttpError) {
500
+ if (singleError instanceof PermanentHttpError && singleError.status !== 401) {
422
501
  engine.queue.remove([first.eventId]);
423
502
  reportError('ingest event quarantined after permanent rejection', {
424
503
  eventId: first.eventId,
@@ -436,14 +515,14 @@ async function performFlush(engine) {
436
515
  }
437
516
  }
438
517
  // After draining the queue, opportunistically pull pending survey offers.
439
- await flushMutations(engine);
518
+ void flushMutations(engine);
440
519
  // Triggered surveys appear in the offer ledger after the server processes
441
520
  // the event; the small delay gives NATS + the trigger engine time to
442
521
  // run before we ask.
443
522
  setTimeout(() => {
444
523
  void pollSurveyOffers(engine);
445
524
  void pollInstructions(engine);
446
- }, 1500);
525
+ }, requestedImmediateDelivery ? 100 : 1500);
447
526
  }
448
527
  catch (e) {
449
528
  reportError('flushNow failed', e);
@@ -469,14 +548,48 @@ async function performMutationFlush(engine) {
469
548
  break;
470
549
  const deliveryGeneration = engine.resetGeneration;
471
550
  const consent = engine.consent.get();
551
+ if (mutation.purpose === 'analytics' && !consent.analytics)
552
+ break;
472
553
  if (mutation.purpose === 'feedback' && !consent.feedback)
473
554
  break;
474
555
  if (mutation.purpose === 'survey' && !consent.survey)
475
556
  break;
476
557
  try {
477
- if (mutation.kind === 'feedback-response') {
558
+ if (consent.analytics && (mutation.kind === 'identify' || mutation.kind === 'user-properties')) {
559
+ const current = engine.identity.get();
560
+ await engine.transport.consent({ anonymousId: current.anonymousId, externalId: current.externalId,
561
+ purposes: { analytics: consent.analytics, feedback: consent.feedback, push: consent.push, survey: consent.survey },
562
+ version: consent.version, effectiveAt: consent.updatedAt ?? new Date().toISOString(),
563
+ }, current.externalId && mutation.kind === 'identify' ? String(mutation.payload.subjectToken) : undefined);
564
+ if (engine.resetting || engine.resetGeneration !== deliveryGeneration)
565
+ break;
566
+ if (mutation.kind === 'user-properties' && !engine.consent.allowsAnalytics())
567
+ continue;
568
+ }
569
+ if (mutation.kind === 'instruction-ack') {
570
+ await engine.transport.acknowledgeInstructions(mutation.payload.ids);
571
+ }
572
+ else if (mutation.kind === 'user-properties') {
573
+ const result = await engine.transport.userProperties(mutation.payload);
574
+ if (engine.resetting || engine.resetGeneration !== deliveryGeneration)
575
+ break;
576
+ const set = mutation.payload.set;
577
+ const unset = mutation.payload.unset;
578
+ engine.userState.mergeProperties(Object.fromEntries(Object.entries(set ?? {}).filter(([key]) => !result.filteredKeys.includes(key))), unset);
579
+ if (result.filteredKeys.length)
580
+ reportError('User properties filtered by app privacy settings', result.filteredKeys);
581
+ }
582
+ else if (mutation.kind === 'feedback-response') {
478
583
  await engine.transport.submitResponse(mutation.payload);
479
584
  }
585
+ else if (mutation.kind === 'survey-start') {
586
+ await engine.transport.surveyCreateAttempt(String(mutation.payload.surveyId), mutation.payload
587
+ .body);
588
+ }
589
+ else if (mutation.kind === 'survey-progress') {
590
+ await engine.transport.surveyUpdateProgress(String(mutation.payload.attemptId), mutation.payload
591
+ .body);
592
+ }
480
593
  else if (mutation.kind === 'survey-complete') {
481
594
  const attemptId = mutation.payload.attemptId;
482
595
  if (typeof attemptId !== 'string')
@@ -499,30 +612,49 @@ async function performMutationFlush(engine) {
499
612
  typeof externalId !== 'string')
500
613
  throw new Error('invalid-identify-mutation');
501
614
  try {
502
- await engine.transport.identify({
615
+ const accepted = await engine.transport.identify({
503
616
  anonymousId,
504
617
  externalId,
505
- ...(properties && typeof properties === 'object'
506
- ? { properties: properties }
618
+ ...(engine.subjectToken ? { previousSubjectToken: engine.subjectToken } : {}),
619
+ ...(engine.consent.allowsAnalytics() && properties && typeof properties === 'object'
620
+ ? {
621
+ properties: properties,
622
+ }
507
623
  : {}),
508
624
  }, subjectToken);
509
625
  if (engine.resetting || engine.resetGeneration !== deliveryGeneration)
510
626
  break;
511
- await engine.storage.setJsonStrict(STORAGE_KEYS.subjectToken, subjectToken);
627
+ const sessionToken = accepted.subjectToken ?? subjectToken;
628
+ await engine.storage.setJsonStrict(STORAGE_KEYS.subjectToken, sessionToken);
512
629
  if (engine.resetting || engine.resetGeneration !== deliveryGeneration)
513
630
  break;
514
- engine.subjectToken = subjectToken;
515
- engine.transport.setSubjectToken(subjectToken);
631
+ engine.subjectToken = sessionToken;
632
+ engine.transport.setSubjectToken(sessionToken);
633
+ if (engine.identity.get().externalId !== externalId)
634
+ engine.presentation.invalidate();
516
635
  await engine.identity.setExternalId(externalId);
636
+ if (engine.resetting || engine.resetGeneration !== deliveryGeneration)
637
+ break;
638
+ identityChanged(engine, 'identified');
639
+ void engine.retryPushRegistration?.().catch(error => reportError('push registration deferred', error));
517
640
  if (properties && typeof properties === 'object') {
518
- const clean = properties;
519
- engine.userState.mergeProperties(clean);
641
+ const supplied = properties;
642
+ const clean = accepted.filteredKeys
643
+ ? Object.fromEntries(Object.entries(supplied).filter(([key]) => !accepted.filteredKeys.includes(key)))
644
+ : asEventProps(supplied) ?? {};
645
+ if (accepted.properties)
646
+ engine.userState.replaceProperties(accepted.properties);
647
+ else
648
+ engine.userState.mergeProperties(clean);
520
649
  if (engine.consent.allowsAnalytics()) {
521
- enqueueAndEvaluate(engine, '$identify', clean);
650
+ enqueueAndEvaluate(engine, '$identify', asEventProps(clean));
522
651
  }
523
652
  }
524
- else if (engine.consent.allowsAnalytics()) {
525
- enqueueAndEvaluate(engine, '$identify', undefined);
653
+ else {
654
+ if (accepted.properties)
655
+ engine.userState.replaceProperties(accepted.properties);
656
+ if (engine.consent.allowsAnalytics())
657
+ enqueueAndEvaluate(engine, '$identify', undefined);
526
658
  }
527
659
  }
528
660
  catch (error) {
@@ -536,7 +668,9 @@ async function performMutationFlush(engine) {
536
668
  catch (error) {
537
669
  if (engine.resetting || engine.resetGeneration !== deliveryGeneration)
538
670
  break;
539
- if (error instanceof PermanentHttpError) {
671
+ if (error instanceof PermanentHttpError && error.status === 401 && mutation.kind === 'identify')
672
+ identityChanged(engine, 'authentication-required');
673
+ if (error instanceof PermanentHttpError && error.status !== 401) {
540
674
  await engine.mutations.remove(mutation.id);
541
675
  permanentlyRejectedIds.add(mutation.id);
542
676
  reportError('mutation quarantined after permanent rejection', {
@@ -570,6 +704,7 @@ export async function pollSurveyOffers(engine) {
570
704
  lastPollAt = now;
571
705
  if (!engine.consent.get().survey)
572
706
  return;
707
+ const presentationIsValid = engine.presentation.validator('survey');
573
708
  const id = engine.identity.get();
574
709
  const res = await engine.transport.surveysAvailable({
575
710
  anonymousId: id.anonymousId,
@@ -578,7 +713,8 @@ export async function pollSurveyOffers(engine) {
578
713
  const surveys = res.surveys ?? [];
579
714
  if (surveys.length === 0)
580
715
  return;
581
- const seen = (await engine.storage.getJson(SEEN_OFFERS_KEY)) ?? [];
716
+ const seen = (await engine.storage.getJson(SEEN_OFFERS_KEY)) ??
717
+ [];
582
718
  const seenSet = new Set(seen);
583
719
  const localIds = surveys
584
720
  .map((survey) => survey.id)
@@ -587,6 +723,8 @@ export async function pollSurveyOffers(engine) {
587
723
  !engine.locallyHandledSurveyIds.has(survey.id));
588
724
  if (fresh.length === 0 && localIds.length === 0)
589
725
  return;
726
+ if (!presentationIsValid() || !engine.consent.allowsSurvey())
727
+ return;
590
728
  for (const s of fresh) {
591
729
  engine.events.emit('surveyInvite', {
592
730
  surveyId: s.id,
@@ -620,27 +758,82 @@ async function performInstructionPoll(engine) {
620
758
  try {
621
759
  await ensureHydrated(engine);
622
760
  const after = (await engine.storage.getJson(STORAGE_KEYS.instructionCursor)) ?? 0;
623
- const seen = (await engine.storage.getJson(STORAGE_KEYS.seenInstructions)) ?? [];
624
- const seenSet = new Set(seen);
625
- const result = await engine.transport.instructions(after, { anonymousId: engine.identity.get().anonymousId, platform: engine.context.platform(), sdkVersion: engine.context.sdkVersion() });
626
- if (result.instructions.length === 0)
627
- return;
628
- const handledIds = [];
629
- for (const instruction of result.instructions) {
630
- handledIds.push(instruction.id);
631
- if (seenSet.has(instruction.id))
632
- continue;
633
- dispatchInstruction(engine, instruction.type, instruction.payload);
634
- seenSet.add(instruction.id);
635
- await engine.storage.setJsonStrict(STORAGE_KEYS.seenInstructions, [...seenSet].slice(-200));
636
- }
637
- await engine.storage.setJsonStrict(STORAGE_KEYS.instructionCursor, Math.max(after, ...handledIds));
638
- await engine.transport.acknowledgeInstructions(handledIds);
761
+ const generation = engine.resetGeneration;
762
+ const subjectToken = engine.subjectToken;
763
+ const result = await engine.transport.instructions(after, {
764
+ anonymousId: engine.identity.get().anonymousId,
765
+ platform: engine.context.platform(),
766
+ sdkVersion: engine.context.sdkVersion(),
767
+ });
768
+ await consumeInstructions(engine, result.instructions, true, generation, subjectToken);
639
769
  }
640
770
  catch (error) {
641
771
  reportError('instruction poll failed', error);
642
772
  }
643
773
  }
774
+ /** Serializes HTTP responses and polling. Event-scoped responses must never
775
+ * advance the global inbox cursor past older, still-undelivered instructions. */
776
+ export function consumeInstructions(engine, instructions, advanceCursor, generation = engine.resetGeneration, subjectToken = engine.subjectToken) {
777
+ const isCurrent = () => !engine.resetting &&
778
+ engine.resetGeneration === generation &&
779
+ engine.subjectToken === subjectToken;
780
+ const work = (engine.instructionDispatchPromise ?? Promise.resolve())
781
+ .catch(() => undefined)
782
+ .then(async () => {
783
+ if (!instructions.length || !isCurrent())
784
+ return;
785
+ const seen = new Set((await engine.storage.getJson(STORAGE_KEYS.seenInstructions)) ?? []);
786
+ if (!isCurrent())
787
+ return;
788
+ const handled = [];
789
+ for (const instruction of instructions) {
790
+ if (!isCurrent())
791
+ return;
792
+ handled.push(instruction.id);
793
+ if (seen.has(instruction.id))
794
+ continue;
795
+ const payload = instruction.payload;
796
+ if (instruction.type === 'survey.offer' && engine.consent.allowsSurvey() &&
797
+ typeof payload.surveyId === 'string' && typeof payload.name === 'string' &&
798
+ typeof payload.presentationId === 'string' && payload.survey && typeof payload.survey === 'object') {
799
+ // Persist before invoking the host callback or acknowledging delivery.
800
+ // A storage failure leaves the instruction available for retry.
801
+ await engine.surveyInvitations.remember({
802
+ surveyId: payload.surveyId,
803
+ presentationId: payload.presentationId,
804
+ source: typeof payload.source === 'string' ? payload.source : 'triggered',
805
+ survey: { ...payload.survey, presentationId: payload.presentationId },
806
+ attempt: payload.attempt,
807
+ expiresAt: Math.min(Date.parse(instruction.expiresAt), Date.now() + 24 * 60 * 60 * 1000),
808
+ });
809
+ if (!isCurrent())
810
+ return;
811
+ }
812
+ dispatchInstruction(engine, instruction.type, instruction.payload);
813
+ seen.add(instruction.id);
814
+ await engine.storage.setJsonStrict(STORAGE_KEYS.seenInstructions, [...seen].slice(-200));
815
+ }
816
+ if (!isCurrent())
817
+ return;
818
+ // Persist receipts before advancing the cursor. Network retries must not
819
+ // hold the display/ingest lane or lose cap accounting after a restart.
820
+ await engine.mutations.enqueue('instruction-ack', 'essential', {
821
+ ids: handled,
822
+ });
823
+ if (!isCurrent())
824
+ return;
825
+ if (advanceCursor) {
826
+ const after = (await engine.storage.getJson(STORAGE_KEYS.instructionCursor)) ?? 0;
827
+ if (!isCurrent())
828
+ return;
829
+ await engine.storage.setJsonStrict(STORAGE_KEYS.instructionCursor, Math.max(after, ...handled));
830
+ }
831
+ if (isCurrent())
832
+ void flushMutations(engine);
833
+ });
834
+ engine.instructionDispatchPromise = work;
835
+ return work;
836
+ }
644
837
  function dispatchInstruction(engine, type, payload) {
645
838
  if (type === 'prompt.show') {
646
839
  if (!engine.consent.allowsFeedback())
@@ -675,7 +868,8 @@ function dispatchInstruction(engine, type, payload) {
675
868
  if (type === 'survey.offer') {
676
869
  if (!engine.consent.allowsSurvey())
677
870
  return;
678
- if (typeof payload.surveyId !== 'string' || typeof payload.name !== 'string')
871
+ if (typeof payload.surveyId !== 'string' ||
872
+ typeof payload.name !== 'string')
679
873
  return;
680
874
  const triggerEventId = payload.triggerEventId;
681
875
  if (typeof triggerEventId === 'string' &&
@@ -687,6 +881,13 @@ function dispatchInstruction(engine, type, payload) {
687
881
  return;
688
882
  }
689
883
  engine.events.emit('surveyInvite', {
884
+ attempt: payload.attempt,
885
+ survey: payload.survey && typeof payload.survey === 'object'
886
+ ? {
887
+ ...payload.survey,
888
+ presentationId: payload.presentationId,
889
+ }
890
+ : undefined,
690
891
  surveyId: payload.surveyId,
691
892
  name: payload.name,
692
893
  source: typeof payload.source === 'string' ? payload.source : 'triggered',
@@ -760,7 +961,8 @@ export function enqueueAndEvaluate(engine, eventName, properties, purpose = 'ana
760
961
  engine.queue.enqueue(base);
761
962
  engine.userState.recordEvent(eventName, now);
762
963
  evaluateLocally(engine, eventName, base.eventId);
763
- if (engine.queue.size() >= engine.config.flushBatchSize)
964
+ if (needsImmediateDelivery(engine, eventName) ||
965
+ engine.queue.size() >= engine.config.flushBatchSize)
764
966
  void flushNow(engine);
765
967
  else
766
968
  scheduleFlush(engine);
@@ -776,7 +978,8 @@ export function enqueueAndEvaluate(engine, eventName, properties, purpose = 'ana
776
978
  engine.queue.enqueue(withId);
777
979
  engine.userState.recordEvent(eventName, now);
778
980
  evaluateLocally(engine, eventName, withId.eventId);
779
- if (engine.queue.size() >= engine.config.flushBatchSize)
981
+ if (needsImmediateDelivery(engine, eventName) ||
982
+ engine.queue.size() >= engine.config.flushBatchSize)
780
983
  void flushNow(engine);
781
984
  else
782
985
  scheduleFlush(engine);
@@ -806,14 +1009,21 @@ function evaluateLocally(engine, eventName, eventId) {
806
1009
  }
807
1010
  export async function submitResponse(engine, payload, triggerEventName, trackInternal) {
808
1011
  const id = engine.identity.get();
1012
+ const context = engine.context.build(id);
809
1013
  const submitPayload = {
810
1014
  idempotencyKey: generateEventId(),
811
1015
  promptId: payload.promptId,
812
1016
  anonymousId: id.anonymousId,
813
1017
  externalId: id.externalId,
814
- answers: payload.answers.map((a) => ({ questionId: a.questionId, value: a.value })),
1018
+ answers: payload.answers.map((a) => ({
1019
+ questionId: a.questionId,
1020
+ value: a.value,
1021
+ })),
815
1022
  dismissed: payload.dismissed,
816
1023
  latencyMs: payload.latencyMs,
1024
+ platform: context.platform,
1025
+ sdkVersion: context.sdkVersion,
1026
+ appVersion: context.appVersion,
817
1027
  };
818
1028
  engine.events.emit('response', payload);
819
1029
  trackInternal('$feedback_response', {
@@ -837,28 +1047,15 @@ export async function clearAllState(engine) {
837
1047
  pendingAppOpenConsent.delete(engine);
838
1048
  engine.events.emit('resetSurfaces', undefined);
839
1049
  engine.transport.cancelAll();
1050
+ engine.transport.setSubjectToken(null);
840
1051
  await Promise.allSettled([
841
1052
  engine.mutationFlushPromise ?? Promise.resolve(),
842
1053
  engine.flushPromise ?? Promise.resolve(),
843
1054
  engine.sessionPromise ?? Promise.resolve(),
844
1055
  engine.instructionPollPromise ?? Promise.resolve(),
1056
+ engine.instructionDispatchPromise ?? Promise.resolve(),
845
1057
  ]);
846
- // Reset is a local privacy operation and must not wait on network retries.
847
- // Revoke the old credential through an isolated transport so failures cannot
848
- // open the live engine's circuit or delay the new anonymous session.
849
- const subjectToken = engine.subjectToken;
850
- if (subjectToken) {
851
- const revocationTransport = createTransport({
852
- writeKey: engine.config.writeKey,
853
- apiUrl: engine.config.apiUrl,
854
- });
855
- revocationTransport.setSubjectToken(subjectToken);
856
- void revocationTransport.revokeSession().catch((error) => {
857
- debugLog('subject session revoke deferred', {
858
- reason: error instanceof Error ? error.message : String(error),
859
- });
860
- });
861
- }
1058
+ await rememberRevocation(engine);
862
1059
  if (engine.flushTimer) {
863
1060
  clearTimeout(engine.flushTimer);
864
1061
  engine.flushTimer = null;
@@ -875,9 +1072,9 @@ export async function clearAllState(engine) {
875
1072
  engine.userState.clear(),
876
1073
  engine.consent.clear(),
877
1074
  engine.localInstructionDedupe.clear(),
1075
+ engine.surveyInvitations.clear(),
878
1076
  ]);
879
1077
  await engine.storage.clearAll([
880
- STORAGE_KEYS.identity,
881
1078
  STORAGE_KEYS.consent,
882
1079
  STORAGE_KEYS.queue,
883
1080
  STORAGE_KEYS.rulesCache,
@@ -894,6 +1091,7 @@ export async function clearAllState(engine) {
894
1091
  STORAGE_KEYS.pushToken,
895
1092
  SEEN_OFFERS_KEY,
896
1093
  ]);
1094
+ await engine.storage.setJsonStrict(STORAGE_KEYS.subjectToken, null);
897
1095
  engine.subjectToken = null;
898
1096
  engine.transport.setSubjectToken(null);
899
1097
  engine.locallyHandledSurveyIds.clear();
@@ -904,6 +1102,10 @@ export async function clearAllState(engine) {
904
1102
  engine.lastPushRegistrationAt = 0;
905
1103
  lastPollAt = 0;
906
1104
  await engine.identity.hydrate();
907
- await ensureSubjectSession(engine);
1105
+ engine.pendingReset = undefined;
1106
+ identityChanged(engine, 'anonymous');
1107
+ void drainRevocations(engine).catch(error => reportError('session cleanup deferred', error));
1108
+ // Local logout completes independently of network availability.
1109
+ setTimeout(() => { void ensureSubjectSession(engine).catch(error => reportError('anonymous session deferred', error)); }, 0);
908
1110
  }
909
1111
  //# sourceMappingURL=engine.js.map