From daebf7f240ba9754d82711bbc653c72761cf7dff Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 16:19:21 -0600 Subject: [PATCH] feat: add lifecycle storage foundation [rn] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated AsyncStorage namespace (`metarouter:lifecycle:*`) for persisting last-seen `(version, build)`. Lives in its own module so neither `IdentityManager.reset()` nor `MetaRouterAnalyticsClient.reset()` can clear install/update history — lifecycle state is device-scope, not user-scope (parity with iOS `LifecycleStorage` and Android equivalent). Storage failures are swallowed and treated as 'no prior lifecycle state' so the caller can safely fall through to fresh-install or upgrade detection without try/catch noise at every read site. Slice 1 of 4 in the RN lifecycle stack (sc-36800). --- src/analytics/utils/lifecycleStorage.test.ts | 73 ++++++++++++++++++++ src/analytics/utils/lifecycleStorage.ts | 40 +++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/analytics/utils/lifecycleStorage.test.ts create mode 100644 src/analytics/utils/lifecycleStorage.ts diff --git a/src/analytics/utils/lifecycleStorage.test.ts b/src/analytics/utils/lifecycleStorage.test.ts new file mode 100644 index 0000000..4b985f3 --- /dev/null +++ b/src/analytics/utils/lifecycleStorage.test.ts @@ -0,0 +1,73 @@ +import { + getLifecycleVersion, + getLifecycleBuild, + setLifecycleVersionBuild, + LIFECYCLE_VERSION_KEY, + LIFECYCLE_BUILD_KEY, +} from './lifecycleStorage'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), +})); + +describe('lifecycleStorage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('reads the lifecycle version key from AsyncStorage', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue('1.4.0'); + + const version = await getLifecycleVersion(); + + expect(version).toBe('1.4.0'); + expect(AsyncStorage.getItem).toHaveBeenCalledWith(LIFECYCLE_VERSION_KEY); + }); + + it('reads the lifecycle build key from AsyncStorage', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue('42'); + + const build = await getLifecycleBuild(); + + expect(build).toBe('42'); + expect(AsyncStorage.getItem).toHaveBeenCalledWith(LIFECYCLE_BUILD_KEY); + }); + + it('returns null when version key is missing', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + + expect(await getLifecycleVersion()).toBeNull(); + expect(await getLifecycleBuild()).toBeNull(); + }); + + it('returns null when AsyncStorage throws on read', async () => { + (AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('boom')); + + expect(await getLifecycleVersion()).toBeNull(); + expect(await getLifecycleBuild()).toBeNull(); + }); + + it('writes both version and build to AsyncStorage', async () => { + (AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined); + + await setLifecycleVersionBuild('1.4.0', '42'); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + LIFECYCLE_VERSION_KEY, + '1.4.0' + ); + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + LIFECYCLE_BUILD_KEY, + '42' + ); + }); + + it('does not throw if write fails', async () => { + (AsyncStorage.setItem as jest.Mock).mockRejectedValue(new Error('fail')); + + await expect(setLifecycleVersionBuild('1.0', '1')).resolves.toBeUndefined(); + }); +}); diff --git a/src/analytics/utils/lifecycleStorage.ts b/src/analytics/utils/lifecycleStorage.ts new file mode 100644 index 0000000..0cc85b2 --- /dev/null +++ b/src/analytics/utils/lifecycleStorage.ts @@ -0,0 +1,40 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export const LIFECYCLE_VERSION_KEY = 'metarouter:lifecycle:version'; +export const LIFECYCLE_BUILD_KEY = 'metarouter:lifecycle:build'; + +/** + * Storage for app lifecycle state (last-seen version + build). Lives in a + * dedicated module so neither IdentityManager.reset() nor the client's reset() + * can wipe install/update history. Errors are swallowed so missing or + * unavailable storage is treated as "no prior lifecycle state" — the caller + * decides whether that means install or update. + */ + +export async function getLifecycleVersion(): Promise { + try { + return await AsyncStorage.getItem(LIFECYCLE_VERSION_KEY); + } catch { + return null; + } +} + +export async function getLifecycleBuild(): Promise { + try { + return await AsyncStorage.getItem(LIFECYCLE_BUILD_KEY); + } catch { + return null; + } +} + +export async function setLifecycleVersionBuild( + version: string, + build: string +): Promise { + try { + await AsyncStorage.setItem(LIFECYCLE_VERSION_KEY, version); + await AsyncStorage.setItem(LIFECYCLE_BUILD_KEY, build); + } catch { + // best-effort; cold-launch state will be re-derived on next run + } +}