@teardown/react-native 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -75,7 +75,8 @@ export const IDENTIFY_STORAGE_KEY = "IDENTIFY_STATE";
75
75
 
76
76
  export class IdentityClient {
77
77
  private emitter = new EventEmitter<IdentifyStateChangeEvents>();
78
- private identifyState: IdentifyState;
78
+ private identifyState: IdentifyState = { type: "unidentified" };
79
+ private initialized = false;
79
80
 
80
81
  public readonly logger: Logger;
81
82
  public readonly utils: UtilsClient;
@@ -93,22 +94,46 @@ export class IdentityClient {
93
94
  });
94
95
  this.storage = storage.createStorage("identity");
95
96
  this.utils = utils;
96
- this.identifyState = this.getIdentifyStateFromStorage();
97
+ // Don't load from storage here - defer to initialize()
97
98
  }
98
99
 
99
100
  async initialize(): Promise<void> {
101
+ this.logger.debug("Initializing IdentityClient");
102
+ if (this.initialized) {
103
+ this.logger.debug("IdentityClient already initialized");
104
+ return;
105
+ }
106
+ this.initialized = true;
107
+
108
+ // Load state from storage first (for fallback if identify fails)
109
+ this.identifyState = this.getIdentifyStateFromStorage();
110
+ this.logger.debug(`Initialized with state: ${this.identifyState.type}`);
111
+
112
+ // Always identify on app boot to refresh version status
100
113
  await this.identify();
101
114
  }
102
115
 
103
116
  private getIdentifyStateFromStorage(): IdentifyState {
104
117
  const stored = this.storage.getItem(IDENTIFY_STORAGE_KEY);
118
+
105
119
  if (stored == null) {
106
- // console.log("no stored session state");
120
+ this.logger.debug("No stored identity state, returning unidentified");
121
+ return UnidentifiedSessionStateSchema.parse({ type: "unidentified" });
122
+ }
123
+
124
+ const parsed = IdentifyStateSchema.parse(JSON.parse(stored));
125
+ this.logger.debug(`Parsed identity state from storage: ${parsed.type}`);
126
+
127
+ // "identifying" is a transient state - if we restore it, treat as unidentified
128
+ // This can happen if the app was killed during an identify call
129
+ if (parsed.type === "identifying") {
130
+ this.logger.debug("Found stale 'identifying' state in storage, resetting to unidentified");
131
+ // Clear the stale state from storage immediately
132
+ this.storage.removeItem(IDENTIFY_STORAGE_KEY);
107
133
  return UnidentifiedSessionStateSchema.parse({ type: "unidentified" });
108
134
  }
109
135
 
110
- // console.log("stored session state", stored);
111
- return IdentifyStateSchema.parse(JSON.parse(stored));
136
+ return parsed;
112
137
  }
113
138
 
