@goliapkg/sentori-react-native 6.2.1 → 6.4.0

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 (33) hide show
  1. package/android/build.gradle +6 -0
  2. package/android/src/main/java-core/com/sentori/Sentori.kt +33 -4
  3. package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
  4. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +1 -1
  5. package/android/src/main/java-core/com/sentori/SentoriCrashHandler.kt +19 -0
  6. package/android/src/main/java-core/com/sentori/SentoriFirebaseMessagingService.kt +15 -0
  7. package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
  8. package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
  9. package/android/src/main/java-core/com/sentori/SentoriPush.kt +242 -13
  10. package/android/src/main/java-core/com/sentori/SentoriPushNotifications.kt +168 -17
  11. package/android/src/main/java-core/com/sentori/SentoriScope.kt +49 -2
  12. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +95 -2
  13. package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
  14. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +43 -0
  15. package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
  16. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +556 -0
  17. package/ios/core/SentoriAttachment.swift +129 -0
  18. package/ios/core/SentoriCrashHandler.swift +15 -0
  19. package/ios/core/SentoriPushNotifications.swift +8 -0
  20. package/lib/push.d.ts.map +1 -1
  21. package/lib/push.js +155 -1
  22. package/lib/push.js.map +1 -1
  23. package/lib/scope.d.ts +6 -0
  24. package/lib/scope.d.ts.map +1 -1
  25. package/lib/scope.js +45 -1
  26. package/lib/scope.js.map +1 -1
  27. package/lib/transport.js +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/push.test.ts +203 -1
  30. package/src/push.ts +147 -1
  31. package/src/scope.ts +50 -1
  32. package/src/transport.ts +1 -1
  33. package/ios/core/SentoriPush.swift +0 -340
@@ -15,7 +15,7 @@ import {
15
15
  register,
16
16
  } from '../push';
17
17
  import { __resetForTests as resetConfig, setConfig } from '../config';
18
- import { __resetForTests as resetScope } from '../scope';
18
+ import { __resetForTests as resetScope, setUser } from '../scope';
19
19
 
20
20
  const baseConfig = {
21
21
  token: 'st_test',
@@ -202,3 +202,205 @@ describe('push.register', () => {
202
202
  expect(seen).not.toBeNull();
203
203
  });
204
204
  });
