@layers/amba-react-native 4.2.2 → 4.3.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.
package/dist/index.js CHANGED
@@ -62,6 +62,20 @@ var AsyncStoragePersistence = class {
62
62
  if (v !== null) this.memoryCache.set(fullKey, v);
63
63
  return v;
64
64
  }
65
+ /**
66
+ * Read `key` from storage again, past the in-memory copy, and keep what
67
+ * it holds now. For a record whose first read came back incomplete (see
68
+ * `IdentityStore.recordStillUnreadable`).
69
+ */
70
+ async reread(key) {
71
+ const fullKey = `${this.prefix}:${key}`;
72
+ const storage = await getStorage();
73
+ if (!storage) return this.memoryCache.get(fullKey) ?? null;
74
+ const v = await storage.getItem(fullKey);
75
+ if (v !== null) this.memoryCache.set(fullKey, v);
76
+ else this.memoryCache.delete(fullKey);
77
+ return v;
78
+ }
65
79
  async write(key, value) {
66
80
  const fullKey = `${this.prefix}:${key}`;
67
81
  const storage = await getStorage();
@@ -613,11 +627,42 @@ var IdentityStore = class IdentityStore {
613
627
  installId;
614
628
  /** Whether the stored record carries `installId`. */
615
629
  installIdStored;
616
- constructor(persistence, seed, vault, plaintext, vaultUnreadable, seq) {
630
+ /** See `IdentitySnapshot.signInRequired`. */
631
+ signInRequired;
632
+ /**
633
+ * Advanced by every committed write that installs or ends a session (a
634
+ * sign-in, a sign-out, an ended session). A refresh or a link keeps it.
635
+ */
636
+ generation = 0;
637
+ /** Set by `dispose`: the store commits and persists nothing after it. */
638
+ disposed = false;
639
+ /**
640
+ * Set while the record's credential part could not be read (see
641
+ * `PersistedIdentity.credentials_unreadable`). Writes wait for a read
642
+ * that succeeds, so a session this launch never loaded is not replaced.
643
+ */
644
+ recordUnreadable;
645
+ /**
646
+ * Set while the anonymous id is a stand-in for one the unreadable
647
+ * credential part holds. It is never persisted.
648
+ */
649
+ temporaryAnonymousId;
650
+ /** See `onStoredSessionLoaded`. */
651
+ storedSessionLoaded = null;
652
+ /** See `onAnonymousIdKnown`. */
653
+ anonymousIdKnown = null;
654
+ /** The refresh token the store held once created (see `continuesLaunchSession`). */
655
+ launchRefreshToken = null;
656
+ /** Sign-ins committed since the store was created. */
657
+ signInsSinceLaunch = 0;
658
+ constructor(persistence, seed, vault, plaintext, vaultUnreadable, seq, recordUnreadable, temporaryAnonymousId) {
617
659
  this.persistence = persistence;
618
660
  this.vault = vault;
619
661
  this.plaintext = plaintext;
620
662
  this.vaultUnreadable = vaultUnreadable;
663
+ this.recordUnreadable = recordUnreadable;
664
+ this.temporaryAnonymousId = temporaryAnonymousId;
665
+ this.signInRequired = seed.sign_in_required;
621
666
  this.seq = seq;
622
667
  this.installId = seed.install_id ?? uuidv4();
623
668
  this.installIdStored = seed.install_id !== null;
@@ -653,21 +698,17 @@ var IdentityStore = class IdentityStore {
653
698
  assertSecureRandomAvailable();
654
699
  const raw = await persistence.read(IDENTITY_KEY);
655
700
  let recordPresent = false;
701
+ let recordUnreadable = false;
702
+ let temporaryAnonymousId = false;
656
703
  let seed;
657
704
  if (raw) try {
658
705
  const parsed = JSON.parse(raw);
659
- seed = {
660
- anonymous_id: parsed.anonymous_id ?? uuidv4(),
661
- app_user_id: parsed.app_user_id ?? null,
662
- session_token: parsed.session_token ?? null,
663
- refresh_token: parsed.refresh_token ?? null,
664
- expires_at: parsed.expires_at ?? null,
665
- anonymous_session: typeof parsed.anonymous_session === "boolean" ? parsed.anonymous_session : null,
666
- write_seq: seqOf(parsed.write_seq) ?? 0,
667
- vault_seq: parsed.vault_seq === void 0 ? 0 : seqOf(parsed.vault_seq),
668
- install_id: typeof parsed.install_id === "string" ? parsed.install_id : null
669
- };
706
+ seed = seedFrom(parsed);
670
707
  recordPresent = typeof parsed.anonymous_id === "string";
708
+ if (parsed.credentials_unreadable === true) {
709
+ recordUnreadable = true;
710
+ temporaryAnonymousId = !recordPresent;
711
+ }
671
712
  } catch {
672
713
  seed = freshSeed();
673
714
  }
@@ -682,7 +723,8 @@ var IdentityStore = class IdentityStore {
682
723
  } : null;
683
724
  let vaultReadable = true;
684
725
  let seq = seed.write_seq;
685
- if (vault) {
726
+ if (vault && recordUnreadable) vaultReadable = false;
727
+ else if (vault) {
686
728
  let fromVault = null;
687
729
  try {
688
730
  fromVault = await vault.read();
@@ -701,17 +743,10 @@ var IdentityStore = class IdentityStore {
701
743
  seed.vault_seq = null;
702
744
  }
703
745
  }
704
- if (vaultReadable && seed.session_token === null && seed.refresh_token === null) {
705
- if (seed.app_user_id !== null && seed.anonymous_session === false) seed.anonymous_id = uuidv4();
706
- seed.app_user_id = null;
707
- seed.anonymous_session = null;
708
- }
709
- if (isExpired(seed.expires_at)) {
710
- seed.session_token = null;
711
- seed.expires_at = null;
712
- }
713
- const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable, seq);
746
+ settleSeed(seed, vaultReadable && !recordUnreadable);
747
+ const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable, seq, recordUnreadable, temporaryAnonymousId);
714
748
  await store.flush({ hydrating: true });
749
+ store.launchRefreshToken = store.refreshToken;
715
750
  return store;
716
751
  }
717
752
  snapshot() {
@@ -721,9 +756,184 @@ var IdentityStore = class IdentityStore {
721
756
  sessionToken: this.sessionToken,
722
757
  refreshToken: this.refreshToken,
723
758
  expiresAt: this.expiresAt,
724
- anonymousSession: this.anonymousSession
759
+ anonymousSession: this.anonymousSession,
760
+ signInRequired: this.signInRequired
725
761
  };
726
762
  }
763
+ /** The current generation (see `CommitFence`). */
764
+ getGeneration() {
765
+ return this.generation;
766
+ }
767
+ /**
768
+ * Whether the store still holds the session it was created with, renewed
769
+ * or ended since but never replaced by a sign-in: it was created holding
770
+ * `refreshToken`, and no sign-in has committed since.
771
+ */
772
+ continuesLaunchSession(refreshToken) {
773
+ return this.launchRefreshToken === refreshToken && this.signInsSinceLaunch === 0;
774
+ }
775
+ /** See `IdentitySnapshot.signInRequired`. */
776
+ isSignInRequired() {
777
+ return this.signInRequired;
778
+ }
779
+ /**
780
+ * Detach the store (the SDK was configured again). Nothing commits or
781
+ * persists after this; the promise settles once the writes already under
782
+ * way have landed.
783
+ */
784
+ dispose() {
785
+ this.disposed = true;
786
+ return this.flushChain;
787
+ }
788
+ /** Undo `dispose` (a `configure()` that failed before it replaced this store). */
789
+ reattach() {
790
+ this.disposed = false;
791
+ }
792
+ /** Whether a write under `fence` may commit now. */
793
+ mayCommit(fence) {
794
+ if (this.disposed) return false;
795
+ return fence.ifGeneration === void 0 || fence.ifGeneration === this.generation;
796
+ }
797
+ /** Commit bookkeeping: advance the generation unless the fence keeps it. */
798
+ committed(fence) {
799
+ if (fence.advance !== false) this.generation += 1;
800
+ return this.generation;
801
+ }
802
+ /**
803
+ * Called when a stored session that couldn't be read at launch (a locked
804
+ * vault or credential record) is loaded after it, so the SDK can tell its
805
+ * subscribers. A session loaded while the store is created is not
806
+ * reported.
807
+ */
808
+ onStoredSessionLoaded(listener) {
809
+ this.storedSessionLoaded = listener;
810
+ }
811
+ /**
812
+ * Called when the stand-in anonymous id gives way to the device's own (see
813
+ * `temporaryAnonymousId`): a later read found it, or a sign-in gave one.
814
+ * A sign-out that rotates the id is not reported: it starts over.
815
+ */
816
+ onAnonymousIdKnown(listener) {
817
+ this.anonymousIdKnown = listener;
818
+ }
819
+ /** Whether the anonymous id is a stand-in (see `temporaryAnonymousId`). */
820
+ hasStandInAnonymousId() {
821
+ return this.temporaryAnonymousId;
822
+ }
823
+ /** The stand-in gives way to a known anonymous id (see `onAnonymousIdKnown`). */
824
+ anonymousIdNowKnown() {
825
+ if (!this.temporaryAnonymousId) return;
826
+ this.temporaryAnonymousId = false;
827
+ if (!this.disposed) this.anonymousIdKnown?.();
828
+ }
829
+ /** Report a session a late read loaded (see `onStoredSessionLoaded`). */
830
+ reportStoredSession() {
831
+ if (this.signInsSinceLaunch === 0 && this.launchRefreshToken === null) this.launchRefreshToken = this.refreshToken;
832
+ if (this.disposed) return;
833
+ if (this.sessionToken === null && this.refreshToken === null) return;
834
+ this.storedSessionLoaded?.();
835
+ }
836
+ /**
837
+ * Whether the record's credential part still can't be read (see
838
+ * `PersistedIdentity.credentials_unreadable`). When the launch read
839
+ * failed, the record is read again first. A session it holds is loaded
840
+ * when nothing has replaced the launch state since; the stored anonymous
841
+ * id replaces the stand-in either way.
842
+ */
843
+ async recordStillUnreadable() {
844
+ if (!this.recordUnreadable) return false;
845
+ let parsed = null;
846
+ try {
847
+ const raw = await this.persistence.reread(IDENTITY_KEY);
848
+ parsed = raw ? JSON.parse(raw) : null;
849
+ } catch {
850
+ return true;
851
+ }
852
+ if (!this.recordUnreadable) return false;
853
+ if (parsed?.credentials_unreadable === true) return true;
854
+ this.recordUnreadable = false;
855
+ const storedAnonymousId = parsed && typeof parsed.anonymous_id === "string" ? parsed.anonymous_id : null;
856
+ const untouched = this.generation === 0 && this.sessionToken === null && this.refreshToken === null;
857
+ if (parsed && storedAnonymousId !== null && untouched) {
858
+ if (!this.vault) {
859
+ const stored$1 = seedFrom(parsed);
860
+ settleSeed(stored$1, true);
861
+ this.anonymousId = stored$1.anonymous_id;
862
+ this.appUserId = stored$1.app_user_id;
863
+ this.sessionToken = stored$1.session_token;
864
+ this.refreshToken = stored$1.refresh_token;
865
+ this.expiresAt = stored$1.expires_at;
866
+ this.anonymousSession = stored$1.anonymous_session;
867
+ this.signInRequired = stored$1.sign_in_required;
868
+ this.seq = Math.max(this.seq, stored$1.write_seq);
869
+ this.reportStoredSession();
870
+ this.anonymousIdNowKnown();
871
+ return false;
872
+ }
873
+ const stored = seedFrom(parsed);
874
+ this.anonymousId = stored.anonymous_id;
875
+ const wasStandIn = this.temporaryAnonymousId;
876
+ this.temporaryAnonymousId = false;
877
+ this.appUserId = stored.app_user_id;
878
+ this.anonymousSession = stored.anonymous_session;
879
+ this.signInRequired = stored.sign_in_required;
880
+ this.recordSeq = stored.write_seq;
881
+ this.vaultSeq = stored.vault_seq;
882
+ this.seq = Math.max(this.seq, stored.write_seq);
883
+ await this.vaultStillUnreadable();
884
+ if (this.sessionToken === null && this.refreshToken === null && (stored.session_token !== null || stored.refresh_token !== null)) {
885
+ this.plaintext = {
886
+ appUserId: stored.app_user_id,
887
+ slice: {
888
+ session_token: stored.session_token,
889
+ refresh_token: stored.refresh_token,
890
+ expires_at: stored.expires_at
891
+ }
892
+ };
893
+ const expired = isExpired(stored.expires_at);
894
+ this.sessionToken = expired ? null : stored.session_token;
895
+ this.refreshToken = stored.refresh_token;
896
+ this.expiresAt = expired ? null : stored.expires_at;
897
+ this.reportStoredSession();
898
+ }
899
+ if (!this.vaultUnreadable && this.sessionToken === null && this.refreshToken === null) {
900
+ if (this.appUserId !== null && this.anonymousSession === false) this.anonymousId = uuidv4();
901
+ this.appUserId = null;
902
+ this.anonymousSession = null;
903
+ }
904
+ if (wasStandIn && !this.disposed) this.anonymousIdKnown?.();
905
+ return false;
906
+ }
907
+ if (this.temporaryAnonymousId && storedAnonymousId !== null) this.anonymousId = storedAnonymousId;
908
+ this.anonymousIdNowKnown();
909
+ return false;
910
+ }
911
+ /**
912
+ * Whether the stored session can't be read right now: the record's
913
+ * credential part (see `recordStillUnreadable`) or the vault (see
914
+ * `vaultStillUnreadable`). The record is read first: it binds the entry.
915
+ */
916
+ async credentialsStillUnreadable() {
917
+ if (await this.recordStillUnreadable()) return true;
918
+ return this.vaultStillUnreadable();
919
+ }
920
+ /**
921
+ * Whether the stored credentials were unreadable at the last attempt. A
922
+ * call that needs a session reports `SECURE_STORE_UNAVAILABLE` then.
923
+ */
924
+ credentialsUnknown() {
925
+ return this.recordUnreadable || this.vaultUnreadable;
926
+ }
927
+ /**
928
+ * Whether the anonymous id is still a stand-in after the record is read
929
+ * again (see `temporaryAnonymousId`). Anything sent under it would belong
930
+ * to nobody.
931
+ */
932
+ async anonymousIdStillUnknown() {
933
+ if (!this.temporaryAnonymousId) return false;
934
+ await this.recordStillUnreadable();
935
+ return this.temporaryAnonymousId;
936
+ }
727
937
  /**
728
938
  * Whether the vault still can't be read. When the launch read failed, the
729
939
  * vault is read again first, and a session it holds for this install is
@@ -732,6 +942,7 @@ var IdentityStore = class IdentityStore {
732
942
  */
733
943
  async vaultStillUnreadable() {
734
944
  if (!this.vault || !this.vaultUnreadable) return false;
945
+ if (this.recordUnreadable) return true;
735
946
  let stored;
736
947
  try {
737
948
  stored = await this.vault.read();
@@ -740,7 +951,10 @@ var IdentityStore = class IdentityStore {
740
951
  }
741
952
  this.vaultUnreadable = false;
742
953
  this.noteStoredSeq(stored);
743
- if (this.sessionToken === null && this.refreshToken === null) this.adoptStored(stored);
954
+ if (this.sessionToken === null && this.refreshToken === null) {
955
+ this.adoptStored(stored);
956
+ this.reportStoredSession();
957
+ }
744
958
  return false;
745
959
  }
746
960
  /** Keep the next sequence number above an entry read from the vault. */
@@ -777,30 +991,51 @@ var IdentityStore = class IdentityStore {
777
991
  /**
778
992
  * Store a session. `anonymous` records whether it is a purely anonymous
779
993
  * one (true only for `/v1/client/auth/anonymous`); omit it to keep the
780
- * current value (a token refresh of the same session).
994
+ * current value (a token refresh of the same session). A stored session
995
+ * ends a pending `signInRequired`.
996
+ *
997
+ * Resolves the generation the write committed at, or null when the fence
998
+ * refused it (nothing changed).
781
999
  */
782
- async setSession(args) {
1000
+ async setSession(args, fence = {}) {
1001
+ if (!this.mayCommit(fence)) return null;
783
1002
  this.appUserId = args.appUserId;
784
1003
  this.sessionToken = args.sessionToken;
785
1004
  this.refreshToken = args.refreshToken;
786
1005
  this.expiresAt = args.expiresAt;
787
1006
  if (args.anonymous !== void 0) this.anonymousSession = args.anonymous;
1007
+ this.signInRequired = false;
1008
+ if (fence.advance !== false) this.signInsSinceLaunch += 1;
1009
+ const generation = this.committed(fence);
788
1010
  await this.flush();
1011
+ return generation;
789
1012
  }
790
- /** The current session gained an identity (a link). */
791
- async markIdentified() {
792
- if (this.anonymousSession === false) return;
1013
+ /**
1014
+ * The current session gained an identity (a link). False when the fence
1015
+ * refused the write.
1016
+ */
1017
+ async markIdentified(fence = {}) {
1018
+ if (!this.mayCommit(fence)) return false;
1019
+ if (this.anonymousSession === false) return true;
793
1020
  this.anonymousSession = false;
794
1021
  await this.flush();
1022
+ return true;
795
1023
  }
796
1024
  /**
797
1025
  * Adopt the anonymous id the server holds for this install's user: the id
798
1026
  * an anonymous sign-in answered with, or the fresh id a link gave the user.
1027
+ * False when the fence refused the write.
799
1028
  */
800
- async setAnonymousId(anonymousId) {
801
- if (anonymousId === this.anonymousId) return;
802
- this.anonymousId = anonymousId;
803
- await this.flush();
1029
+ async setAnonymousId(anonymousId, fence = {}) {
1030
+ if (!this.mayCommit(fence)) return false;
1031
+ if (anonymousId !== this.anonymousId) {
1032
+ this.anonymousId = anonymousId;
1033
+ this.anonymousIdNowKnown();
1034
+ await this.flush();
1035
+ return true;
1036
+ }
1037
+ this.anonymousIdNowKnown();
1038
+ return true;
804
1039
  }
805
1040
  /**
806
1041
  * End the session. The anonymous id is rotated when `rotateAnonymousId`
@@ -812,16 +1047,24 @@ var IdentityStore = class IdentityStore {
812
1047
  * anonymous sign-in).
813
1048
  */
814
1049
  async clearSession(rotateAnonymousId, opts = {}) {
1050
+ if (!this.mayCommit({ ifGeneration: opts.ifGeneration })) return false;
815
1051
  const hadSession = this.sessionToken !== null || this.refreshToken !== null;
1052
+ const unreadSession = this.credentialsUnknown() && this.appUserId !== null;
1053
+ if (opts.unlessSignedInSince !== void 0 && opts.unlessSignedInSince !== this.generation && hadSession) return false;
816
1054
  const identified = this.anonymousSession === false || this.anonymousSession === null && !opts.keepIdWhenKindUnknown;
817
- const nextAnonymousId = rotateAnonymousId || hadSession && identified ? uuidv4() : this.anonymousId;
1055
+ const rotate = rotateAnonymousId || (hadSession || unreadSession) && identified;
1056
+ const nextAnonymousId = rotate ? uuidv4() : this.anonymousId;
1057
+ if (rotate) this.temporaryAnonymousId = false;
818
1058
  this.appUserId = null;
819
1059
  this.sessionToken = null;
820
1060
  this.refreshToken = null;
821
1061
  this.expiresAt = null;
822
1062
  this.anonymousSession = null;
1063
+ this.signInRequired = opts.signInRequired === true;
823
1064
  this.anonymousId = nextAnonymousId;
1065
+ this.committed({});
824
1066
  await this.flush({ signingOut: true });
1067
+ return true;
825
1068
  }
826
1069
  /**
827
1070
  * Persist the identity. `hydrating` marks the write that follows `create`:
@@ -831,11 +1074,13 @@ var IdentityStore = class IdentityStore {
831
1074
  * to a new anonymous id so the entry left behind no longer belongs to it.
832
1075
  */
833
1076
  flush(opts) {
1077
+ if (this.disposed) return Promise.resolve();
834
1078
  const run = this.flushChain.then(() => this.flushNow(opts));
835
1079
  this.flushChain = run.catch(() => void 0);
836
1080
  return run;
837
1081
  }
838
1082
  async flushNow(opts) {
1083
+ if (await this.recordStillUnreadable() && this.generation === 0) return;
839
1084
  if (!this.vault) {
840
1085
  const seq$1 = ++this.seq;
841
1086
  const recordSeq$1 = opts?.hydrating ? this.recordSeq : seq$1;
@@ -846,9 +1091,11 @@ var IdentityStore = class IdentityStore {
846
1091
  refresh_token: this.refreshToken,
847
1092
  expires_at: this.expiresAt,
848
1093
  anonymous_session: this.anonymousSession,
1094
+ sign_in_required: this.signInRequired,
849
1095
  write_seq: recordSeq$1,
850
1096
  vault_seq: null,
851
- install_id: this.installId
1097
+ install_id: this.installId,
1098
+ ...this.temporaryAnonymousId ? { anonymous_id_unknown: true } : {}
852
1099
  };
853
1100
  await this.persistence.write(IDENTITY_KEY, JSON.stringify(payload));
854
1101
  this.recordSeq = recordSeq$1;
@@ -927,9 +1174,11 @@ var IdentityStore = class IdentityStore {
927
1174
  refresh_token: keep?.refresh_token ?? null,
928
1175
  expires_at: keep?.expires_at ?? null,
929
1176
  anonymous_session: this.anonymousSession,
1177
+ sign_in_required: this.signInRequired,
930
1178
  write_seq: recordSeq,
931
1179
  vault_seq: this.vaultSeq,
932
- install_id: this.installId
1180
+ install_id: this.installId,
1181
+ ...this.temporaryAnonymousId ? { anonymous_id_unknown: true } : {}
933
1182
  };
934
1183
  }
935
1184
  };
@@ -941,11 +1190,47 @@ function freshSeed() {
941
1190
  refresh_token: null,
942
1191
  expires_at: null,
943
1192
  anonymous_session: null,
1193
+ sign_in_required: false,
944
1194
  write_seq: 0,
945
1195
  vault_seq: null,
946
1196
  install_id: null
947
1197
  };
948
1198
  }
1199
+ /** The identity a parsed record describes, with defaults for missing fields. */
1200
+ function seedFrom(parsed) {
1201
+ return {
1202
+ anonymous_id: parsed.anonymous_id ?? uuidv4(),
1203
+ app_user_id: parsed.app_user_id ?? null,
1204
+ session_token: parsed.session_token ?? null,
1205
+ refresh_token: parsed.refresh_token ?? null,
1206
+ expires_at: parsed.expires_at ?? null,
1207
+ anonymous_session: typeof parsed.anonymous_session === "boolean" ? parsed.anonymous_session : null,
1208
+ sign_in_required: parsed.sign_in_required === true,
1209
+ write_seq: seqOf(parsed.write_seq) ?? 0,
1210
+ vault_seq: parsed.vault_seq === void 0 ? 0 : seqOf(parsed.vault_seq),
1211
+ install_id: typeof parsed.install_id === "string" ? parsed.install_id : null
1212
+ };
1213
+ }
1214
+ /**
1215
+ * Settle a hydrated identity. With the credentials known (`readable`), a
1216
+ * user id with no token to act on is stale (memory storage after a
1217
+ * relaunch, a vault entry that was discarded): nobody is signed in, and the
1218
+ * session kind goes with it. A session that ended this way after it gained
1219
+ * an identity rotates the anonymous id, as `clearSession` would. An expired
1220
+ * session token is dropped; its refresh token stays so the session can be
1221
+ * renewed.
1222
+ */
1223
+ function settleSeed(seed, readable) {
1224
+ if (readable && seed.session_token === null && seed.refresh_token === null) {
1225
+ if (seed.app_user_id !== null && seed.anonymous_session === false) seed.anonymous_id = uuidv4();
1226
+ seed.app_user_id = null;
1227
+ seed.anonymous_session = null;
1228
+ }
1229
+ if (isExpired(seed.expires_at)) {
1230
+ seed.session_token = null;
1231
+ seed.expires_at = null;
1232
+ }
1233
+ }
949
1234
 
950
1235
  //#endregion
951
1236
  //#region src/token-storage.ts
@@ -1638,14 +1923,18 @@ var EventBatchQueue = class {
1638
1923
  if (this.events.length) await this.store.set(this.storageKey, JSON.stringify(this.events));
1639
1924
  else await this.store.delete(this.storageKey);
1640
1925
  }
1641
- enqueue(event, properties, telemetry) {
1926
+ /**
1927
+ * Queue an event. `timestamp` is when it happened, for an event that waited
1928
+ * elsewhere before reaching the queue; it defaults to now.
1929
+ */
1930
+ enqueue(event, properties, telemetry, timestamp) {
1642
1931
  if (this.disposed) return Promise.reject(/* @__PURE__ */ new Error("Amba event queue disposed"));
1643
1932
  const id = this.identity();
1644
1933
  const item = {
1645
1934
  event,
1646
1935
  properties,
1647
1936
  telemetry,
1648
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1937
+ timestamp: timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
1649
1938
  idempotency_key: newIdempotencyKey(),
1650
1939
  userId: id.sessionToken ? id.userId : null,
1651
1940
  anonymousId: id.anonymousId
@@ -2780,7 +3069,9 @@ function delay(ms) {
2780
3069
  //#endregion
2781
3070
  //#region src/index.ts
2782
3071
  /** SDK semver — burned in at build time. Bump alongside package.json. */
2783
- const SDK_VERSION = "4.2.2";
3072
+ const SDK_VERSION = "4.3.0";
3073
+ /** The most events held at once; the oldest goes first past it. */
3074
+ const MAX_HELD_EVENTS = 500;
2784
3075
  /**
2785
3076
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
2786
3077
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -2797,6 +3088,23 @@ let state = null;
2797
3088
  * instead of disabling the now-active outbox.
2798
3089
  */
2799
3090
  let _offlineEnableToken = 0;
3091
+ /** The `configure()` running now, if any (see `liveState`). */
3092
+ let configuring = null;
3093
+ /** Serializes `configure()` calls, so each detaches the one before it. */
3094
+ let configureChain = Promise.resolve();
3095
+ /**
3096
+ * The state calls act on. While a `configure()` is running, waits for it,
3097
+ * so a sign-in, a link or a sign-out made meanwhile lands on the state it
3098
+ * installs (or on the earlier one, when it fails).
3099
+ */
3100
+ async function liveState() {
3101
+ while (configuring) await configuring.catch(() => void 0);
3102
+ return getState();
3103
+ }
3104
+ /** Whether two states talk to the same project with the same stored identity. */
3105
+ function sameIdentityTarget(a, b) {
3106
+ return a.projectId === b.projectId && a.clientKey === b.clientKey && a.baseUrl === b.baseUrl && a.persistence.prefix === b.persistence.prefix;
3107
+ }
2800
3108
  function getState() {
2801
3109
  if (!state) throw new AmbaApiError("NOT_INITIALIZED", "@layers/amba-react-native: SDK not configured. Call Amba.configure({ projectId, clientKey }) at app start.");
2802
3110
  return state;
@@ -2889,10 +3197,16 @@ function snapshotSession(user) {
2889
3197
  expiresAt: ""
2890
3198
  };
2891
3199
  }
2892
- function notifyAuthSubscribers(session) {
3200
+ /**
3201
+ * Tell `onAuthStateChange` subscribers about an auth change of `state_`.
3202
+ * A state a later `configure()` replaced tells no one.
3203
+ */
3204
+ function notifyAuthSubscribers(state_, session, reason) {
3205
+ if (state_ !== state || state_.disposed) return;
2893
3206
  if (session === null) experimentReporter.reset();
3207
+ const event = { reason };
2894
3208
  for (const cb of authSubscribers) try {
2895
- cb(session);
3209
+ cb(session, event);
2896
3210
  } catch (err) {
2897
3211
  if (typeof console !== "undefined") console.error("[amba] onAuthStateChange callback threw", err);
2898
3212
  }
@@ -2949,21 +3263,50 @@ function normalizeAuthResult(result) {
2949
3263
  user: normalizeUserAuthProviders(result.user)
2950
3264
  };
2951
3265
  }
2952
- async function notifyFromAuthResult(result) {
2953
- notifyAuthSubscribers(snapshotSession(result.user));
3266
+ function notifyFromAuthResult(state_, result, reason) {
3267
+ notifyAuthSubscribers(state_, snapshotSession(result.user), reason);
3268
+ }
3269
+ /** A call needs a session while the user must sign in again. */
3270
+ function signInRequiredError() {
3271
+ return new AmbaApiError("SIGN_IN_REQUIRED", "the session ended and the user must sign in again; call a signIn* method, or Amba.auth.signInAnonymously() to start a new anonymous user");
3272
+ }
3273
+ function secureStoreUnavailableError() {
3274
+ return new AmbaApiError("SECURE_STORE_UNAVAILABLE", "the stored session can't be read yet (the device may be locked); retry once it is unlocked");
3275
+ }
3276
+ function sessionChangedError(message) {
3277
+ return new AmbaApiError("SESSION_CHANGED", message);
3278
+ }
3279
+ function isSessionChanged(err) {
3280
+ return err instanceof AmbaApiError && err.code === "SESSION_CHANGED";
3281
+ }
3282
+ /**
3283
+ * The stored session token, for a call that needs one. Without a session
3284
+ * the call fails with `SIGN_IN_REQUIRED` while the user must sign in again,
3285
+ * and with `UNAUTHORIZED` otherwise.
3286
+ */
3287
+ function requireSession(state_) {
3288
+ const token = state_.identity.getSessionToken();
3289
+ if (token) return token;
3290
+ if (state_.identity.isSignInRequired()) throw signInRequiredError();
3291
+ if (state_.identity.credentialsUnknown()) throw secureStoreUnavailableError();
3292
+ throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
2954
3293
  }
2955
3294
  function expiresAtFromResult(result) {
2956
3295
  if (result.expires_at && typeof result.expires_at === "string") return result.expires_at;
2957
3296
  return new Date(Date.now() + 3600 * 1e3).toISOString();
2958
3297
  }
2959
- async function persistAuthResult(state_, result, anonymous) {
2960
- await state_.identity.setSession({
3298
+ /**
3299
+ * Store an auth result under `fence`. Resolves the generation it committed
3300
+ * at, or null when the fence refused it.
3301
+ */
3302
+ function persistAuthResult(state_, result, anonymous, fence = {}) {
3303
+ return state_.identity.setSession({
2961
3304
  appUserId: result.user.id,
2962
3305
  sessionToken: result.session_token,
2963
3306
  refreshToken: result.refresh_token,
2964
3307
  expiresAt: expiresAtFromResult(result),
2965
3308
  anonymous
2966
- });
3309
+ }, fence);
2967
3310
  }
2968
3311
  function nonEmptyString(value) {
2969
3312
  return typeof value === "string" && value.length > 0 ? value : null;
@@ -2975,14 +3318,31 @@ function nonEmptyString(value) {
2975
3318
  * request carried none). A link adopts the account's rotated anonymous id.
2976
3319
  * A refresh keeps the session's kind; every other sign-in records an
2977
3320
  * identified session.
3321
+ *
3322
+ * A sign-in the app asked for stores its session whatever happened
3323
+ * meanwhile: it is the newest thing the app asked for. The automatic
3324
+ * anonymous sign-in and a link pass a `fence` and fail with
3325
+ * `SESSION_CHANGED` (storing nothing) when the session changed while they
3326
+ * were in flight.
2978
3327
  */
2979
- async function postAuth(state_, path, body) {
3328
+ async function postAuth(state_, path, body, fence = {}) {
2980
3329
  const result = normalizeAuthResult((await state_.http.post(path, body)).data);
2981
- await persistAuthResult(state_, result, path === P.authAnonymous ? true : path === P.authRefresh ? void 0 : false);
3330
+ const anonymous = path === P.authAnonymous ? true : path === P.authRefresh ? void 0 : false;
3331
+ let target = state_;
3332
+ let committedAt = await persistAuthResult(state_, result, anonymous, fence);
3333
+ if (committedAt === null && fence.ifGeneration === void 0 && state_.disposed) {
3334
+ const next = await liveState();
3335
+ if (next === state_ ? !next.disposed : sameIdentityTarget(next, state_)) {
3336
+ target = next;
3337
+ committedAt = await persistAuthResult(next, result, anonymous);
3338
+ }
3339
+ }
3340
+ if (committedAt === null) throw sessionChangedError("the session changed while this sign-in was in flight; the newer session stays");
3341
+ state_ = target;
2982
3342
  const adoptedAnonymousId = path === P.authAnonymous ? nonEmptyString(result.anonymous_id) ?? nonEmptyString(result.user?.anonymous_id) : path === P.authLink ? nonEmptyString(result.user?.anonymous_id) : null;
2983
- if (adoptedAnonymousId) await adoptAnonymousId(state_, adoptedAnonymousId);
3343
+ if (adoptedAnonymousId) await adoptAnonymousId(state_, adoptedAnonymousId, committedAt);
2984
3344
  state_.eventQueue?.flush();
2985
- await notifyFromAuthResult(result);
3345
+ notifyFromAuthResult(state_, result, "signed_in");
2986
3346
  return result;
2987
3347
  }
2988
3348
  /**
@@ -2991,16 +3351,86 @@ async function postAuth(state_, path, body) {
2991
3351
  * session; they are handed to the signed-in user first, so the id change
2992
3352
  * leaves them replayable.
2993
3353
  */
2994
- async function adoptAnonymousId(state_, anonymousId) {
3354
+ async function adoptAnonymousId(state_, anonymousId, generation) {
2995
3355
  const previous = state_.identity.getAnonymousId();
2996
3356
  if (anonymousId === previous) return;
3357
+ if (state_.identity.getGeneration() !== generation) return;
2997
3358
  const appUserId = state_.identity.getAppUserId();
2998
3359
  if (appUserId && state_.offlineEnabled) try {
2999
3360
  await getOfflineQueue(state_).claimAnonymousOps(previous, appUserId);
3000
3361
  } catch (err) {
3001
3362
  if (typeof console !== "undefined") console.warn("[amba] could not reassign queued offline writes", err);
3002
3363
  }
3003
- await state_.identity.setAnonymousId(anonymousId);
3364
+ await state_.identity.setAnonymousId(anonymousId, { ifGeneration: generation });
3365
+ }
3366
+ /**
3367
+ * Hold an event `track()` got while the stored identity couldn't be read.
3368
+ * It keeps its call time and a copy of its properties; past
3369
+ * `MAX_HELD_EVENTS` the oldest is dropped.
3370
+ */
3371
+ function holdEvent(state_, event, properties, options) {
3372
+ const held = state_.heldEvents ??= [];
3373
+ if (held.length >= MAX_HELD_EVENTS) {
3374
+ held.shift();
3375
+ state_.heldEventsDropped = (state_.heldEventsDropped ?? 0) + 1;
3376
+ }
3377
+ held.push({
3378
+ event,
3379
+ properties: snapshotProperties(properties),
3380
+ options,
3381
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
3382
+ generation: state_.identity.getGeneration()
3383
+ });
3384
+ }
3385
+ /** Report the held events dropped for `MAX_HELD_EVENTS`, once. */
3386
+ function reportHeldEventsDropped(state_) {
3387
+ const dropped = state_.heldEventsDropped ?? 0;
3388
+ state_.heldEventsDropped = 0;
3389
+ if (dropped > 0 && typeof console !== "undefined") console.warn(`[amba] dropped ${dropped} event(s) tracked while the secure store was locked: more than ${MAX_HELD_EVENTS} were waiting`);
3390
+ }
3391
+ /** Drop the held events, with one warning that says why. */
3392
+ function dropHeldEvents(state_, why) {
3393
+ const count = (state_.heldEvents ?? []).length + (state_.heldEventsDropped ?? 0);
3394
+ state_.heldEvents = [];
3395
+ state_.heldEventsDropped = 0;
3396
+ if (count > 0 && typeof console !== "undefined") console.warn(`[amba] dropped ${count} event(s) tracked while the secure store was locked: ${why}`);
3397
+ }
3398
+ /**
3399
+ * Send the held events once the stored identity is known, oldest first,
3400
+ * through the path `track()` uses, under the identity that loaded. A session
3401
+ * renewal the load started finishes first. An event that fails is reported
3402
+ * and the rest go on; events tracked meanwhile join the end of the line.
3403
+ * An event goes out only while the session it belongs to is still the
3404
+ * stored one: the session that loaded for events held before, the session
3405
+ * of the moment for events tracked during the release. When the session
3406
+ * changed since (the renewal is refused and the session ends, a sign-in or
3407
+ * sign-out lands), the event is dropped, and one warning counts them.
3408
+ */
3409
+ async function releaseHeldEvents(state_) {
3410
+ if (state_.releasingHeldEvents || !state_.heldEvents?.length) return;
3411
+ state_.releasingHeldEvents = true;
3412
+ let stale = 0;
3413
+ try {
3414
+ const loadedAt = state_.identity.getGeneration();
3415
+ await settleSessionInFlight(state_);
3416
+ reportHeldEventsDropped(state_);
3417
+ while (state_.heldEvents?.length && !state_.disposed) {
3418
+ const next = state_.heldEvents.shift();
3419
+ const belongsTo = Math.max(next.generation, loadedAt);
3420
+ if (state_.identity.getGeneration() !== belongsTo) {
3421
+ stale += 1;
3422
+ continue;
3423
+ }
3424
+ try {
3425
+ await sendEvent(state_, next.event, next.properties, next.options, next.occurredAt);
3426
+ } catch (err) {
3427
+ if (typeof console !== "undefined") console.warn(`[amba] could not send "${next.event}", tracked while the secure store was locked`, err);
3428
+ }
3429
+ }
3430
+ } finally {
3431
+ state_.releasingHeldEvents = false;
3432
+ if (stale > 0 && typeof console !== "undefined") console.warn(`[amba] dropped ${stale} event(s) tracked while the secure store was locked: the session they belong to changed before they could be sent`);
3433
+ }
3004
3434
  }
3005
3435
  /** Paths that must NOT trigger lazy anonymous sign-in. */
3006
3436
  function pathSkipsLazySession(path) {
@@ -3011,21 +3441,41 @@ function pathSkipsLazySession(path) {
3011
3441
  if (path === P.flags || path.startsWith(`${P.flags}/`)) return true;
3012
3442
  return false;
3013
3443
  }
3444
+ /**
3445
+ * Make sure a session is stored before an authenticated call. While the user
3446
+ * must sign in again (`sign_in_required`), the call fails with
3447
+ * `SIGN_IN_REQUIRED` and no anonymous user starts.
3448
+ */
3014
3449
  async function ensureAnonymousSession(state_) {
3015
3450
  if (state_.identity.getSessionToken()) return;
3451
+ if (state_.identity.isSignInRequired()) throw signInRequiredError();
3016
3452
  if (state_.anonSignInInflight) {
3017
3453
  await state_.anonSignInInflight.catch(() => {});
3018
3454
  if (state_.identity.getSessionToken()) return;
3455
+ if (state_.identity.isSignInRequired()) throw signInRequiredError();
3456
+ }
3457
+ for (let attempt = 0; attempt < 2; attempt += 1) {
3458
+ if (state_.identity.getSessionToken() || state_.disposed) return;
3459
+ if (state_.identity.isSignInRequired()) throw signInRequiredError();
3460
+ await anonymousSignInOnce(state_);
3019
3461
  }
3462
+ if (!state_.identity.getSessionToken() && state_.identity.isSignInRequired()) throw signInRequiredError();
3463
+ }
3464
+ /** One run of the lazy sign-in, shared by every caller that waits for it. */
3465
+ function anonymousSignInOnce(state_) {
3020
3466
  if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
3021
3467
  try {
3468
+ await state_.identity.vaultStillUnreadable();
3469
+ if (await state_.identity.anonymousIdStillUnknown()) throw secureStoreUnavailableError();
3470
+ if (state_.identity.getSessionToken()) return;
3022
3471
  if (await resumeWithRefreshToken(state_)) return;
3023
- await signInAnonymouslyFresh(state_);
3472
+ if (state_.identity.getSessionToken() || state_.identity.isSignInRequired()) return;
3473
+ await signInAnonymouslyAutomatic(state_);
3024
3474
  } finally {
3025
3475
  state_.anonSignInInflight = null;
3026
3476
  }
3027
3477
  })();
3028
- await state_.anonSignInInflight;
3478
+ return state_.anonSignInInflight;
3029
3479
  }
3030
3480
  /**
3031
3481
  * Restore a stored session whose session token expired while the app was
@@ -3033,7 +3483,7 @@ async function ensureAnonymousSession(state_) {
3033
3483
  * `onAuthStateChange` show the signed-in user without waiting for an
3034
3484
  * authenticated call. Shares the in-flight slot with the lazy sign-in, so a
3035
3485
  * concurrent authenticated call waits for it. A rejected refresh token ends
3036
- * the session (subscribers hear `null`); this never signs in anonymously.
3486
+ * the session (`endLostSession`); this never signs in anonymously.
3037
3487
  */
3038
3488
  function restoreStoredSession(state_) {
3039
3489
  if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
@@ -3046,11 +3496,16 @@ function restoreStoredSession(state_) {
3046
3496
  return state_.anonSignInInflight;
3047
3497
  }
3048
3498
  /**
3049
- * Server answers that mean the refresh token's session is over. A 401
3050
- * without the server's envelope (a proxy, a captive portal) maps to
3051
- * `UNAUTHORIZED` and leaves the session in place.
3499
+ * Answers that mean the refresh token's session is over: the server
3500
+ * rejected or revoked it, or it renewed a different user than the stored
3501
+ * one (`OWNER_MISMATCH`). A 401 without the server's envelope (a proxy, a
3502
+ * captive portal) maps to `UNAUTHORIZED` and leaves the session in place.
3052
3503
  */
3053
- const SESSION_ENDED_CODES = new Set(["INVALID_TOKEN", "SESSION_REVOKED"]);
3504
+ const SESSION_ENDED_CODES = new Set([
3505
+ "INVALID_TOKEN",
3506
+ "SESSION_REVOKED",
3507
+ "OWNER_MISMATCH"
3508
+ ]);
3054
3509
  function isSessionEnded(err) {
3055
3510
  return err instanceof AmbaApiError && SESSION_ENDED_CODES.has(err.code);
3056
3511
  }
@@ -3077,8 +3532,8 @@ function trackLink(state_, link) {
3077
3532
  * waited for first;
3078
3533
  * - a session another flow stored after the call left is used as is;
3079
3534
  * - otherwise the refresh token continues the session. A rejected refresh
3080
- * token ends it (subscribers hear `null`) and the call's own error
3081
- * reaches the app.
3535
+ * token ends it (`endLostSession`) and the call's own error reaches the
3536
+ * app.
3082
3537
  * A session of any other user (a sign-in meanwhile, or the anonymous user
3083
3538
  * a lazy sign-in starts after the session ended) answers false, and the
3084
3539
  * call fails with the server's code. Offline or a server error keeps the
@@ -3114,8 +3569,8 @@ async function recoverRefusedSession(state_, usedToken, usedUserId) {
3114
3569
  * reaction on that promise), so recovery never waits on itself.
3115
3570
  */
3116
3571
  async function withBodySession(state_, send) {
3117
- const usedToken = state_.identity.getSessionToken();
3118
- if (!usedToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3572
+ await loadUnreadSession(state_);
3573
+ const usedToken = requireSession(state_);
3119
3574
  const usedUserId = state_.identity.getAppUserId();
3120
3575
  try {
3121
3576
  return await send(usedToken);
@@ -3128,78 +3583,136 @@ async function withBodySession(state_, send) {
3128
3583
  }
3129
3584
  }
3130
3585
  /**
3586
+ * Read a store the launch couldn't read (a locked device) again. When that
3587
+ * loads a session, wait for the renewal it starts, so the caller acts on
3588
+ * the loaded session (a sign-out revokes it, a link sends it).
3589
+ */
3590
+ async function loadUnreadSession(state_) {
3591
+ if (!state_.identity.credentialsUnknown()) return;
3592
+ await state_.identity.credentialsStillUnreadable();
3593
+ if (!state_.identity.credentialsUnknown()) await settleSessionInFlight(state_);
3594
+ }
3595
+ /**
3131
3596
  * Before a read of the current session (`me`, `getSession`,
3132
- * `getCurrentIdentifiers`) while no session token is stored: wait for the
3133
- * launch restore or sign-in in flight, so a check made at launch sees the
3134
- * session it restores.
3597
+ * `getCurrentIdentifiers`) while no session token is stored: load a stored
3598
+ * session the launch couldn't read yet, then wait for the launch restore or
3599
+ * sign-in in flight, so a check made at launch sees the session it
3600
+ * restores.
3135
3601
  */
3136
3602
  async function settleSessionInFlight(state_) {
3603
+ if (state_.identity.getSessionToken()) return;
3604
+ await state_.identity.credentialsStillUnreadable();
3137
3605
  if (state_.identity.getSessionToken() || !state_.anonSignInInflight) return;
3138
3606
  await state_.anonSignInInflight.catch(() => {});
3139
3607
  }
3140
3608
  /**
3141
3609
  * Continue the stored session with its refresh token. True when it did.
3142
- * False when there is no refresh token, or the server rejected it: the
3143
- * session is then cleared (the anonymous id rotates unless the session was
3144
- * purely anonymous) and subscribers hear the sign-out. Any other failure
3145
- * (offline, a 5xx) is thrown, so a flaky network never starts a new user.
3610
+ * False when there is no refresh token, or the session is over (the server
3611
+ * rejected the refresh token, or it renewed another user): the session then
3612
+ * ends through `endLostSession`. Any other failure (offline, a 5xx) is
3613
+ * thrown, so a flaky network never ends a session or starts a new user.
3146
3614
  */
3147
3615
  async function resumeWithRefreshToken(state_) {
3148
3616
  const used = state_.identity.getRefreshToken();
3149
3617
  if (!used) return false;
3618
+ const startedAt = state_.identity.getGeneration();
3150
3619
  try {
3151
3620
  await refreshSession(state_);
3152
3621
  return true;
3153
3622
  } catch (err) {
3154
3623
  if (state_.identity.getRefreshToken() !== used) return state_.identity.getSessionToken() !== null;
3155
3624
  if (!isSessionEnded(err)) throw err;
3156
- await state_.identity.clearSession(false, { keepIdWhenKindUnknown: true });
3157
- notifyAuthSubscribers(null);
3625
+ await endLostSession(state_, startedAt);
3158
3626
  return false;
3159
3627
  }
3160
3628
  }
3161
3629
  /**
3162
- * `/v1/client/auth/anonymous` with the device's anonymous id. When the
3163
- * server answers `IDENTITY_SIGN_IN_REQUIRED` (the id belongs to a user who
3164
- * signs in with a linked identity), the device takes a new anonymous id and
3165
- * signs in once more as a new anonymous user.
3630
+ * End a session that is over without a sign-out, once, unless the session
3631
+ * changed since `startedAt`.
3632
+ *
3633
+ * - A session that gained an identity (a sign-in other than anonymous, or a
3634
+ * link) leaves the user to sign in again: its tokens are cleared, the
3635
+ * anonymous id rotates (it belongs to the identified account), and
3636
+ * subscribers hear `null` with `sign_in_required`. No new anonymous user
3637
+ * starts; calls that need a session fail with `SIGN_IN_REQUIRED`.
3638
+ * - A purely anonymous session keeps its anonymous id, so the next call that
3639
+ * needs a session resumes the same anonymous user through `/anonymous`
3640
+ * (or the server starts a new one). Subscribers hear `null` with
3641
+ * `session_ended`.
3642
+ * - A session of unknown kind (stored by an SDK before 4.0.14) is treated
3643
+ * like an anonymous one: `/anonymous` resumes a still-anonymous user, and
3644
+ * answers `IDENTITY_SIGN_IN_REQUIRED` for one who linked an identity,
3645
+ * which then leaves the user to sign in again.
3166
3646
  */
3167
- async function signInAnonymouslyFresh(state_) {
3647
+ async function endLostSession(state_, startedAt) {
3648
+ if (state_.identity.snapshot().anonymousSession === false) {
3649
+ if (await state_.identity.clearSession(false, {
3650
+ signInRequired: true,
3651
+ ifGeneration: startedAt
3652
+ })) notifyAuthSubscribers(state_, null, "sign_in_required");
3653
+ return;
3654
+ }
3655
+ if (await state_.identity.clearSession(false, {
3656
+ keepIdWhenKindUnknown: true,
3657
+ ifGeneration: startedAt
3658
+ })) notifyAuthSubscribers(state_, null, "session_ended");
3659
+ }
3660
+ /**
3661
+ * The anonymous sign-in the SDK starts on its own before an authenticated
3662
+ * call: `/v1/client/auth/anonymous` with the device's anonymous id. It
3663
+ * stores its session only when nothing replaced the stored identity while
3664
+ * it was in flight; a sign-in that landed first wins and this answer is
3665
+ * dropped.
3666
+ *
3667
+ * When the server answers `IDENTITY_SIGN_IN_REQUIRED` (the id belongs to a
3668
+ * user who signs in with a linked identity), the stored session continues
3669
+ * when this device holds its refresh token. Otherwise the user must sign in
3670
+ * again (`sign_in_required`): no new anonymous user starts on its own.
3671
+ */
3672
+ async function signInAnonymouslyAutomatic(state_) {
3673
+ if (state_.disposed) return;
3674
+ const startedAt = state_.identity.getGeneration();
3168
3675
  try {
3169
- return await postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
3676
+ await postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() }, { ifGeneration: startedAt });
3170
3677
  } catch (err) {
3678
+ if (isSessionChanged(err)) return;
3171
3679
  if (!(err instanceof AmbaApiError) || err.code !== IDENTITY_SIGN_IN_REQUIRED) throw err;
3172
- return recoverFromHeldAnonymousId(state_);
3680
+ if (await continueHeldSession(state_)) return;
3681
+ if (await state_.identity.clearSession(true, {
3682
+ signInRequired: true,
3683
+ ifGeneration: startedAt
3684
+ })) notifyAuthSubscribers(state_, null, "sign_in_required");
3173
3685
  }
3174
3686
  }
3175
3687
  const IDENTITY_SIGN_IN_REQUIRED = "IDENTITY_SIGN_IN_REQUIRED";
3176
3688
  /**
3177
3689
  * After `/anonymous` answered `IDENTITY_SIGN_IN_REQUIRED`: continue the
3178
- * stored session when this device holds its refresh token, and otherwise
3179
- * take a new anonymous id and sign in as a new anonymous user. While the
3180
- * secure store can't be read (an iOS device not yet unlocked since a
3181
- * restart), the stored session is unknown, so the call fails with
3182
- * `SECURE_STORE_UNAVAILABLE` and replaces nothing.
3690
+ * stored session when this device holds its refresh token. Null when it
3691
+ * holds none, or the server rejected it. While the stored session can't be
3692
+ * read (an iOS device not yet unlocked since a restart), it is unknown, so
3693
+ * the call fails with `SECURE_STORE_UNAVAILABLE` and replaces nothing.
3183
3694
  */
3184
- async function recoverFromHeldAnonymousId(state_) {
3185
- if (await state_.identity.vaultStillUnreadable()) throw new AmbaApiError("SECURE_STORE_UNAVAILABLE", "the stored session can't be read yet (the device may be locked); retry once it is unlocked");
3695
+ async function continueHeldSession(state_) {
3696
+ if (await state_.identity.credentialsStillUnreadable()) throw secureStoreUnavailableError();
3186
3697
  const used = state_.identity.getRefreshToken();
3187
- if (used) try {
3698
+ if (!used) return null;
3699
+ try {
3188
3700
  return await refreshSession(state_);
3189
3701
  } catch (refreshErr) {
3190
3702
  if (!isSessionEnded(refreshErr)) throw refreshErr;
3191
3703
  const current = state_.identity.getRefreshToken();
3192
3704
  if (current !== null && current !== used) throw refreshErr;
3705
+ return null;
3193
3706
  }
3194
- await state_.identity.clearSession(true);
3195
- return postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
3196
3707
  }
3197
3708
  /**
3198
3709
  * Rotate the session pair with the stored refresh token. The session keeps
3199
3710
  * its user and its kind (anonymous or identified), and auth-state
3200
3711
  * subscribers hear the refreshed session. The server includes the user; an
3201
3712
  * older server that leaves it out keeps the stored user id, and the result's
3202
- * user then carries only that id.
3713
+ * user then carries only that id. A pair that renews a different user than
3714
+ * the stored one is never stored: the session ends (`endLostSession`) and
3715
+ * the call fails with `OWNER_MISMATCH`.
3203
3716
  */
3204
3717
  function refreshSession(state_) {
3205
3718
  if (state_.refreshInflight) return state_.refreshInflight;
@@ -3212,32 +3725,66 @@ function refreshSession(state_) {
3212
3725
  return inflight;
3213
3726
  }
3214
3727
  async function refreshSessionOnce(state_) {
3728
+ if (state_.disposed) throw sessionChangedError("the SDK was configured again");
3215
3729
  const refreshToken = state_.identity.getRefreshToken();
3730
+ if (!refreshToken && state_.identity.isSignInRequired()) throw signInRequiredError();
3216
3731
  if (!refreshToken) throw new AmbaApiError("UNAUTHORIZED", "no refresh token available — call signIn* first");
3217
- const env = await state_.http.post(P.authRefresh, { refresh_token: refreshToken });
3218
- if (state_.identity.getRefreshToken() !== refreshToken) throw new AmbaApiError("SESSION_CHANGED", "the session changed while it was being refreshed");
3219
- const appUserId = env.data.user?.id ?? state_.identity.getAppUserId();
3220
- if (!appUserId) throw new AmbaApiError("UNAUTHORIZED", "the refreshed session names no user — call signIn* first");
3221
- const user = env.data.user ? normalizeUserAuthProviders(env.data.user) : {
3222
- id: appUserId,
3223
- auth_providers: [],
3224
- properties: {}
3225
- };
3226
- const result = {
3227
- session_token: env.data.session_token,
3228
- refresh_token: env.data.refresh_token,
3229
- user,
3230
- expires_at: env.data.expires_at ?? null
3231
- };
3232
- await persistAuthResult(state_, result, void 0);
3233
- const anonymousId = nonEmptyString(user.anonymous_id);
3234
- if (anonymousId && anonymousId !== state_.identity.getAnonymousId() && state_.identity.getRefreshToken() === result.refresh_token) {
3235
- if (state_.identity.snapshot().anonymousSession === true) await state_.identity.markIdentified();
3236
- await adoptAnonymousId(state_, anonymousId);
3732
+ const startedAt = state_.identity.getGeneration();
3733
+ const exchange$1 = refreshExchangeFor(state_, refreshToken);
3734
+ try {
3735
+ const env = await exchange$1.response;
3736
+ if (state_.identity.getRefreshToken() !== refreshToken || state_.identity.getGeneration() !== startedAt) throw sessionChangedError("the session changed while it was being refreshed");
3737
+ const storedUserId = state_.identity.getAppUserId();
3738
+ const renewedUserId = nonEmptyString(env.data.user?.id);
3739
+ if (renewedUserId !== null && storedUserId !== null && renewedUserId !== storedUserId) {
3740
+ await endLostSession(state_, startedAt);
3741
+ throw new AmbaApiError("OWNER_MISMATCH", "the refreshed session belongs to a different user than the stored one; sign in again");
3742
+ }
3743
+ const appUserId = renewedUserId ?? storedUserId;
3744
+ if (!appUserId) throw new AmbaApiError("UNAUTHORIZED", "the refreshed session names no user — call signIn* first");
3745
+ const user = env.data.user ? normalizeUserAuthProviders(env.data.user) : {
3746
+ id: appUserId,
3747
+ auth_providers: [],
3748
+ properties: {}
3749
+ };
3750
+ const result = {
3751
+ session_token: env.data.session_token,
3752
+ refresh_token: env.data.refresh_token,
3753
+ user,
3754
+ expires_at: env.data.expires_at ?? null
3755
+ };
3756
+ const committedAt = await persistAuthResult(state_, result, void 0, {
3757
+ ifGeneration: startedAt,
3758
+ advance: false
3759
+ });
3760
+ if (committedAt === null) throw sessionChangedError("the session changed while it was being refreshed");
3761
+ const anonymousId = nonEmptyString(user.anonymous_id);
3762
+ if (anonymousId && anonymousId !== state_.identity.getAnonymousId() && state_.identity.getRefreshToken() === result.refresh_token) {
3763
+ if (state_.identity.snapshot().anonymousSession === true) await state_.identity.markIdentified({ ifGeneration: committedAt });
3764
+ await adoptAnonymousId(state_, anonymousId, committedAt);
3765
+ }
3766
+ state_.eventQueue?.flush();
3767
+ notifyFromAuthResult(state_, result, "token_refreshed");
3768
+ return result;
3769
+ } finally {
3770
+ if (state_.refreshExchange === exchange$1) state_.refreshExchange = null;
3237
3771
  }
3238
- state_.eventQueue?.flush();
3239
- await notifyFromAuthResult(result);
3240
- return result;
3772
+ }
3773
+ /**
3774
+ * The `/refresh` request for `refreshToken`: the one the previous state had
3775
+ * in flight for the same token when `configure()` ran again, or a new one.
3776
+ * It stays on the state until its answer is stored, so a `configure()`
3777
+ * meanwhile hands it on instead of presenting the token again.
3778
+ */
3779
+ function refreshExchangeFor(state_, refreshToken) {
3780
+ const inherited = state_.inheritedRefresh;
3781
+ if (inherited) state_.inheritedRefresh = null;
3782
+ const exchange$1 = inherited && inherited.token === refreshToken ? inherited : {
3783
+ token: refreshToken,
3784
+ response: state_.http.post(P.authRefresh, { refresh_token: refreshToken })
3785
+ };
3786
+ state_.refreshExchange = exchange$1;
3787
+ return exchange$1;
3241
3788
  }
3242
3789
  /**
3243
3790
  * POST an OTP or email + password link. The request asks the server to
@@ -3254,6 +3801,11 @@ function postLink(state_, body) {
3254
3801
  })));
3255
3802
  }
3256
3803
  async function postLinkOnce(state_, body) {
3804
+ const startedAt = state_.identity.getGeneration();
3805
+ const fence = {
3806
+ ifGeneration: startedAt,
3807
+ advance: false
3808
+ };
3257
3809
  const env = await state_.http.post(P.authLink, {
3258
3810
  ...body,
3259
3811
  rotate_session: true
@@ -3261,7 +3813,7 @@ async function postLinkOnce(state_, body) {
3261
3813
  const user = normalizeUserAuthProviders(env.data.user);
3262
3814
  const sessionToken = nonEmptyString(env.data.session_token);
3263
3815
  const refreshToken = nonEmptyString(env.data.refresh_token);
3264
- if (sessionToken && refreshToken) await state_.identity.setSession({
3816
+ if (!(sessionToken && refreshToken ? await state_.identity.setSession({
3265
3817
  appUserId: user.id,
3266
3818
  sessionToken,
3267
3819
  refreshToken,
@@ -3272,10 +3824,9 @@ async function postLinkOnce(state_, body) {
3272
3824
  expires_at: env.data.expires_at ?? null
3273
3825
  }),
3274
3826
  anonymous: false
3275
- });
3276
- else await state_.identity.markIdentified();
3827
+ }, fence) !== null : await state_.identity.markIdentified(fence))) throw sessionChangedError("the session changed while the link was in flight; the newer session stays");
3277
3828
  const anonymousId = nonEmptyString(user.anonymous_id);
3278
- if (anonymousId) await adoptAnonymousId(state_, anonymousId);
3829
+ if (anonymousId) await adoptAnonymousId(state_, anonymousId, startedAt);
3279
3830
  return user;
3280
3831
  }
3281
3832
  /**
@@ -3286,7 +3837,7 @@ async function postUserNoRotate(state_, path, body) {
3286
3837
  return normalizeUserAuthProviders((await state_.http.post(path, body)).data.user);
3287
3838
  }
3288
3839
  async function unlinkProvider(provider) {
3289
- const s = getState();
3840
+ const s = await liveState();
3290
3841
  return withBodySession(s, (sessionToken) => postUserNoRotate(s, P.authUnlink, {
3291
3842
  provider,
3292
3843
  session_token: sessionToken
@@ -3543,107 +4094,238 @@ function detectDeviceRegion() {
3543
4094
  }
3544
4095
  }
3545
4096
  /**
3546
- * Top-level SDK namespace for React Native.
4097
+ * The send path of `events.track()`: the event batch queue when batching is
4098
+ * on, otherwise the live request (with the offline outbox behind it when
4099
+ * enabled). `occurredAtOverride` is when the event happened, for one that
4100
+ * waited (see `releaseHeldEvents`).
3547
4101
  */
3548
- const Amba = {
3549
- configure: async (config) => {
3550
- if (!config.projectId || config.projectId.trim() === "") throw new Error("Amba.configure: projectId is required");
3551
- if (!config.clientKey || config.clientKey.trim() === "") throw new Error("Amba.configure: clientKey is required");
3552
- const batchSize = config.events?.batchSize ?? 100;
3553
- const flushIntervalMs = config.events?.flushIntervalMs ?? 6e4;
3554
- const maxQueue = config.events?.maxQueue ?? 1e4;
3555
- if (config.events?.batching) {
3556
- if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 500) throw new Error("Amba.configure: events.batchSize must be 1–500");
3557
- if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
3558
- if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
4102
+ async function sendEvent(s, event, properties, options, occurredAtOverride) {
4103
+ if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true, occurredAtOverride);
4104
+ const occurredAt = occurredAtOverride ?? (/* @__PURE__ */ new Date()).toISOString();
4105
+ const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
4106
+ const body = {
4107
+ event,
4108
+ properties: props ?? void 0,
4109
+ anonymous_id: s.identity.getAnonymousId(),
4110
+ timestamp: occurredAt
4111
+ };
4112
+ if (options?.telemetry) body["telemetry"] = true;
4113
+ const idempotencyKey = s.offlineEnabled ? newIdempotencyKey() : void 0;
4114
+ if (idempotencyKey && keyedLiveTrack(props, options?.telemetry)) body["idempotency_key"] = idempotencyKey;
4115
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
4116
+ try {
4117
+ await s.http.post(P.events, body);
4118
+ } catch (err) {
4119
+ if (s.offlineEnabled && isOfflineError(err)) {
4120
+ await getOfflineQueue(s).enqueue({
4121
+ kind: "track",
4122
+ event,
4123
+ properties: props,
4124
+ telemetry: options?.telemetry,
4125
+ occurredAt,
4126
+ idempotencyKey
4127
+ }, outboxTicket);
4128
+ return;
3559
4129
  }
3560
- const deadlines = resolveRequestDeadlines(config);
3561
- const tokenStorage = config.tokenStorage ?? "secure";
3562
- if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
3563
- const secureStorage = config.secureStorage;
3564
- if (secureStorage !== void 0 && (typeof secureStorage?.getItem !== "function" || typeof secureStorage?.setItem !== "function" || typeof secureStorage?.removeItem !== "function")) throw new Error("Amba.configure: secureStorage must provide getItem, setItem and removeItem");
3565
- const configDefaults = readConfigDefaults(config.configDefaults);
3566
- _pendingConfigDefaults = configDefaults;
3567
- const storagePrefix = config.storagePrefix ?? "amba";
3568
- const persistence = new AsyncStoragePersistence(storagePrefix);
3569
- if (config.events?.batching) await persistence.assertDurable();
3570
- await persistence.hydrate();
3571
- const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
3572
- const identity = await IdentityStore.create(persistence, vault);
3573
- const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
3574
- const remoteValues = new RemoteValuesCache(persistence, recordKey(baseUrl, config.clientKey), configDefaults ?? {}, () => identity.getAppUserId());
4130
+ throw err;
4131
+ }
4132
+ }
4133
+ /**
4134
+ * The local half of a sign-out on `state_`: clear the session (unless a
4135
+ * sign-in stored a newer one since `startedAt`), drop the signed-out
4136
+ * user's cached values and queued writes, and tell subscribers. False when
4137
+ * nothing was cleared.
4138
+ */
4139
+ async function endSignedOutSession(state_, rotateAnonymousId, startedAt) {
4140
+ const signedOutUser = state_.identity.getAppUserId();
4141
+ if (!await state_.identity.clearSession(rotateAnonymousId, { unlessSignedInSince: startedAt })) return false;
4142
+ dropHeldEvents(state_, "the device signed out before its identity could be read");
4143
+ state_.remoteValues.clearForSignOut(signedOutUser);
4144
+ await state_.remoteValues.flushed();
4145
+ try {
4146
+ await getOfflineQueue(state_).purge();
4147
+ } catch (err) {
4148
+ if (typeof console !== "undefined") console.warn("[amba] signOut could not clear the offline outbox", err);
4149
+ }
4150
+ state_.eventQueue?.flush();
4151
+ notifyAuthSubscribers(state_, null, "signed_out");
4152
+ return true;
4153
+ }
4154
+ /**
4155
+ * One `configure()`: build the state for `config` and install it in place
4156
+ * of the previous one (see `Amba.configure`).
4157
+ */
4158
+ async function configureOnce(config) {
4159
+ if (!config.projectId || config.projectId.trim() === "") throw new Error("Amba.configure: projectId is required");
4160
+ if (!config.clientKey || config.clientKey.trim() === "") throw new Error("Amba.configure: clientKey is required");
4161
+ const batchSize = config.events?.batchSize ?? 100;
4162
+ const flushIntervalMs = config.events?.flushIntervalMs ?? 6e4;
4163
+ const maxQueue = config.events?.maxQueue ?? 1e4;
4164
+ if (config.events?.batching) {
4165
+ if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 500) throw new Error("Amba.configure: events.batchSize must be 1–500");
4166
+ if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
4167
+ if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
4168
+ }
4169
+ const deadlines = resolveRequestDeadlines(config);
4170
+ const tokenStorage = config.tokenStorage ?? "secure";
4171
+ if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
4172
+ const secureStorage = config.secureStorage;
4173
+ if (secureStorage !== void 0 && (typeof secureStorage?.getItem !== "function" || typeof secureStorage?.setItem !== "function" || typeof secureStorage?.removeItem !== "function")) throw new Error("Amba.configure: secureStorage must provide getItem, setItem and removeItem");
4174
+ const configDefaults = readConfigDefaults(config.configDefaults);
4175
+ _pendingConfigDefaults = configDefaults;
4176
+ const storagePrefix = config.storagePrefix ?? "amba";
4177
+ const persistence = new AsyncStoragePersistence(storagePrefix);
4178
+ if (config.events?.batching) await persistence.assertDurable();
4179
+ await persistence.hydrate();
4180
+ const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
4181
+ const prior = state;
4182
+ let inheritedRefresh = null;
4183
+ const detached = prior !== null && !prior.disposed;
4184
+ if (prior && detached) {
4185
+ prior.disposed = true;
4186
+ inheritedRefresh = prior.refreshExchange ?? prior.inheritedRefresh ?? null;
4187
+ await prior.identity.dispose();
4188
+ }
4189
+ const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
4190
+ let identity;
4191
+ let remoteValues;
4192
+ try {
4193
+ identity = await IdentityStore.create(persistence, vault);
4194
+ const store = identity;
4195
+ remoteValues = new RemoteValuesCache(persistence, recordKey(baseUrl, config.clientKey), configDefaults ?? {}, () => store.getAppUserId());
3575
4196
  await remoteValues.load();
3576
- const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
3577
- const http = new AmbaHttpClient({
3578
- apiKey: config.clientKey,
3579
- baseUrl,
3580
- sdkPlatform: "react-native",
3581
- sdkVersionHeader,
3582
- appVersion: normalizeAppVersion(config.appVersion),
3583
- deviceRegion: detectDeviceRegion(),
3584
- deviceOs: detectDeviceOs(),
3585
- identity,
3586
- requestTimeoutMs: deadlines.requestMs,
3587
- longRequestTimeoutMs: deadlines.longMs,
3588
- ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
3589
- if (pathSkipsLazySession(path)) return;
3590
- await ensureAnonymousSession(getState());
3591
- },
3592
- onSessionRefused: (usedToken, usedUserId) => recoverRefusedSession(getState(), usedToken, usedUserId)
4197
+ } catch (err) {
4198
+ if (prior && detached && state === prior) {
4199
+ prior.disposed = false;
4200
+ prior.identity.reattach();
4201
+ prior.inheritedRefresh = inheritedRefresh;
4202
+ if (inheritedRefresh && prior.identity.getRefreshToken() === inheritedRefresh.token) restoreStoredSession(prior).catch(() => {});
4203
+ }
4204
+ throw err;
4205
+ }
4206
+ const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
4207
+ const http = new AmbaHttpClient({
4208
+ apiKey: config.clientKey,
4209
+ baseUrl,
4210
+ sdkPlatform: "react-native",
4211
+ sdkVersionHeader,
4212
+ appVersion: normalizeAppVersion(config.appVersion),
4213
+ deviceRegion: detectDeviceRegion(),
4214
+ deviceOs: detectDeviceOs(),
4215
+ identity,
4216
+ requestTimeoutMs: deadlines.requestMs,
4217
+ longRequestTimeoutMs: deadlines.longMs,
4218
+ ensureSession: async (path) => {
4219
+ if (pathSkipsLazySession(path)) return;
4220
+ if (state?.identity.isSignInRequired()) throw signInRequiredError();
4221
+ if (config.autoAnonymousSession === false) return;
4222
+ await ensureAnonymousSession(getState());
4223
+ },
4224
+ onSessionRefused: (usedToken, usedUserId) => recoverRefusedSession(getState(), usedToken, usedUserId)
4225
+ });
4226
+ _offlineEnableToken += 1;
4227
+ if (prior?.offlineQueue) await prior.offlineQueue.dispose();
4228
+ prior?.detachEventAppState?.();
4229
+ prior?.detachIdentityAppState?.();
4230
+ if (prior?.eventQueue) await prior.eventQueue.dispose();
4231
+ experimentReporter.reset();
4232
+ const nextState = {
4233
+ http,
4234
+ identity,
4235
+ debug: config.debug ?? false,
4236
+ projectId: config.projectId,
4237
+ clientKey: config.clientKey,
4238
+ baseUrl,
4239
+ functionsBaseUrl: config.functionsBaseUrl ?? null,
4240
+ resolvedFunctionsProjectId: null,
4241
+ functionsProjectIdInflight: null,
4242
+ persistence,
4243
+ remoteValues,
4244
+ refreshedFor: {
4245
+ config: null,
4246
+ flags: null
4247
+ },
4248
+ offlineEnabled: false,
4249
+ offlineQueue: null,
4250
+ eventQueue: null,
4251
+ inheritedRefresh
4252
+ };
4253
+ if (prior?.heldEvents?.length || prior?.heldEventsDropped) if (sameIdentityTarget(prior, nextState)) {
4254
+ const launchedAt = identity.getGeneration();
4255
+ nextState.heldEvents = (prior.heldEvents ?? []).map((held) => ({
4256
+ ...held,
4257
+ generation: launchedAt
4258
+ }));
4259
+ nextState.heldEventsDropped = prior.heldEventsDropped;
4260
+ prior.heldEvents = [];
4261
+ prior.heldEventsDropped = 0;
4262
+ } else dropHeldEvents(prior, "the SDK was configured for another project");
4263
+ state = nextState;
4264
+ identity.onAnonymousIdKnown(() => {
4265
+ releaseHeldEvents(nextState);
4266
+ });
4267
+ identity.onStoredSessionLoaded(() => {
4268
+ const userId = identity.getAppUserId();
4269
+ if (identity.getSessionToken() && userId) notifyAuthSubscribers(nextState, snapshotSession({
4270
+ id: userId,
4271
+ auth_providers: [],
4272
+ properties: {}
4273
+ }), "session_restored");
4274
+ else if (config.autoAnonymousSession !== false && identity.getRefreshToken()) restoreStoredSession(nextState).catch(() => {});
4275
+ });
4276
+ try {
4277
+ const { AppState } = require("react-native");
4278
+ const sub = AppState.addEventListener("change", (status) => {
4279
+ if (status === "active" && identity.credentialsUnknown()) identity.credentialsStillUnreadable();
3593
4280
  });
3594
- const prior = state;
3595
- _offlineEnableToken += 1;
3596
- if (prior?.offlineQueue) await prior.offlineQueue.dispose();
3597
- prior?.detachEventAppState?.();
3598
- if (prior?.eventQueue) await prior.eventQueue.dispose();
3599
- experimentReporter.reset();
3600
- const nextState = {
3601
- http,
3602
- identity,
3603
- debug: config.debug ?? false,
3604
- projectId: config.projectId,
3605
- clientKey: config.clientKey,
3606
- baseUrl,
3607
- functionsBaseUrl: config.functionsBaseUrl ?? null,
3608
- resolvedFunctionsProjectId: null,
3609
- functionsProjectIdInflight: null,
3610
- persistence,
3611
- remoteValues,
3612
- refreshedFor: {
3613
- config: null,
3614
- flags: null
3615
- },
3616
- offlineEnabled: false,
3617
- offlineQueue: null,
3618
- eventQueue: null
4281
+ nextState.detachIdentityAppState = () => sub.remove();
4282
+ } catch {}
4283
+ if (config.events?.batching) {
4284
+ const queue = new EventBatchQueue(offlineStore(nextState), `event_outbox:${nextState.projectId}`, {
4285
+ batchSize,
4286
+ flushIntervalMs,
4287
+ maxQueue,
4288
+ onDrop: config.events.onDrop
4289
+ }, () => ({
4290
+ userId: nextState.identity.getAppUserId(),
4291
+ anonymousId: nextState.identity.getAnonymousId(),
4292
+ sessionToken: nextState.identity.getSessionToken()
4293
+ }), (body, token) => nextState.http.postWithSession("/v1/client/events/batch", body, token), (body, token) => nextState.http.postWithSession(P.events, body, token));
4294
+ nextState.eventQueue = queue;
4295
+ await queue.start();
4296
+ try {
4297
+ const { AppState } = require("react-native");
4298
+ const sub = AppState.addEventListener("change", (status) => {
4299
+ if (status === "background") queue.flush();
4300
+ });
4301
+ nextState.detachEventAppState = () => sub.remove();
4302
+ } catch {}
4303
+ }
4304
+ if (!nextState.functionsBaseUrl && !PROJECT_ID_PATTERN.test(nextState.projectId)) resolveFunctionsProjectId(nextState).catch(() => {});
4305
+ if (nextState.heldEvents?.length && !identity.hasStandInAnonymousId()) releaseHeldEvents(nextState);
4306
+ if (inheritedRefresh !== null && identity.getRefreshToken() === inheritedRefresh.token || config.autoAnonymousSession !== false && !identity.getSessionToken() && identity.getRefreshToken()) restoreStoredSession(nextState).catch(() => {});
4307
+ }
4308
+ /**
4309
+ * Top-level SDK namespace for React Native.
4310
+ */
4311
+ const Amba = {
4312
+ configure: (config) => {
4313
+ const run = configuring ? configureChain.then(() => configureOnce(config)) : configureOnce(config);
4314
+ configureChain = run.catch(() => void 0);
4315
+ configuring = run;
4316
+ const settle = () => {
4317
+ if (configuring === run) configuring = null;
3619
4318
  };
3620
- state = nextState;
3621
- if (config.events?.batching) {
3622
- const queue = new EventBatchQueue(offlineStore(nextState), `event_outbox:${nextState.projectId}`, {
3623
- batchSize,
3624
- flushIntervalMs,
3625
- maxQueue,
3626
- onDrop: config.events.onDrop
3627
- }, () => ({
3628
- userId: nextState.identity.getAppUserId(),
3629
- anonymousId: nextState.identity.getAnonymousId(),
3630
- sessionToken: nextState.identity.getSessionToken()
3631
- }), (body, token) => nextState.http.postWithSession("/v1/client/events/batch", body, token), (body, token) => nextState.http.postWithSession(P.events, body, token));
3632
- nextState.eventQueue = queue;
3633
- await queue.start();
3634
- try {
3635
- const { AppState } = require("react-native");
3636
- const sub = AppState.addEventListener("change", (status) => {
3637
- if (status === "background") queue.flush();
3638
- });
3639
- nextState.detachEventAppState = () => sub.remove();
3640
- } catch {}
3641
- }
3642
- if (!nextState.functionsBaseUrl && !PROJECT_ID_PATTERN.test(nextState.projectId)) resolveFunctionsProjectId(nextState).catch(() => {});
3643
- if (config.autoAnonymousSession !== false && !identity.getSessionToken() && identity.getRefreshToken()) restoreStoredSession(nextState).catch(() => {});
4319
+ run.then(settle, settle);
4320
+ return run;
3644
4321
  },
3645
4322
  _reset: () => {
4323
+ if (state) {
4324
+ state.disposed = true;
4325
+ state.identity.dispose();
4326
+ }
3646
4327
  state?.detachEventAppState?.();
4328
+ state?.detachIdentityAppState?.();
3647
4329
  state?.eventQueue?.dispose();
3648
4330
  state = null;
3649
4331
  authSubscribers.clear();
@@ -3677,35 +4359,18 @@ const Amba = {
3677
4359
  },
3678
4360
  events: {
3679
4361
  track: async (event, properties, options) => {
3680
- const s = getState();
3681
- if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true);
3682
- const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
3683
- const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
3684
- const body = {
3685
- event,
3686
- properties: props ?? void 0,
3687
- anonymous_id: s.identity.getAnonymousId(),
3688
- timestamp: occurredAt
3689
- };
3690
- if (options?.telemetry) body["telemetry"] = true;
3691
- const idempotencyKey = s.offlineEnabled ? newIdempotencyKey() : void 0;
3692
- if (idempotencyKey && keyedLiveTrack(props, options?.telemetry)) body["idempotency_key"] = idempotencyKey;
3693
- const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
3694
- try {
3695
- await s.http.post(P.events, body);
3696
- } catch (err) {
3697
- if (s.offlineEnabled && isOfflineError(err)) {
3698
- await getOfflineQueue(s).enqueue({
3699
- kind: "track",
3700
- event,
3701
- properties: props,
3702
- telemetry: options?.telemetry,
3703
- occurredAt,
3704
- idempotencyKey
3705
- }, outboxTicket);
3706
- return;
4362
+ let s = getState();
4363
+ for (let attempt = 0;; attempt += 1) {
4364
+ const hold = (s.heldEvents?.length ?? 0) > 0 || await s.identity.anonymousIdStillUnknown();
4365
+ if (s.disposed && attempt === 0) {
4366
+ s = await liveState();
4367
+ continue;
3707
4368
  }
3708
- throw err;
4369
+ if (!hold) return sendEvent(s, event, properties, options);
4370
+ if (typeof event !== "string" || event.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "event name must be non-empty");
4371
+ holdEvent(s, event, properties, options);
4372
+ if (!s.identity.hasStandInAnonymousId()) releaseHeldEvents(s);
4373
+ return;
3709
4374
  }
3710
4375
  },
3711
4376
  flush: async () => getState().eventQueue?.flush() ?? {
@@ -3727,18 +4392,24 @@ const Amba = {
3727
4392
  },
3728
4393
  auth: {
3729
4394
  signInAnonymously: async () => {
3730
- const s = getState();
4395
+ const s = await liveState();
4396
+ await loadUnreadSession(s);
4397
+ if (await s.identity.anonymousIdStillUnknown()) throw secureStoreUnavailableError();
4398
+ const startedAt = s.identity.getGeneration();
3731
4399
  try {
3732
4400
  return await postAuth(s, P.authAnonymous, { anonymous_id: s.identity.getAnonymousId() });
3733
4401
  } catch (err) {
3734
4402
  if (!(err instanceof AmbaApiError) || err.code !== IDENTITY_SIGN_IN_REQUIRED) throw err;
3735
- return recoverFromHeldAnonymousId(s);
4403
+ const held = await continueHeldSession(s);
4404
+ if (held) return held;
4405
+ if (!await s.identity.clearSession(true, { unlessSignedInSince: startedAt })) throw sessionChangedError("a sign-in stored a newer session while this call was in flight");
4406
+ return postAuth(s, P.authAnonymous, { anonymous_id: s.identity.getAnonymousId() });
3736
4407
  }
3737
4408
  },
3738
4409
  signInWithEmail: async (email, password) => {
3739
4410
  requireEmail(email);
3740
4411
  if (password === "") throw new AmbaApiError("VALIDATION_ERROR", "password must not be empty");
3741
- return postAuth(getState(), P.authEmailLogin, {
4412
+ return postAuth(await liveState(), P.authEmailLogin, {
3742
4413
  email,
3743
4414
  password
3744
4415
  });
@@ -3746,14 +4417,14 @@ const Amba = {
3746
4417
  signUpWithEmail: async (email, password) => {
3747
4418
  requireEmail(email);
3748
4419
  requirePassword(password);
3749
- return postAuth(getState(), P.authEmailSignup, {
4420
+ return postAuth(await liveState(), P.authEmailSignup, {
3750
4421
  email,
3751
4422
  password
3752
4423
  });
3753
4424
  },
3754
4425
  signInWithApple: async (identityToken, options) => {
3755
4426
  if (identityToken.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "identity_token must not be empty");
3756
- return postAuth(getState(), P.authSocial, {
4427
+ return postAuth(await liveState(), P.authSocial, {
3757
4428
  provider: "apple",
3758
4429
  id_token: identityToken,
3759
4430
  ...nonceField(options)
@@ -3761,7 +4432,7 @@ const Amba = {
3761
4432
  },
3762
4433
  signInWithGoogle: async (idToken, options) => {
3763
4434
  if (idToken.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "id_token must not be empty");
3764
- return postAuth(getState(), P.authSocial, {
4435
+ return postAuth(await liveState(), P.authSocial, {
3765
4436
  provider: "google",
3766
4437
  id_token: idToken,
3767
4438
  ...nonceField(options)
@@ -3774,7 +4445,7 @@ const Amba = {
3774
4445
  verifyEmailOtp: async (email, code) => {
3775
4446
  requireEmail(email);
3776
4447
  requireOtpCode(code);
3777
- return postAuth(getState(), P.authEmailOtpVerify, {
4448
+ return postAuth(await liveState(), P.authEmailOtpVerify, {
3778
4449
  email,
3779
4450
  code
3780
4451
  });
@@ -3786,7 +4457,7 @@ const Amba = {
3786
4457
  verifySmsOtp: async (phone, code) => {
3787
4458
  requireE164Phone(phone);
3788
4459
  requireOtpCode(code);
3789
- return postAuth(getState(), P.authSmsOtpVerify, {
4460
+ return postAuth(await liveState(), P.authSmsOtpVerify, {
3790
4461
  phone,
3791
4462
  code
3792
4463
  });
@@ -3797,24 +4468,27 @@ const Amba = {
3797
4468
  },
3798
4469
  verifyMagicLink: async (token) => {
3799
4470
  if (token.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "token must not be empty");
3800
- return postAuth(getState(), P.authMagicLinkVerify, { token });
4471
+ return postAuth(await liveState(), P.authMagicLinkVerify, { token });
3801
4472
  },
3802
4473
  linkAccount: async (provider, credential, options) => {
3803
4474
  const providerTrimmed = provider?.trim() ?? "";
3804
4475
  if (providerTrimmed === "") throw new AmbaApiError("VALIDATION_ERROR", "provider must not be empty");
3805
4476
  if (typeof credential !== "string" || credential.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "credential must not be empty");
3806
- const s = getState();
4477
+ const s = await liveState();
3807
4478
  return withBodySession(s, (sessionToken) => trackLink(s, postAuth(s, P.authLink, {
3808
4479
  provider: providerTrimmed,
3809
4480
  token: credential,
3810
4481
  session_token: sessionToken,
3811
4482
  ...nonceField(options)
4483
+ }, {
4484
+ ifGeneration: s.identity.getGeneration(),
4485
+ advance: false
3812
4486
  })));
3813
4487
  },
3814
4488
  linkSmsOtp: async (phone, code) => {
3815
4489
  requireE164Phone(phone);
3816
4490
  requireOtpCode(code);
3817
- return postLink(getState(), {
4491
+ return postLink(await liveState(), {
3818
4492
  provider: "sms_otp",
3819
4493
  phone,
3820
4494
  code
@@ -3823,7 +4497,7 @@ const Amba = {
3823
4497
  linkEmailOtp: async (email, code) => {
3824
4498
  requireEmail(email);
3825
4499
  requireOtpCode(code);
3826
- return postLink(getState(), {
4500
+ return postLink(await liveState(), {
3827
4501
  provider: "email_otp",
3828
4502
  email,
3829
4503
  code
@@ -3832,7 +4506,7 @@ const Amba = {
3832
4506
  linkEmailPassword: async (email, password) => {
3833
4507
  requireEmail(email);
3834
4508
  requirePassword(password);
3835
- return postLink(getState(), {
4509
+ return postLink(await liveState(), {
3836
4510
  provider: "email_password",
3837
4511
  email,
3838
4512
  password
@@ -3845,34 +4519,29 @@ const Amba = {
3845
4519
  getCurrentIdentifiers: async () => {
3846
4520
  const s = getState();
3847
4521
  await settleSessionInFlight(s);
3848
- if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
4522
+ requireSession(s);
3849
4523
  return (await s.http.get(P.authIdentifiers)).data.identifiers;
3850
4524
  },
3851
4525
  signOut: async (rotateAnonymousId = false) => {
3852
- const s = getState();
4526
+ const s = await liveState();
4527
+ await loadUnreadSession(s);
4528
+ const startedAt = s.identity.getGeneration();
4529
+ const revoked = s.identity.getRefreshToken();
3853
4530
  if (s.identity.getSessionToken()) try {
3854
- const refreshToken = s.identity.getRefreshToken();
3855
- await s.http.post(P.authLogout, refreshToken ? { refresh_token: refreshToken } : {});
4531
+ await s.http.post(P.authLogout, revoked ? { refresh_token: revoked } : {});
3856
4532
  } catch (err) {
3857
4533
  if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
3858
4534
  }
3859
- const signedOutUser = s.identity.getAppUserId();
3860
- await s.identity.clearSession(rotateAnonymousId);
3861
- s.remoteValues.clearForSignOut(signedOutUser);
3862
- await s.remoteValues.flushed();
3863
- try {
3864
- await getOfflineQueue(s).purge();
3865
- } catch (err) {
3866
- if (typeof console !== "undefined") console.warn("[amba] signOut could not clear the offline outbox", err);
3867
- }
3868
- s.eventQueue?.flush();
3869
- notifyAuthSubscribers(null);
4535
+ if (await endSignedOutSession(s, rotateAnonymousId, startedAt)) return;
4536
+ if (!s.disposed || revoked === null) return;
4537
+ const next = await liveState();
4538
+ if (next !== s && !next.disposed && sameIdentityTarget(next, s) && next.identity.continuesLaunchSession(revoked)) await endSignedOutSession(next, rotateAnonymousId, next.identity.getGeneration());
3870
4539
  },
3871
- refresh: async () => refreshSession(getState()),
4540
+ refresh: async () => refreshSession(await liveState()),
3872
4541
  me: async () => {
3873
4542
  const s = getState();
3874
4543
  await settleSessionInFlight(s);
3875
- if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
4544
+ requireSession(s);
3876
4545
  return normalizeUserAuthProviders((await s.http.get(P.authMe)).data);
3877
4546
  },
3878
4547
  getSession: async () => {
@@ -3887,7 +4556,9 @@ const Amba = {
3887
4556
  }
3888
4557
  },
3889
4558
  getAnonymousId: async () => {
3890
- return getState().identity.getAnonymousId();
4559
+ const s = getState();
4560
+ if (await s.identity.anonymousIdStillUnknown()) throw secureStoreUnavailableError();
4561
+ return s.identity.getAnonymousId();
3891
4562
  },
3892
4563
  onAuthStateChange: (cb) => {
3893
4564
  authSubscribers.add(cb);
@@ -3899,17 +4570,19 @@ const Amba = {
3899
4570
  users: {
3900
4571
  get: async (userId) => {
3901
4572
  const s = getState();
3902
- if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
4573
+ await settleSessionInFlight(s);
4574
+ requireSession(s);
3903
4575
  const path = userId === void 0 || userId === null || userId === "" ? P.usersMe : `${P.users}/${encodePathSegment(userId)}`;
3904
4576
  return normalizeUserAuthProviders((await s.http.get(path)).data);
3905
4577
  },
3906
- update: ((arg1, arg2) => {
4578
+ update: (async (arg1, arg2) => {
3907
4579
  const s = getState();
3908
- if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
4580
+ await settleSessionInFlight(s);
4581
+ requireSession(s);
3909
4582
  const isById = typeof arg1 === "string";
3910
4583
  const patch = isById ? arg2 ?? {} : arg1;
3911
4584
  const path = isById ? `${P.users}/${encodePathSegment(arg1)}` : `${P.users}/me`;
3912
- return s.http.patch(path, patch).then((env) => normalizeUserAuthProviders(env.data));
4585
+ return normalizeUserAuthProviders((await s.http.patch(path, patch)).data);
3913
4586
  })
3914
4587
  },
3915
4588
  sessions: {