114
139
  private saveIdentifyStateToStorage(identifyState: IdentifyState): void {
@@ -116,7 +141,13 @@ export class IdentityClient {
116
141
  }
117
142
 
118
143
  private setIdentifyState(newState: IdentifyState): void {
119
- this.logger.info(`Identify state: ${this.identifyState.type} -> ${newState.type}`);
144
+
145
+ if (this.identifyState.type === newState.type) {
146
+ this.logger.debug(`Identify state already set: ${this.identifyState.type}`);
147
+ return;
148
+ }
149
+
150
+ this.logger.debug(`Identify state: ${this.identifyState.type} -> ${newState.type}`);
120
151
  this.identifyState = newState;
121
152
  this.saveIdentifyStateToStorage(newState);
122
153
  this.emitter.emit("IDENTIFY_STATE_CHANGED", newState);
@@ -165,11 +196,14 @@ export class IdentityClient {
165
196
  * @param fn - The function to try
166
197
  * @returns An {@link AsyncResult}
167
198
  */
168
- private async tryCatch<T>(fn: () => AsyncResult<T>): AsyncResult<T> {
199
+ private async tryCatch<T>(fn: () => AsyncResult<T>, onError?: (error: Error) => void): AsyncResult<T> {
169
200
  try {
170
201
  const result = await fn();
171
202
  return result;
172
203
  } catch (error) {
204
+ if (onError) {
205
+ onError(error instanceof Error ? error : new Error(String(error)));
206
+ }
173
207
  return {
174
208
  success: false,
175
209
  error: error instanceof Error ? error.message : "Unknown error",
@@ -178,17 +212,15 @@ export class IdentityClient {
178
212
  }
179
213
 
180
214
  async identify(persona?: Persona): AsyncResult<IdentityUser> {
215
+ this.logger.debug(`Identifying user with persona: ${persona?.name ?? "none"}`);
181
216
  const previousState = this.identifyState;
182
217
  this.setIdentifyState({ type: "identifying" });
183
218
 
184
219
  return this.tryCatch(async () => {
220
+ this.logger.debug("Getting device ID...");
185
221
  const deviceId = await this.device.getDeviceId();
186
222
  const deviceInfo = await this.device.getDeviceInfo();
187
- console.log("this.api.apiKey", this.api.apiKey);
188
- console.log("this.api.orgId", this.api.orgId);
189
- console.log("this.api.projectId", this.api.projectId);
190
- console.log("this.api.environmentSlug", this.api.environmentSlug);
191
- console.log("this.api.deviceId", deviceId);
223
+ this.logger.debug("Calling identify API...");
192
224
  const response = await this.api.client("/v1/identify", {
193
225
  method: "POST",
194
226
  headers: {
@@ -200,20 +232,20 @@ export class IdentityClient {
200
232
  },
201
233
  body: {
202
234
  persona,
235
+ // @ts-expect-error - notifications is not yet implemented
203
236
  device: {
204
- ...deviceInfo,
205
- update: deviceInfo.update
206
- ? {
207
- ...deviceInfo.update,
208
- created_at: deviceInfo.update.created_at,
209
- }
210
- : null,
237
+ timestamp: deviceInfo.timestamp,
238
+ os: deviceInfo.os,
239
+ application: deviceInfo.application,
240
+ hardware: deviceInfo.hardware,
241
+ update: null,
211
242
  },
212
243
  },
213
244
  });
214
245
 
246
+ this.logger.debug(`Identify API response received`);
215
247
  if (response.error != null) {
216
- console.log("identify error", response.error.status, response.error.value);
248
+ this.logger.warn("Identify API error", response.error.status, response.error.value);
217
249
  this.setIdentifyState(previousState);
218
250
 
219
251
  if (response.error.status === 422) {
@@ -250,6 +282,13 @@ export class IdentityClient {
250
282
  },
251
283
  },
252
284
  };
285
+ }, (error) => {
286
+ this.logger.error("Error identifying user", error);
287
+ this.setIdentifyState(previousState);
288
+ return {
289
+ success: false,
290
+ error: error.message,
291
+ };
253
292
  });
254
293
  }
255
294
  }
@@ -0,0 +1,81 @@
1
+ import AsyncStorage from "@react-native-async-storage/async-storage";
2
+ import { StorageAdapter, type SupportedStorage } from "./storage.adpater-interface";
3
+
4
+
5
+ /**
6
+ * Creates a SupportedStorage adapter backed by AsyncStorage.
7
+ *
8
+ * Since SupportedStorage interface is synchronous but AsyncStorage is async,
9
+ * this adapter uses an in-memory cache for sync access. Writes are persisted
10
+ * to AsyncStorage asynchronously.
11
+ *
12
+ * Call `preload()` after creation to hydrate the cache from AsyncStorage.
13
+ * Until hydration completes, reads return null for persisted values.
14
+ */
15
+
16
+ export class AsyncStorageAdapter extends StorageAdapter {
17
+ createStorage(storageKey: string): SupportedStorage {
18
+ let cache: Record<string, string> = {};
19
+ let hydrated = false;
20
+
21
+ const prefixedKey = (key: string): string => `${storageKey}:${key}`;
22
+
23
+ return {
24
+ preload: () => {
25
+ if (hydrated) return;
26
+
27
+ // Fire async hydration - cache will be populated when complete
28
+ AsyncStorage.getAllKeys()
29
+ .then((allKeys) => {
30
+ const relevantKeys = allKeys.filter((k) =>
31
+ k.startsWith(`${storageKey}:`)
32
+ );
33
+ return AsyncStorage.multiGet(relevantKeys);
34
+ })
35
+ .then((pairs) => {
36
+ for (const [fullKey, value] of pairs) {
37
+ if (value != null) {
38
+ const key = fullKey.replace(`${storageKey}:`, "");
39
+ cache[key] = value;
40
+ }
41
+ }
42
+ hydrated = true;
43
+ })
44
+ .catch(() => {
45
+ // Silently fail - cache remains empty
46
+ });
47
+ },
48
+
49
+ getItem: (key: string): string | null => {
50
+ return cache[key] ?? null;
51
+ },
52
+
53
+ setItem: (key: string, value: string): void => {
54
+ cache[key] = value;
55
+ AsyncStorage.setItem(prefixedKey(key), value).catch(() => {
56
+ // Silently fail - value remains in cache
57
+ });
58
+ },
59
+
60
+ removeItem: (key: string): void => {
61
+ delete cache[key];
62
+ AsyncStorage.removeItem(prefixedKey(key)).catch(() => {
63
+ // Silently fail
64
+ });
65
+ },
66
+
67
+ clear: (): void => {
68
+ const keysToRemove = Object.keys(cache).map(prefixedKey);
69
+ cache = {};
70
+ AsyncStorage.multiRemove(keysToRemove).catch(() => {
71
+ // Silently fail
72
+ });
73
+ },
74
+
75
+ keys: (): string[] => {
76
+ return Object.keys(cache);
77
+ },
78
+ };
79
+ }
80
+
81
+ }
@@ -1,14 +1,10 @@
1
- import type { SupportedStorageFactory } from "./storage.client";
2
1
  import * as MMKV from "react-native-mmkv";
2
+ import { StorageAdapter, type SupportedStorage } from "./storage.adpater-interface";
3
3
 
4
- /**
5
- * Creates a storage factory that uses MMKV for persistence.
6
- * Each storage key gets its own MMKV instance.
7
- */
8
- export const createMMKVStorageFactory = (): SupportedStorageFactory => {
9
- return (storageKey: string) => {
10
- const storage = MMKV.createMMKV({ id: storageKey });
11
4
 
5
+ export class MMKVStorageAdapter extends StorageAdapter {
6
+ createStorage(storageKey: string): SupportedStorage {
7
+ const storage = MMKV.createMMKV({ id: storageKey });
12
8
  return {
13
9
  preload: () => {
14
10
  storage.getAllKeys();
@@ -19,5 +15,6 @@ export const createMMKVStorageFactory = (): SupportedStorageFactory => {
19
15
  clear: () => storage.clearAll(),
20
16
  keys: () => storage.getAllKeys(),
21
17
  };
22
- };
23
- };
18
+ }
19
+
20
+ }
@@ -0,0 +1,30 @@
1
+
2
+ /**
3
+ * A storage interface that is used to store data.
4
+ */
5
+ export type SupportedStorage = {
6
+ preload: () => void;
7
+ getItem: (key: string) => string | null;
8
+ setItem: (key: string, value: string) => void;
9
+ removeItem: (key: string) => void;
10
+ clear: () => void;
11
+ keys: () => string[];
12
+ };
13
+
14
+ /**
15
+ * An interface for a storage adapter.
16
+ * This interface is used to abstract the storage adapter implementation.
17
+ */
18
+ export abstract class StorageAdapter {
19
+
20
+ /**
21
+ * Creates a storage instance for a given storage key.
22
+ * @param storageKey - The key to create the storage instance for.
23
+ * @returns A storage instance.
24
+ *
25
+ * We can have multiple storage instances for different purposes. Hence the storage key is used to create the storage instance.
26
+ */
27
+ abstract createStorage(storageKey: string): SupportedStorage;
28
+ }
29
+
30
+
@@ -1 +1,2 @@
1
- export * from "./storage.client";
1
+ export * from "./adapters/storage.adpater-interface";
2
+ export * from "./storage.client";
@@ -1,19 +1,6 @@
1
1
  import type { Logger, LoggingClient } from "../logging";
2
+ import type { StorageAdapter, SupportedStorage } from "./adapters/storage.adpater-interface";
2
3
 
3
- export type SupportedStorage = {
4
- preload: () => void;
5
- getItem: (key: string) => string | null;
6
- setItem: (key: string, value: string) => void;
7
- removeItem: (key: string) => void;
8
- clear: () => void;
9
- keys: () => string[];
10
- };
11
-
12
- export type SupportedStorageFactory = (storageKey: string) => SupportedStorage;
13
-
14
- export type StorageClientOptions = {
15
- createStorage: SupportedStorageFactory;
16
- };
17
4
 
18
5
  export class StorageClient {
19
6
 
@@ -21,14 +8,18 @@ export class StorageClient {
21
8
 
22
9
  private readonly storage: Map<string, SupportedStorage> = new Map();
23
10
 
24
- constructor(logging: LoggingClient, private readonly factory: SupportedStorageFactory) {
11
+ constructor(
12
+ logging: LoggingClient,
13
+ private readonly orgId: string,
14
+ private readonly projectId: string,
15
+ private readonly storageAdapter: StorageAdapter) {
25
16
  this.logger = logging.createLogger({
26
17
  name: "StorageClient",
27
18
  });
28
19
  }
29
20
 
30
21
  private createStorageKey(storageKey: string): string {
31
- return `teardown:v1:${storageKey}`;
22
+ return `teardown:v1:${this.orgId}:${this.projectId}:${storageKey}`;
32
23
  }
33
24
 
34
25
  createStorage(storageKey: string): SupportedStorage {
@@ -48,20 +39,18 @@ export class StorageClient {
48
39
  }
49
40
 
50
41
  this.logger.debug(`Creating new storage for ${fullStorageKey}`);
51
- const newStorage = this.factory(fullStorageKey);
42
+ const newStorage = this.storageAdapter.createStorage(fullStorageKey);
52
43
  newStorage.preload();
53
44
 
54
-
55
45
  const remappedStorage = {
56
46
  ...newStorage,
57
47
  clear: () => {
58
- this.logger.debug(`Clearing storage for ${fullStorageKey}`);
59
48
  this.storage.delete(fullStorageKey);
60
49
  },
61
50
  }
62
51
 
63
52
  this.storage.set(fullStorageKey, remappedStorage);
64
- this.logger.info(`Storage created for ${fullStorageKey}`);
53
+ this.logger.debug(`Storage created for ${fullStorageKey}`);
65
54
 
66
55
  return remappedStorage;
67
56
  }
@@ -1,6 +1,6 @@
1
1
  import type { Logger, LoggingClient } from "../logging";
2
+ import 'react-native-get-random-values';
2
3
  import { v4 as uuidv4 } from "uuid";
3
-
4
4
  export class UtilsClient {
5
5
  private readonly logger: Logger;
6
6
 
@@ -15,61 +15,5 @@ export class UtilsClient {
15
15
  const uuid = uuidv4();
16
16
  this.logger.debug(`Random UUID generated: ${uuid}`);
17
17
  return uuid;
18
- // this.logger.debug("Generating random UUID");
19
- // // Generate a random UUID v4 (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)
20
- // // Uses only Math.random and string manipulation, no dependencies.
21
- // const hex: string[] = [];
22
- // for (let i = 0; i < 256; ++i) {
23
- // hex.push((i < 16 ? "0" : "") + i.toString(16));
24
- // }
25
-
26
- // // Seeded random number generator using mulberry32
27
- // let seedValue = 0;
28
- // if (seed) {
29
- // // Simple hash function to convert seed string to number
30
- // for (let i = 0; i < seed.length; i++) {
31
- // seedValue = ((seedValue << 5) - seedValue + seed.charCodeAt(i)) | 0;
32
- // }
33
- // seedValue = Math.abs(seedValue);
34
- // }
35
-
36
- // const seededRandom = (): number => {
37
- // seedValue = (seedValue + 0x6d2b79f5) | 0;
38
- // let t = Math.imul(seedValue ^ (seedValue >>> 15), 1 | seedValue);
39
- // t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
40
- // return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
41
- // };
42
-
43
- // const getRandomByte = (): number => Math.floor((seed ? seededRandom() : Math.random()) * 256);
44
- // const rnds = new Array(16).fill(0).map(getRandomByte);
45
-
46
- // // Per spec:
47
- // rnds[6] = (rnds[6]! & 0x0f) | 0x40; // version 4
48
- // rnds[8] = (rnds[8]! & 0x3f) | 0x80; // variant 10
49
-
50
- // const uuid =
51
- // hex[rnds[0]!]! +
52
- // hex[rnds[1]!]! +
53
- // hex[rnds[2]!]! +
54
- // hex[rnds[3]!]! +
55
- // "-" +
56
- // hex[rnds[4]!]! +
57
- // hex[rnds[5]!]! +
58
- // "-" +
59
- // hex[rnds[6]!]! +
60
- // hex[rnds[7]!]! +
61
- // "-" +
62
- // hex[rnds[8]!]! +
63
- // hex[rnds[9]!]! +
64
- // "-" +
65
- // hex[rnds[10]!]! +
66
- // hex[rnds[11]!]! +
67
- // hex[rnds[12]!]! +
68
- // hex[rnds[13]!]! +
69
- // hex[rnds[14]!]! +
70
- // hex[rnds[15]!]!;
71
-
72
- // this.logger.debug(`Random UUID generated: ${uuid}`);
73
- // return uuid;
74
18
  }
75
19
  }
@@ -0,0 +1 @@
1
+ export * from "../../clients/storage/adapters/async-storage.adapter";
@@ -0,0 +1 @@
1
+ export * from "../../clients/device/adapters/expo.adapter";
@@ -0,0 +1 @@
1
+ export * from "../../clients/storage/adapters/mmkv.adapter";
@@ -12,7 +12,11 @@ export type UseForceUpdateResult = {
12
12
  */
13
13
  isUpdateAvailable: boolean;
14
14
  /**
15
- * Whether the the current version is out of date and is forced to be updated. "force_update" - isUpdateAvailable will also be true if this is true.
15
+ * Whether an update is recommended for this version, but is not required.
16
+ */
17
+ isUpdateRecommended: boolean;
18
+ /**
19
+ * Whether the the current version is out of date and is forced to be updated.
16
20
  */
17
21
  isUpdateRequired: boolean;
18
22
  };
@@ -29,10 +33,15 @@ export const useForceUpdate = (): UseForceUpdateResult => {
29
33
  return unsubscribe;
30
34
  }, [core.forceUpdate]);
31
35
 
36
+ const isUpdateRequired = versionStatus.type === "update_required";
37
+ const isUpdateRecommended = versionStatus.type === "update_recommended";
38
+ const isUpdateAvailable = isUpdateRequired || isUpdateRecommended || (versionStatus.type === "update_available");
39
+
32
40
  return {
33
41
  versionStatus,
34
- isUpdateRequired: versionStatus.type === "update_required",
35
- isUpdateAvailable: versionStatus.type === "update_available" || versionStatus.type === "update_required" || versionStatus.type === "update_recommended",
42
+ isUpdateRequired,
43
+ isUpdateRecommended,
44
+ isUpdateAvailable,
36
45
  };
37
46
  };
38
47
 
@@ -13,10 +13,13 @@ export const useSession = (): UseSessionResult => {
13
13
 
14
14
  useEffect(() => {
15
15
  const unsubscribe = core.identity.onIdentifyStateChange((state) => {
16
- if (state.type === "identified") {
17
- setSession(state.session);
18
- } else {
19
- setSession(null);
16
+ switch (state.type) {
17
+ case "identified":
18
+ setSession(state.session);
19
+ break;
20
+ case "unidentified":
21
+ setSession(null);
22
+ break;
20
23
  }
21
24
  });
22
25
  return unsubscribe;
@@ -2,8 +2,8 @@ import { ApiClient } from "./clients/api";
2
2
  import { DeviceClient, type DeviceClientOptions } from "./clients/device/device.client";
3
3
  import { ForceUpdateClient, type ForceUpdateClientOptions } from "./clients/force-update";
4
4
  import { IdentityClient } from "./clients/identity";
5
- import { type LogLevel, LoggingClient, type Logger } from "./clients/logging";
6
- import { StorageClient, type SupportedStorageFactory } from "./clients/storage";
5
+ import { LoggingClient, type LogLevel, type Logger } from "./clients/logging";
6
+ import { StorageClient, type StorageAdapter } from "./clients/storage";
7
7
  import { UtilsClient } from "./clients/utils/utils.client";
8
8
 
9
9
  export type TeardownCoreOptions = {
@@ -11,7 +11,7 @@ export type TeardownCoreOptions = {
11
11
  project_id: string;
12
12
  api_key: string;
13
13
  // environment_slug: string; // TODO: add this back in
14
- storageFactory: SupportedStorageFactory;
14
+ storageAdapter: StorageAdapter;
15
15
  deviceAdapter: DeviceClientOptions["adapter"];
16
16
  forceUpdate?: ForceUpdateClientOptions;
17
17
  };
@@ -30,13 +30,18 @@ export class TeardownCore {
30
30
  this.options = options;
31
31
 
32
32
  this.logging = new LoggingClient();
33
- // this.setLogLevel("verbose");
33
+ this.setLogLevel("verbose");
34
34
 
35
35
  this.logger = this.logging.createLogger({
36
36
  name: "TeardownCore",
37
37
  });
38
38
  this.utils = new UtilsClient(this.logging);
39
- this.storage = new StorageClient(this.logging, this.options.storageFactory);
39
+ this.storage = new StorageClient(
40
+ this.logging,
41
+ this.options.org_id,
42
+ this.options.project_id,
43
+ this.options.storageAdapter
44
+ );
40
45
  this.api = new ApiClient(this.logging, this.storage, {
41
46
  org_id: this.options.org_id,
42
47
  project_id: this.options.project_id,
@@ -57,12 +62,17 @@ export class TeardownCore {
57
62
 
58
63
  void this.initialize().catch((error) => {
59
64
  this.logger.error("Error initializing TeardownCore", { error });
65
+ }).then(() => {
66
+ this.logger.debug("TeardownCore initialized");
60
67
  });
61
68
 
62
69
  }
63
70
 
64
71
  async initialize(): Promise<void> {
72
+ // Initialize identity first (loads from storage, then identifies if needed)
65
73
  await this.identity.initialize();
74
+ // Then initialize force update (subscribes to identity events)
75
+ this.forceUpdate.initialize();
66
76
  }
67
77
 
68
78
  setLogLevel(level: LogLevel): void {
@@ -70,7 +80,7 @@ export class TeardownCore {
70
80
  }
71
81
 
72
82
  shutdown(): void {
73
- this.logger.info("Shutting down TeardownCore");
83
+ this.logger.debug("Shutting down TeardownCore");
74
84
  this.storage.shutdown();
75
85
  }
76
86
  }
@@ -1 +0,0 @@
1
- export * from "../clients/device/expo-adapter";
@@ -1 +0,0 @@
1
- export * from "../clients/storage/mmkv-adapter";