205
+
206
+ // A device registers at launch; the person signs in ten seconds
207
+ // later. Until now nothing updated the row, so it carried no user for
208
+ // the life of the install — and a send aimed at that user reached
209
+ // nobody and reported success.
210
+ describe('push registration follows the person', () => {
211
+ /** Record every /v1/push/tokens body, so a test can say what the
212
+ * server was told rather than that it was told something. */
213
+ function recordingFetch(): Array<Record<string, unknown>> {
214
+ const seen: Array<Record<string, unknown>> = [];
215
+ globalThis.fetch = ((_url: string, init?: { body?: string }) => {
216
+ if (init?.body != null) {
217
+ seen.push(JSON.parse(init.body) as Record<string, unknown>);
218
+ }
219
+ return Promise.resolve({
220
+ ok: true,
221
+ status: 202,
222
+ json: () => Promise.resolve({ spToken: 'dev-1', token_id: 'dev-1' }),
223
+ });
224
+ }) as unknown as typeof fetch;
225
+ return seen;
226
+ }
227
+
228
+ /** `user()` hashes on a promise, so the key lands a tick later and
229
+ * the re-registration is sent after that. */
230
+ const settle = () => new Promise((r) => setTimeout(r, 10));
231
+
232
+ beforeEach(() => {
233
+ resetPush();
234
+ resetConfig();
235
+ resetScope();
236
+ setPlatform('ios');
237
+ setConfig(baseConfig);
238
+ __setNativeForTests(grantingNative());
239
+ });
240
+ afterEach(() => {
241
+ resetPush();
242
+ resetConfig();
243
+ resetScope();
244
+ setPlatform(null);
245
+ __setNativeForTests(undefined);
246
+ globalThis.fetch = realFetch;
247
+ });
248
+
249
+ it('sends the identity when the person signs in after registering', async () => {
250
+ const seen = recordingFetch();
251
+ await register();
252
+ expect(seen).toHaveLength(1);
253
+ expect(seen[0]?.userKey).toBeUndefined();
254
+
255
+ setUser({ id: 'usr_123', traits: { plan: 'pro' } });
256
+ await settle();
257
+
258
+ // Two assertions, because either alone passes on a mistake: a
259
+ // count alone passes if the update carries nothing, and a key
260
+ // alone passes if the update never happened and this is still the
261
+ // first request.
262
+ expect(seen).toHaveLength(2);
263
+ expect(typeof seen[1]?.userKey).toBe('string');
264
+ expect(seen[1]?.traits).toEqual({ plan: 'pro' });
265
+ });
266
+
267
+ it('does not send anything when the same person is set again', async () => {
268
+ const seen = recordingFetch();
269
+ await register();
270
+ setUser({ id: 'usr_123', traits: { plan: 'pro' } });
271
+ await settle();
272
+ const after = seen.length;
273
+
274
+ // `user()` is a verb an app may call on every screen. One request
275
+ // per call is not free to a host, and the iron rule is that we are.
276
+ setUser({ id: 'usr_123', traits: { plan: 'pro' } });
277
+ await settle();
278
+ expect(seen).toHaveLength(after);
279
+ });
280
+
281
+ it('clears the traits when the person signs out', async () => {
282
+ const seen = recordingFetch();
283
+ await register();
284
+ setUser({ id: 'usr_123', traits: { plan: 'pro' } });
285
+ await settle();
286
+ setUser(null);
287
+ await settle();
288
+
289
+ const last = seen[seen.length - 1];
290
+ expect(last?.userKey).toBeUndefined();
291
+ // `{}` rather than absent: absent means "keep what is there", so
292
+ // a signed-out device would stay selectable as a pro user.
293
+ expect(last?.traits).toEqual({});
294
+ });
295
+
296
+ it('does not register a device that never registered', async () => {
297
+ const seen = recordingFetch();
298
+ setUser({ id: 'usr_123' });
299
+ await settle();
300
+ expect(seen).toHaveLength(0);
301
+ });
302
+ });
303
+
304
+ // A vendor token is not an address. It rotates — on a reinstall, a
305
+ // restore, cleared app data — and the server keys the row on it, so a
306
+ // rotation wrote a *new* row under a *new* spToken and every backend
307
+ // holding the old one was addressing nothing.
308
+ //
309
+ // The native SDKs were given an installId and a rotation report. This
310
+ // package was not: its registration goes through JS, and the native
311
+ // rotation hook it does have refuses to act on a device the *native*
312
+ // side never registered — which, for a React Native app, is every
313
+ // device.
314
+ describe('push survives the vendor rotating its token', () => {
315
+ function recordingFetch(): Array<Record<string, unknown>> {
316
+ const seen: Array<Record<string, unknown>> = [];
317
+ globalThis.fetch = ((_url: string, init?: { body?: string }) => {
318
+ if (init?.body != null) {
319
+ seen.push(JSON.parse(init.body) as Record<string, unknown>);
320
+ }
321
+ return Promise.resolve({
322
+ ok: true,
323
+ status: 202,
324
+ json: () => Promise.resolve({ spToken: 'dev-1', token_id: 'dev-1' }),
325
+ });
326
+ }) as unknown as typeof fetch;
327
+ return seen;
328
+ }
329
+
330
+ beforeEach(() => {
331
+ resetPush();
332
+ resetConfig();
333
+ resetScope();
334
+ setPlatform('ios');
335
+ setConfig(baseConfig);
336
+ });
337
+ afterEach(() => {
338
+ resetPush();
339
+ resetConfig();
340
+ resetScope();
341
+ setPlatform(null);
342
+ __setNativeForTests(undefined);
343
+ globalThis.fetch = realFetch;
344
+ });
345
+
346
+ it('tells the server which installation this is', async () => {
347
+ __setNativeForTests(grantingNative());
348
+ const seen = recordingFetch();
349
+ await register();
350
+ const installId = seen[0]?.installId;
351
+ expect(typeof installId).toBe('string');
352
+ expect(String(installId).length).toBeGreaterThan(8);
353
+ });
354
+
355
+ it('keeps the same installation across registrations', async () => {
356
+ __setNativeForTests(grantingNative());
357
+ const seen = recordingFetch();
358
+ await register();
359
+ await register();
360
+ expect(seen).toHaveLength(2);
361
+ // Asserting equality alone passes when neither call sent one —
362
+ // `undefined === undefined` — which is the state this test
363
+ // exists to reject. So: a string, and the same string.
364
+ expect(typeof seen[0]?.installId).toBe('string');
365
+ expect(seen[1]?.installId).toBe(seen[0]?.installId);
366
+ });
367
+
368
+ it('reports a token that rotated while the app was running', async () => {
369
+ // The vendor hands back a different token on the second drain,
370
+ // which is what a rotation looks like from here.
371
+ let handed = 0;
372
+ __setNativeForTests(
373
+ grantingNative({
374
+ pushDrainState: () => {
375
+ handed += 1;
376
+ return Promise.resolve({
377
+ notifications: [],
378
+ taps: [],
379
+ token: handed <= 1 ? 'abc123' : 'rotated456',
380
+ });
381
+ },
382
+ }),
383
+ );
384
+ const seen = recordingFetch();
385
+ await register();
386
+ expect(seen).toHaveLength(1);
387
+
388
+ // One turn of the drain loop, which already runs at 1 Hz and
389
+ // already reads the token — it just threw it away.
390
+ await new Promise((r) => setTimeout(r, 1400));
391
+
392
+ expect(seen).toHaveLength(2);
393
+ expect(seen[1]?.nativeToken).toBe('rotated456');
394
+ // Same installation, or the server writes a second row and the
395
+ // rotation has achieved exactly what it used to.
396
+ expect(seen[1]?.installId).toBe(seen[0]?.installId);
397
+ });
398
+
399
+ it('does not report the same token twice', async () => {
400
+ __setNativeForTests(grantingNative());
401
+ const seen = recordingFetch();
402
+ await register();
403
+ await new Promise((r) => setTimeout(r, 2400));
404
+ expect(seen).toHaveLength(1);
405
+ });
406
+ });
package/src/push.ts CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { logger, pushSignal } from '@goliapkg/sentori-core'
25
25
 
26
- import { currentUserKey } from './scope.js'
26
+ import { currentUserKey, currentUserTraits, onIdentityChange } from './scope.js'
27
27
 
28
28
  // AppState is RN-only; we treat it dynamically so the SDK keeps
29
29
  // importing cleanly under Bun / web.
@@ -44,8 +44,25 @@ import {
44
44
  } from './native.js'
45
45
 
46
46
  const STORAGE_KEY = 'sentori.push.ipt'
47
+ /// Which installation this is.
48
+ ///
49
+ /// Minted once and kept, so the server can key the device row on it
50
+ /// rather than on the vendor's token. Without one a rotation writes a
51
+ /// *new* row under a *new* spToken, and every backend holding the old
52
+ /// one is addressing nothing — with nothing to tell it. The native
53
+ /// SDKs were given this; this package registers from JS and was not.
54
+ const INSTALL_KEY = 'sentori.push.install'
47
55
 
48
56
  let _cachedIpt: null | string = null
57
+ // The last token the vendor handed us, kept so a sign-in can re-send
58
+ // the registration without asking the OS for a token again.
59
+ let _lastNativeToken: null | string = null
60
+ let _lastOptions: PushRegisterOptions = {}
61
+ // What the server was last told. A re-registration that would send
62
+ // the same thing is not sent: `user()` is a verb an app may call on
63
+ // every screen, and one HTTP request per call is not free to a host.
64
+ let _lastSentIdentity: string | null = null
65
+ let _installId: null | string = null
49
66
  let _drainInterval: ReturnType<typeof setInterval> | null = null
50
67
  let _appStateSubscription: { remove: () => void } | null = null
51
68
  let _backgrounded = false
@@ -176,9 +193,13 @@ export async function register(opts: PushRegisterOptions = {}): Promise<PushRegi
176
193
  const token = await waitForToken(opts.tokenTimeoutMs ?? 8000)
177
194
  const ipt = await registerWithServer(cfg, token, opts)
178
195
  _cachedIpt = ipt
196
+ _lastNativeToken = token
197
+ _lastOptions = opts
179
198
  void persistIpt(ipt)
180
199
  opts.onToken?.(ipt)
181
200
  bindBufferDrain(opts.onMessage, opts.onTap)
201
+ // From here on, signing in or out updates the row by itself.
202
+ onIdentityChange(reRegisterAfterIdentityChange)
182
203
  return { ok: true, ipt }
183
204
  } catch (e) {
184
205
  const err = e instanceof Error ? e : new Error(String(e))
@@ -199,6 +220,29 @@ function fail(
199
220
  return { ok: false, message, reason }
200
221
  }
201
222
 
223
+ /**
224
+ * Send the registration again because the person changed.
225
+ *
226
+ * Only for a device that has already registered — the vendor token is
227
+ * the evidence, the same rule the native SDKs use for a rotated
228
+ * token. Nothing here throws and nothing here is awaited by the host:
229
+ * `user()` is synchronous and stays that way.
230
+ */
231
+ function reRegisterAfterIdentityChange(): void {
232
+ const token = _lastNativeToken
233
+ const cfg = tryGetRuntimeConfig()
234
+ if (token == null || cfg == null) return
235
+ const identity = JSON.stringify([currentUserKey() ?? null, currentUserTraits() ?? null])
236
+ if (identity === _lastSentIdentity) return
237
+ _lastSentIdentity = identity
238
+ void registerWithServer(cfg, token, _lastOptions).catch((e: unknown) => {
239
+ // A failed update leaves the row pointing at the previous person,
240
+ // so the next change must be allowed to try again.
241
+ _lastSentIdentity = null
242
+ logger.warn('push', 'updating the device after a sign-in failed', e)
243
+ })
244
+ }
245
+
202
246
  /** Internal carrier so the helpers below can name which failure they
203
247
  * are without every one of them returning a result object. */
204
248
  class PushRegisterError extends Error {
@@ -228,6 +272,9 @@ export async function unregister(): Promise<void> {
228
272
  }
229
273
  }
230
274
  nativePushUnregister()
275
+ onIdentityChange(undefined)
276
+ _lastNativeToken = null
277
+ _lastSentIdentity = null
231
278
  _cachedIpt = null
232
279
  void clearPersistedIpt()
233
280
  teardownBufferDrain()
@@ -295,6 +342,10 @@ async function registerWithServer(
295
342
  // `kind`, not `provider` — the server's field name, which this
296
343
  // sent for a year as `provider` and got a 422 for every time.
297
344
  const body: Record<string, unknown> = {
345
+ // Which installation this is. The server keys the row on it, so a
346
+ // rotated token updates this device rather than creating a second
347
+ // one under a new address.
348
+ installId: await installId(),
298
349
  kind: isAndroid ? 'fcm' : 'apns',
299
350
  nativeToken,
300
351
  // The same salted identity hash every event carries, so the
@@ -310,6 +361,12 @@ async function registerWithServer(
310
361
  // out it went nowhere. (Insight asked whether it was stored; it was
311
362
  // not — 2026-08-11.)
312
363
  if (opts.metadata != null) body.metadata = opts.metadata
364
+ // Attributes of the person rather than of the device, kept apart so
365
+ // a build channel called "pro" cannot answer a send aimed at the pro
366
+ // plan. Absent leaves whatever the row already had; `{}` clears it,
367
+ // which is what signing out sends.
368
+ const traits = currentUserTraits()
369
+ if (traits != null) body.traits = traits
313
370
  const res = await fetch(joinUrl(cfg.ingestUrl, '/v1/push/tokens'), {
314
371
  method: 'POST',
315
372
  headers: {
@@ -362,6 +419,45 @@ function teardownBufferDrain(): void {
362
419
  async function pumpOnce(): Promise<void> {
363
420
  const state = await pushDrainState()
364
421
  flushBuffered(state.notifications, state.taps)
422
+ await reportRotationIfAny(state.token)
423
+ }
424
+
425
+ /// Tell the server when the vendor has handed the app a different
426
+ /// token from the one it registered with.
427
+ ///
428
+ /// Both native layers already route a rotation into the *native*
429
+ /// `SentoriPush.handleRotatedToken`, and that refuses to act on a
430
+ /// device the native side never registered — which, for a React
431
+ /// Native app, is every device: registration goes through JS and the
432
+ /// handle lives in AsyncStorage. So the rotation arrived nowhere, and
433
+ /// the server held a dead token until the host next called
434
+ /// `register()`. For an app that stays resident that is not a bounded
435
+ /// wait.
436
+ ///
437
+ /// This is the drain loop that already runs at 1 Hz and already reads
438
+ /// the token; it threw it away. Foreground only, like the rest of the
439
+ /// loop — a rotation while backgrounded is reported on the next tick
440
+ /// after the app comes back, which is bounded.
441
+ async function reportRotationIfAny(token: string | undefined): Promise<void> {
442
+ if (token == null || token === '' || token === _lastNativeToken) return
443
+ // Only a device that has registered: a token arriving for one that
444
+ // never did is not something to act on unasked, which is the rule
445
+ // both native SDKs follow.
446
+ if (_lastNativeToken == null) return
447
+ const cfg = tryGetRuntimeConfig()
448
+ if (cfg == null) return
449
+ const previous = _lastNativeToken
450
+ _lastNativeToken = token
451
+ try {
452
+ const ipt = await registerWithServer(cfg, token, _lastOptions)
453
+ _cachedIpt = ipt
454
+ void persistIpt(ipt)
455
+ } catch (e) {
456
+ // Put it back, or one failed report means the rotation is never
457
+ // mentioned again and the device is unreachable for good.
458
+ _lastNativeToken = previous
459
+ logger.warn('push', 'reporting a rotated token failed', e)
460
+ }
365
461
  }
366
462
 
367
463
  function flushBuffered(
@@ -487,6 +583,51 @@ function startAppStateWatch(): void {
487
583
  }
488
584
  }
489
585
 
586
+ /// This installation's id, minted on first use and kept after.
587
+ ///
588
+ /// Falls back to a module-scoped value when there is no AsyncStorage,
589
+ /// which is the same fallback the handle uses: worse than persistent,
590
+ /// better than nothing, and it still keeps one launch's rotations
591
+ /// pointed at one row.
592
+ async function installId(): Promise<string> {
593
+ if (_installId != null) return _installId
594
+ const storage = await tryAsyncStorage()
595
+ if (storage) {
596
+ try {
597
+ const seen = await storage.getItem(INSTALL_KEY)
598
+ if (typeof seen === 'string' && seen.length > 0) {
599
+ _installId = seen
600
+ return seen
601
+ }
602
+ } catch {
603
+ // An unreadable store is a store with nothing in it.
604
+ }
605
+ }
606
+ const fresh = randomId()
607
+ _installId = fresh
608
+ if (storage) {
609
+ try {
610
+ await storage.setItem(INSTALL_KEY, fresh)
611
+ } catch (e) {
612
+ logger.warn('push', 'AsyncStorage.setItem failed', e)
613
+ }
614
+ }
615
+ return fresh
616
+ }
617
+
618
+ /// A uuid, without requiring one to exist.
619
+ ///
620
+ /// `crypto.randomUUID` is present on newer Hermes and absent on older
621
+ /// ones, and this must not be the line that decides whether push
622
+ /// works on an older engine.
623
+ function randomId(): string {
624
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
625
+ if (typeof c?.randomUUID === 'function') return c.randomUUID()
626
+ let out = ''
627
+ for (let i = 0; i < 32; i++) out += Math.floor(Math.random() * 16).toString(16)
628
+ return out
629
+ }
630
+
490
631
  async function persistIpt(ipt: string): Promise<void> {
491
632
  const storage = await tryAsyncStorage()
492
633
  if (!storage) return
@@ -542,9 +683,14 @@ function joinUrl(base: string, path: string): string {
542
683
  * into the next. Production code paths must not call this. */
543
684
  export function __resetForTests(): void {
544
685
  _cachedIpt = null
686
+ _installId = null
545
687
  _onMessage = undefined
546
688
  _onTap = undefined
547
689
  _ackQueue = []
690
+ _lastNativeToken = null
691
+ _lastOptions = {}
692
+ _lastSentIdentity = null
693
+ onIdentityChange(undefined)
548
694
  teardownBufferDrain()
549
695
  }
550
696
 
package/src/scope.ts CHANGED
@@ -9,12 +9,50 @@ let _user: User | null = null;
9
9
  let _userKey: string | undefined;
10
10
  let _context: Record<string, unknown> = {};
11
11
  let _hashGeneration = 0;
12
+ let _traits: Record<string, unknown> | undefined;
13
+
14
+ // What to tell when the person changes.
15
+ //
16
+ // The device row carries the identity, and until now nothing updated
17
+ // it after registration: an app that registers for push at launch and
18
+ // signs in ten seconds later — which is every app with a login screen
19
+ // — held a row with no user on it for the life of the install. A send
20
+ // aimed at that user reached nobody and reported success.
21
+ //
22
+ // A callback rather than a direct call because push.ts already imports
23
+ // this file; the arrow has to point one way.
24
+ let _onIdentityChange: (() => void) | undefined;
25
+
26
+ /** Register interest in identity changes. Only push does. */
27
+ export const onIdentityChange = (fn: (() => void) | undefined): void => {
28
+ _onIdentityChange = fn;
29
+ };
30
+
31
+ const announce = (): void => {
32
+ try {
33
+ _onIdentityChange?.();
34
+ } catch {
35
+ // NEVER rule: whatever the listener does, user() returns.
36
+ }
37
+ };
12
38
 
13
39
  export const setUser = safeFn('user', function setUser(u: User | null): void {
14
40
  _user = u;
15
41
  _userKey = undefined;
42
+ // A call to `user()` describes the person completely, so one made
43
+ // without traits means they have none — not "leave the last ones".
44
+ // Absent and empty are different on the wire: absent keeps what the
45
+ // row has, and a signed-out device that kept them would still be
46
+ // selectable as whoever just left.
47
+ _traits = u?.traits == null ? {} : { ...u.traits };
16
48
  const generation = ++_hashGeneration;
17
- if (u === null) return;
49
+ if (u === null) {
50
+ // Signing out is an identity change too, and the one where a
51
+ // stale row matters most: the device would otherwise keep
52
+ // answering to the person who just left.
53
+ announce();
54
+ return;
55
+ }
18
56
  // The wire carries only a salted hash — raw identity never leaves
19
57
  // the device (breadth × depth needs distinctness, not identity).
20
58
  // Hashing is async (WebCrypto); the verb stays synchronous and the
@@ -24,6 +62,9 @@ export const setUser = safeFn('user', function setUser(u: User | null): void {
24
62
  .then((hashes) => {
25
63
  if (generation === _hashGeneration) {
26
64
  _userKey = hashes.id ?? hashes.email;
65
+ // Announced after the hash lands, not before: the whole point
66
+ // is to send the new key, and the key is what arrives late.
67
+ announce();
27
68
  }
28
69
  })
29
70
  .catch(() => {
@@ -39,12 +80,20 @@ export const patchContext = safeFn('context', function patchContext(
39
80
 
40
81
  export const currentUserKey = (): string | undefined => _userKey;
41
82
 
83
+ /** The person's attributes, for the device row. Undefined when the
84
+ * host has not set any — which is different from `{}`, the value that
85
+ * clears them. */
86
+ export const currentUserTraits = (): Record<string, unknown> | undefined =>
87
+ _traits === undefined ? undefined : { ..._traits };
88
+
42
89
  export const currentContext = (): Record<string, unknown> | undefined =>
43
90
  Object.keys(_context).length > 0 ? { ..._context } : undefined;
44
91
 
45
92
  export const __resetForTests = (): void => {
46
93
  _user = null;
47
94
  _userKey = undefined;
95
+ _traits = undefined;
48
96
  _context = {};
97
+ _onIdentityChange = undefined;
49
98
  _hashGeneration++;
50
99
  };
package/src/transport.ts CHANGED
@@ -20,7 +20,7 @@ const STORAGE_KEY = '@sentori/pending';
20
20
  const MAX_PERSISTED = 1000;
21
21
 
22
22
  // Pinned to package.json by a test — bump both together.
23
- const SDK_VERSION = '6.2.1';
23
+ const SDK_VERSION = '6.4.0';
24
24
 
25
25
  let _queue: WireEvent[] = [];
26
26
  let _assertStats = new Map<string, AssertStat>();