From d9a45d87bdbadfbf5d59a391544f10bd5c61cbcc Mon Sep 17 00:00:00 2001 From: lovesworking Date: Mon, 26 May 2025 09:44:36 -0600 Subject: [PATCH] Add support for env and local storage --- README.md | 57 +- src/react-query-external-sync/User.ts | 2 + src/react-query-external-sync/hooks/index.ts | 23 + .../hooks/storageQueryKeys.ts | 38 + .../hooks/useDynamicAsyncStorageQueries.ts | 237 ++++++ .../hooks/useDynamicEnvQueries.ts | 97 +++ .../hooks/useDynamicMmkvQueries.ts | 130 +++ .../hooks/useDynamicSecureStorageQueries.ts | 331 ++++++++ .../hooks/useStorageQueries.ts | 136 +++ src/react-query-external-sync/hydration.ts | 10 +- src/react-query-external-sync/types.ts | 15 +- src/react-query-external-sync/useMySocket.ts | 141 ++-- .../useSyncQueriesExternal.ts | 776 +++++++++++++++--- src/react-query-external-sync/utils/logger.ts | 353 +++++++- .../utils/storageHandlers.ts | 327 ++++++++ 15 files changed, 2400 insertions(+), 273 deletions(-) create mode 100644 src/react-query-external-sync/hooks/index.ts create mode 100644 src/react-query-external-sync/hooks/storageQueryKeys.ts create mode 100644 src/react-query-external-sync/hooks/useDynamicAsyncStorageQueries.ts create mode 100644 src/react-query-external-sync/hooks/useDynamicEnvQueries.ts create mode 100644 src/react-query-external-sync/hooks/useDynamicMmkvQueries.ts create mode 100644 src/react-query-external-sync/hooks/useDynamicSecureStorageQueries.ts create mode 100644 src/react-query-external-sync/hooks/useStorageQueries.ts create mode 100644 src/react-query-external-sync/utils/storageHandlers.ts diff --git a/README.md b/README.md index 5e896a9..e35e656 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # React Query External Sync -A powerful debugging tool for React Query state in any React-based application. Whether you're building for mobile, web, desktop, TV, or VR - this package has you covered. It works seamlessly across all platforms where React runs, with zero configuration to disable in production. +A powerful debugging tool for React Query state and device storage in any React-based application. Whether you're building for mobile, web, desktop, TV, or VR - this package has you covered. It works seamlessly across all platforms where React runs, with zero configuration to disable in production. Pairs perfectly with [React Native DevTools](https://github.com/LovesWorking/rn-better-dev-tools) for a complete development experience. @@ -9,6 +9,7 @@ Pairs perfectly with [React Native DevTools](https://github.com/LovesWorking/rn- ## ✨ Features - 🔄 Real-time React Query state synchronization +- 💾 **Device storage monitoring with CRUD operations** - MMKV, AsyncStorage, and SecureStorage - 📱 Works with any React-based framework (React, React Native, Expo, Next.js, etc.) - 🖥️ Platform-agnostic: Web, iOS, Android, macOS, Windows, Linux, tvOS, VR - you name it! - 🔌 Socket.IO integration for reliable communication @@ -37,8 +38,10 @@ Add the hook to your application where you set up your React Query context: ```jsx import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useSyncQueriesExternal } from "react-query-external-sync"; -// Import Platform for React Native or use other platform detection for web/desktop import { Platform } from "react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as SecureStore from "expo-secure-store"; +import { storage } from "./mmkv"; // Your MMKV instance // Create your query client const queryClient = new QueryClient(); @@ -52,19 +55,30 @@ function App() { } function AppContent() { - // Set up the sync hook - automatically disabled in production! + // Unified storage queries and external sync - all in one hook! useSyncQueriesExternal({ queryClient, - socketURL: "http://localhost:42831", // Default port for React Native DevTools - deviceName: Platform?.OS || "web", // Platform detection - platform: Platform?.OS || "web", // Use appropriate platform identifier - deviceId: Platform?.OS || "web", // Use a PERSISTENT identifier (see note below) + socketURL: "http://localhost:42831", + deviceName: Platform.OS, + platform: Platform.OS, + deviceId: Platform.OS, extraDeviceInfo: { - // Optional additional info about your device appVersion: "1.0.0", - // Add any relevant platform info }, - enableLogs: false, + enableLogs: true, + envVariables: { + NODE_ENV: process.env.NODE_ENV, + }, + // Storage monitoring with CRUD operations + mmkvStorage: storage, // MMKV storage for ['#storage', 'mmkv', 'key'] queries + monitoring + asyncStorage: AsyncStorage, // AsyncStorage for ['#storage', 'async', 'key'] queries + monitoring + secureStorage: SecureStore, // SecureStore for ['#storage', 'secure', 'key'] queries + monitoring + secureStorageKeys: [ + "userToken", + "refreshToken", + "biometricKey", + "deviceId", + ], // SecureStore keys to monitor }); // Your app content @@ -107,15 +121,20 @@ For the best experience, use this package with the [React Native DevTools](https The `useSyncQueriesExternal` hook accepts the following options: -| Option | Type | Required | Description | -| ----------------- | ----------- | -------- | ----------------------------------------------------------------------- | -| `queryClient` | QueryClient | Yes | Your React Query client instance | -| `socketURL` | string | Yes | URL of the socket server (e.g., 'http://localhost:42831') | -| `deviceName` | string | Yes | Human-readable name for your device | -| `platform` | string | Yes | Platform identifier ('ios', 'android', 'web', 'macos', 'windows', etc.) | -| `deviceId` | string | Yes | Unique identifier for your device | -| `extraDeviceInfo` | object | No | Additional device metadata to display in DevTools | -| `enableLogs` | boolean | No | Enable console logging for debugging (default: false) | +| Option | Type | Required | Description | +| ------------------- | ------------ | -------- | ----------------------------------------------------------------------- | +| `queryClient` | QueryClient | Yes | Your React Query client instance | +| `socketURL` | string | Yes | URL of the socket server (e.g., 'http://localhost:42831') | +| `deviceName` | string | Yes | Human-readable name for your device | +| `platform` | string | Yes | Platform identifier ('ios', 'android', 'web', 'macos', 'windows', etc.) | +| `deviceId` | string | Yes | Unique identifier for your device | +| `extraDeviceInfo` | object | No | Additional device metadata to display in DevTools | +| `enableLogs` | boolean | No | Enable console logging for debugging (default: false) | +| `envVariables` | object | No | Environment variables to sync with DevTools | +| `mmkvStorage` | MmkvStorage | No | MMKV storage instance for real-time monitoring | +| `asyncStorage` | AsyncStorage | No | AsyncStorage instance for polling-based monitoring | +| `secureStorage` | SecureStore | No | SecureStore instance for secure data monitoring | +| `secureStorageKeys` | string[] | No | Array of SecureStore keys to monitor (required if using secureStorage) | ## 🐛 Troubleshooting diff --git a/src/react-query-external-sync/User.ts b/src/react-query-external-sync/User.ts index a4f4dfe..03593aa 100644 --- a/src/react-query-external-sync/User.ts +++ b/src/react-query-external-sync/User.ts @@ -3,4 +3,6 @@ export interface User { deviceName: string; deviceId?: string; // Optional for backward compatibility platform?: string; // Device platform (iOS, Android, Web) + extraDeviceInfo?: string; // json string of additional device information as key-value pairs + envVariables?: Record; // Environment variables from the mobile app } diff --git a/src/react-query-external-sync/hooks/index.ts b/src/react-query-external-sync/hooks/index.ts new file mode 100644 index 0000000..9b8ff70 --- /dev/null +++ b/src/react-query-external-sync/hooks/index.ts @@ -0,0 +1,23 @@ +// Storage Query Keys +export { storageQueryKeys } from './storageQueryKeys'; + +// Individual Storage Hooks +export { + type AsyncStorageQueryResult, + useDynamicAsyncStorageQueries, + type UseDynamicAsyncStorageQueriesOptions, +} from './useDynamicAsyncStorageQueries'; +export { + type MmkvQueryResult, + type MmkvStorage, + useDynamicMmkvQueries, + type UseDynamicMmkvQueriesOptions, +} from './useDynamicMmkvQueries'; +export { + type SecureStorageQueryResult, + useDynamicSecureStorageQueries, + type UseDynamicSecureStorageQueriesOptions, +} from './useDynamicSecureStorageQueries'; + +// Unified Storage Hook +export { type StorageQueryResults, useStorageQueries, type UseStorageQueriesOptions } from './useStorageQueries'; diff --git a/src/react-query-external-sync/hooks/storageQueryKeys.ts b/src/react-query-external-sync/hooks/storageQueryKeys.ts new file mode 100644 index 0000000..12dcecd --- /dev/null +++ b/src/react-query-external-sync/hooks/storageQueryKeys.ts @@ -0,0 +1,38 @@ +/** + * Centralized storage query keys for all storage hooks + * This ensures consistency across MMKV, AsyncStorage, and SecureStorage hooks + * and allows easy modification of the base storage key in one place + */ +export const storageQueryKeys = { + /** + * Base storage key - change this to update all storage-related queries + */ + base: () => ['#storage'] as const, + + /** + * MMKV storage query keys + */ + mmkv: { + root: () => [...storageQueryKeys.base(), 'mmkv'] as const, + key: (key: string) => [...storageQueryKeys.mmkv.root(), key] as const, + all: () => [...storageQueryKeys.mmkv.root(), 'all'] as const, + }, + + /** + * AsyncStorage query keys + */ + async: { + root: () => [...storageQueryKeys.base(), 'async'] as const, + key: (key: string) => [...storageQueryKeys.async.root(), key] as const, + all: () => [...storageQueryKeys.async.root(), 'all'] as const, + }, + + /** + * SecureStorage query keys + */ + secure: { + root: () => [...storageQueryKeys.base(), 'secure'] as const, + key: (key: string) => [...storageQueryKeys.secure.root(), key] as const, + all: () => [...storageQueryKeys.secure.root(), 'all'] as const, + }, +} as const; diff --git a/src/react-query-external-sync/hooks/useDynamicAsyncStorageQueries.ts b/src/react-query-external-sync/hooks/useDynamicAsyncStorageQueries.ts new file mode 100644 index 0000000..a682c42 --- /dev/null +++ b/src/react-query-external-sync/hooks/useDynamicAsyncStorageQueries.ts @@ -0,0 +1,237 @@ +import { useEffect, useMemo, useState } from 'react'; +import { QueryClient, useQueries } from '@tanstack/react-query'; + +import { storageQueryKeys } from './storageQueryKeys'; + +/** + * AsyncStorage static interface (from @react-native-async-storage/async-storage) + */ +export interface AsyncStorageStatic { + getItem: (key: string) => Promise; + getAllKeys: () => Promise; + setItem: (key: string, value: string) => Promise; + removeItem: (key: string) => Promise; +} + +export interface UseDynamicAsyncStorageQueriesOptions { + /** + * The React Query client instance + */ + queryClient: QueryClient; + /** + * AsyncStorage instance to use for storage operations + * Pass your AsyncStorage instance from @react-native-async-storage/async-storage + * If not provided, the hook will be disabled + */ + asyncStorage?: AsyncStorageStatic; + /** + * Optional interval in milliseconds to poll for key changes + * Defaults to 5000ms (5 seconds). Set to 0 to disable polling. + */ + pollInterval?: number; + /** + * Whether to enable AsyncStorage monitoring + * When false, no queries will be created and no polling will occur + * @default true + */ + enabled?: boolean; +} + +export interface AsyncStorageQueryResult { + key: string; + data: unknown; + isLoading: boolean; + error: Error | null; +} + +/** + * Hook that creates individual React Query queries for each AsyncStorage key + * This gives you granular control and better performance since each key has its own query + * Since AsyncStorage doesn't have built-in change listeners, this hook uses polling to detect changes + * + * @example + * // Get individual queries for all AsyncStorage keys + * const queries = useDynamicAsyncStorageQueries({ queryClient }); + * // Returns: [ + * // { key: '@notifications:status', data: 'enabled', isLoading: false, error: null }, + * // { key: '@user:preferences', data: { theme: 'dark' }, isLoading: false, error: null }, + * // ... + * // ] + */ +export function useDynamicAsyncStorageQueries({ + queryClient, + asyncStorage, + pollInterval = 1000, + enabled = true, +}: UseDynamicAsyncStorageQueriesOptions): AsyncStorageQueryResult[] { + // State to track AsyncStorage keys (since getAllKeys is async) + const [asyncStorageKeys, setAsyncStorageKeys] = useState([]); + + // Helper function to get a single AsyncStorage value + const getAsyncStorageValue = useMemo(() => { + return async (key: string): Promise => { + if (!asyncStorage) { + return null; + } + + try { + const value = await asyncStorage.getItem(key); + if (value === null) { + return null; + } + + // Try to parse as JSON, fall back to string + try { + return JSON.parse(value); + } catch { + return value; + } + } catch (error) { + console.error('Error getting AsyncStorage value for key:', key, error); + throw error; + } + }; + }, [asyncStorage]); + + // Function to refresh the list of AsyncStorage keys + const refreshKeys = useMemo(() => { + return async () => { + if (!enabled || !asyncStorage) { + setAsyncStorageKeys([]); + return; + } + + try { + const keys = await asyncStorage.getAllKeys(); + // Filter out React Query cache and other noisy keys + const filteredKeys = keys.filter( + (key) => !key.includes('REACT_QUERY_OFFLINE_CACHE') && !key.includes('RCTAsyncLocalStorage'), + ); + setAsyncStorageKeys([...filteredKeys]); // Convert readonly array to mutable array + } catch (error) { + console.error('📱 [AsyncStorage Hook] Error getting AsyncStorage keys:', error); + setAsyncStorageKeys([]); + } + }; + }, [enabled, asyncStorage]); + + // Initial load of keys + useEffect(() => { + refreshKeys(); + }, [refreshKeys]); + + // Set up polling for key changes (since AsyncStorage doesn't have listeners) + useEffect(() => { + if (!enabled || pollInterval <= 0 || !asyncStorage) { + return; + } + + const interval = setInterval(async () => { + try { + const currentKeys = await asyncStorage.getAllKeys(); + // Filter out React Query cache and other noisy keys + const filteredKeys = currentKeys.filter( + (key) => !key.includes('REACT_QUERY_OFFLINE_CACHE') && !key.includes('RCTAsyncLocalStorage'), + ); + + // Check if keys have changed (added/removed) + const keysChanged = + filteredKeys.length !== asyncStorageKeys.length || + !filteredKeys.every((key) => asyncStorageKeys.includes(key)); + + if (keysChanged) { + console.log('🔄 [AsyncStorage Hook] AsyncStorage keys changed!'); + console.log('🔄 [AsyncStorage Hook] Old keys:', asyncStorageKeys.length); + console.log('🔄 [AsyncStorage Hook] New keys:', filteredKeys.length); + setAsyncStorageKeys([...filteredKeys]); // Convert readonly array to mutable array + + // Invalidate all AsyncStorage queries to refresh data + queryClient.invalidateQueries({ + queryKey: storageQueryKeys.async.root(), + }); + } else { + // Keys are the same, but check if any values have changed + for (const key of asyncStorageKeys) { + try { + // Check if the query exists in the cache first + const queryExists = queryClient.getQueryCache().find({ queryKey: storageQueryKeys.async.key(key) }); + + // If query doesn't exist (e.g., after cache clear), skip comparison + // The useQueries hook will recreate the query automatically + if (!queryExists) { + continue; + } + + // Get current value from AsyncStorage + const currentValue = await getAsyncStorageValue(key); + + // Get cached value from React Query + const cachedData = queryClient.getQueryData(storageQueryKeys.async.key(key)); + + // Only compare if we have cached data (avoid false positives after cache clear) + if (cachedData !== undefined) { + // Compare values (deep comparison for objects) + const valuesAreDifferent = JSON.stringify(currentValue) !== JSON.stringify(cachedData); + + if (valuesAreDifferent) { + console.log('🔄 [AsyncStorage Hook] Value changed for key:', key); + + // Invalidate this specific query + queryClient.invalidateQueries({ + queryKey: storageQueryKeys.async.key(key), + }); + } + } + } catch (error) { + console.error('📱 [AsyncStorage Hook] Error checking value for key:', key, error); + } + } + } + } catch (error) { + console.error('📱 [AsyncStorage Hook] Error polling AsyncStorage keys:', error); + } + }, pollInterval); + + return () => { + clearInterval(interval); + }; + }, [pollInterval, asyncStorageKeys, queryClient, getAsyncStorageValue, enabled, asyncStorage]); + + // Create individual queries for each key + const queries = useQueries( + { + queries: + enabled && asyncStorage + ? asyncStorageKeys.map((key) => ({ + queryKey: storageQueryKeys.async.key(key), + queryFn: async () => { + const value = await getAsyncStorageValue(key); + return value; + }, + staleTime: pollInterval > 0 ? pollInterval / 2 : 0, // Half the poll interval + gcTime: 5 * 60 * 1000, // 5 minutes + networkMode: 'always' as const, + retry: 0, // Retry failed requests + retryDelay: 100, // 1 second delay between retries + })) + : [], + combine: (results) => { + if (!enabled || !asyncStorage) { + return []; + } + + const combinedResults = results.map((result, index) => ({ + key: asyncStorageKeys[index], + data: result.data, + isLoading: result.isLoading, + error: result.error, + })); + + return combinedResults; + }, + }, + queryClient, + ); + + return queries; +} diff --git a/src/react-query-external-sync/hooks/useDynamicEnvQueries.ts b/src/react-query-external-sync/hooks/useDynamicEnvQueries.ts new file mode 100644 index 0000000..fc7922f --- /dev/null +++ b/src/react-query-external-sync/hooks/useDynamicEnvQueries.ts @@ -0,0 +1,97 @@ +import { useMemo } from 'react'; + +export interface UseDynamicEnvOptions { + /** + * Optional filter function to determine which env vars to include + * Note: Only EXPO_PUBLIC_ prefixed variables are available in process.env + */ + envFilter?: (key: string, value: string | undefined) => boolean; +} + +export interface EnvResult { + key: string; + data: unknown; +} + +/** + * Hook that returns all available environment variables with parsed values + * Includes all available environment variables by default (only EXPO_PUBLIC_ prefixed vars are loaded by Expo) + * + * @example + * // Get all available environment variables (only EXPO_PUBLIC_ prefixed) + * const envVars = useDynamicEnv(); + * // Returns: [ + * // { key: 'EXPO_PUBLIC_API_URL', data: 'https://api.example.com' }, + * // { key: 'EXPO_PUBLIC_APP_NAME', data: 'MyApp' }, + * // ... + * // ] + * + * @example + * // Filter to specific variables + * const envVars = useDynamicEnv({ + * envFilter: (key) => key.includes('API') || key.includes('URL') + * }); + * + * @example + * // Filter by value content + * const envVars = useDynamicEnv({ + * envFilter: (key, value) => value !== undefined && value.length > 0 + * }); + */ +export function useDynamicEnv({ + envFilter = () => true, // Default: include all available environment variables (EXPO_PUBLIC_ only) +}: UseDynamicEnvOptions = {}): EnvResult[] { + // Helper function to get a single environment variable value + const getEnvValue = useMemo(() => { + return (key: string): unknown => { + const value = process.env[key]; + + if (value === undefined) { + return null; + } + + // Try to parse as JSON for complex values, fall back to string + try { + // Only attempt JSON parsing if it looks like JSON (starts with { or [) + if (value.startsWith('{') || value.startsWith('[')) { + return JSON.parse(value); + } + + // Parse boolean-like strings + if (value.toLowerCase() === 'true') return true; + if (value.toLowerCase() === 'false') return false; + + // Parse number-like strings + if (/^\d+$/.test(value)) { + const num = parseInt(value, 10); + return !isNaN(num) ? num : value; + } + + if (/^\d*\.\d+$/.test(value)) { + const num = parseFloat(value); + return !isNaN(num) ? num : value; + } + + return value; + } catch { + return value; + } + }; + }, []); + + // Get all environment variables and process them + const envResults = useMemo(() => { + const allEnvKeys = Object.keys(process.env); + const filteredKeys = allEnvKeys.filter((key) => { + const value = process.env[key]; + return envFilter(key, value); + }); + + return filteredKeys.map((key) => ({ + key, + data: getEnvValue(key), + })); + }, [envFilter, getEnvValue]); + + return envResults; +} diff --git a/src/react-query-external-sync/hooks/useDynamicMmkvQueries.ts b/src/react-query-external-sync/hooks/useDynamicMmkvQueries.ts new file mode 100644 index 0000000..d2c0bf1 --- /dev/null +++ b/src/react-query-external-sync/hooks/useDynamicMmkvQueries.ts @@ -0,0 +1,130 @@ +import { useEffect, useMemo } from 'react'; +import { QueryClient, useQueries } from '@tanstack/react-query'; + +import { storageQueryKeys } from './storageQueryKeys'; + +// Define the MMKV storage interface for better type safety +export interface MmkvStorage { + getAllKeys(): string[]; + getString(key: string): string | undefined; + getNumber(key: string): number | undefined; + getBoolean(key: string): boolean | undefined; + addOnValueChangedListener(listener: (key: string) => void): { remove: () => void }; +} + +export interface UseDynamicMmkvQueriesOptions { + /** + * The React Query client instance + */ + queryClient: QueryClient; + /** + * The MMKV storage instance to use + */ + storage: MmkvStorage; +} + +export interface MmkvQueryResult { + key: string; + data: unknown; + isLoading: boolean; + error: Error | null; +} + +/** + * Hook that creates individual React Query queries for each MMKV key + * This gives you granular control and better performance since each key has its own query + * Automatically listens for MMKV changes and updates the relevant queries + * + * @example + * // Get individual queries for all MMKV keys + * const queries = useDynamicMmkvQueries({ queryClient, storage }); + * // Returns: [ + * // { key: 'sync_download_progress', data: 75, isLoading: false, error: null }, + * // { key: 'user_preference', data: 'dark', isLoading: false, error: null }, + * // ... + * // ] + */ +export function useDynamicMmkvQueries({ queryClient, storage }: UseDynamicMmkvQueriesOptions): MmkvQueryResult[] { + // Get all MMKV keys + const mmkvKeys = useMemo(() => { + return storage.getAllKeys(); + }, [storage]); + + // Helper function to get a single MMKV value + const getMmkvValue = useMemo(() => { + return (key: string): unknown => { + // Try to get the value as different types since MMKV doesn't tell us the type + const stringValue = storage.getString(key); + if (stringValue !== undefined) { + // Try to parse as JSON, fall back to string + try { + return JSON.parse(stringValue); + } catch { + return stringValue; + } + } + + const numberValue = storage.getNumber(key); + if (numberValue !== undefined) { + return numberValue; + } + + const boolValue = storage.getBoolean(key); + if (boolValue !== undefined) { + return boolValue; + } + + return null; + }; + }, [storage]); + + // Create individual queries for each key + const queries = useQueries( + { + queries: mmkvKeys.map((key) => ({ + queryKey: storageQueryKeys.mmkv.key(key), + queryFn: () => { + // Removed repetitive fetch logs for cleaner output + const value = getMmkvValue(key); + return value; + }, + staleTime: 0, // Always fetch fresh data + gcTime: 5 * 60 * 1000, // 5 minutes + networkMode: 'always' as const, + })), + combine: (results) => { + return results.map((result, index) => ({ + key: mmkvKeys[index], + data: result.data, + isLoading: result.isLoading, + error: result.error, + })); + }, + }, + queryClient, + ); + + // Set up MMKV listener for automatic updates + useEffect(() => { + if (mmkvKeys.length === 0) return; + + // Removed repetitive listener setup logs for cleaner output + + const listener = storage.addOnValueChangedListener((changedKey) => { + // Only invalidate if the changed key is in our list + if (mmkvKeys.includes(changedKey)) { + // Removed repetitive value change logs for cleaner output + queryClient.invalidateQueries({ + queryKey: storageQueryKeys.mmkv.key(changedKey), + }); + } + }); + + return () => { + // Removed repetitive listener cleanup logs for cleaner output + listener.remove(); + }; + }, [mmkvKeys, queryClient, storage]); + + return queries; +} diff --git a/src/react-query-external-sync/hooks/useDynamicSecureStorageQueries.ts b/src/react-query-external-sync/hooks/useDynamicSecureStorageQueries.ts new file mode 100644 index 0000000..b972b52 --- /dev/null +++ b/src/react-query-external-sync/hooks/useDynamicSecureStorageQueries.ts @@ -0,0 +1,331 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { QueryClient, useQueries } from '@tanstack/react-query'; + +import { storageQueryKeys } from './storageQueryKeys'; + +/** + * SecureStore interface that matches expo-secure-store API + * Users can pass any implementation that follows this interface + */ +export interface SecureStoreStatic { + getItemAsync: (key: string) => Promise; + setItemAsync?: (key: string, value: string) => Promise; + deleteItemAsync?: (key: string) => Promise; +} + +export interface UseDynamicSecureStorageQueriesOptions { + /** + * The React Query client instance + */ + queryClient: QueryClient; + /** + * SecureStore instance that implements the SecureStore interface + * This allows users to provide their own SecureStore implementation + * (e.g., expo-secure-store, react-native-keychain, or custom implementation) + */ + secureStorage?: SecureStoreStatic; + /** + * Optional interval in milliseconds to poll for value changes + * Defaults to 1000ms (1 second). Set to 0 to disable polling. + * Note: SecureStore doesn't provide getAllKeys() for security reasons, + * so we only poll known keys for value changes. + */ + pollInterval?: number; + /** + * Array of known SecureStore keys to monitor + * Since SecureStore doesn't expose getAllKeys() for security reasons, + * you must provide the keys you want to monitor + */ + knownKeys: string[]; +} + +export interface SecureStorageQueryResult { + key: string; + data: unknown; + isLoading: boolean; + error: Error | null; +} + +/** + * Hook that creates individual React Query queries for each SecureStore key + * This gives you granular control and better performance since each key has its own query + * Since SecureStore doesn't have built-in change listeners and doesn't expose getAllKeys(), + * this hook uses polling to detect value changes for provided known keys + * + * @example + * // With expo-secure-store + * import * as SecureStore from 'expo-secure-store'; + * + * const queries = useDynamicSecureStorageQueries({ + * queryClient, + * secureStorage: SecureStore, + * knownKeys: ['auth.session', 'auth.email', 'sessionToken', 'knock_push_token'] + * }); + * + * @example + * // With react-native-keychain or custom implementation + * const customSecureStore = { + * getItemAsync: async (key: string) => { + * // Your custom implementation + * return await Keychain.getInternetCredentials(key); + * } + * }; + * + * const queries = useDynamicSecureStorageQueries({ + * queryClient, + * secureStorage: customSecureStore, + * knownKeys: ['auth.session', 'auth.email'] + * }); + * + * // Returns: [ + * // { key: 'auth.session', data: { user: {...} }, isLoading: false, error: null }, + * // { key: 'auth.email', data: 'user@example.com', isLoading: false, error: null }, + * // ... + * // ] + */ +export function useDynamicSecureStorageQueries({ + queryClient, + secureStorage, + pollInterval = 1000, + knownKeys, +}: UseDynamicSecureStorageQueriesOptions): SecureStorageQueryResult[] { + // State to track which keys actually exist in SecureStore + const [existingKeys, setExistingKeys] = useState([]); + + // Use ref to track the current keys to avoid stale closures in polling + const existingKeysRef = useRef([]); + + // Track if we're currently checking keys to prevent concurrent checks + const isCheckingKeysRef = useRef(false); + + // Update ref whenever existingKeys changes + useEffect(() => { + existingKeysRef.current = existingKeys; + }, [existingKeys]); + + // Helper function to get a single SecureStore value + const getSecureStorageValue = useCallback( + async (key: string): Promise => { + if (!secureStorage) { + throw new Error('SecureStorage instance not provided'); + } + + try { + const value = await secureStorage.getItemAsync(key); + if (value === null) { + return null; + } + + // Try to parse as JSON, fall back to string + try { + return JSON.parse(value); + } catch { + return value; + } + } catch (error) { + console.error('Error getting SecureStore value for key:', key, error); + throw error; + } + }, + [secureStorage], + ); + + // Helper function to compare arrays + const arraysEqual = useCallback((a: string[], b: string[]): boolean => { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((val, index) => val === sortedB[index]); + }, []); + + // Function to check which known keys actually exist in SecureStore + const refreshExistingKeys = useCallback(async (): Promise => { + if (!secureStorage || isCheckingKeysRef.current) { + return; + } + + isCheckingKeysRef.current = true; + + try { + const existingKeysList: string[] = []; + + // Check each known key to see if it exists + await Promise.all( + knownKeys.map(async (key) => { + try { + const value = await secureStorage.getItemAsync(key); + if (value !== null) { + existingKeysList.push(key); + } + } catch (error) { + console.error('🔑 [SecureStore Hook] Error checking key:', key, error); + } + }), + ); + + // Only update state if the keys have actually changed + const currentKeys = existingKeysRef.current; + if (!arraysEqual(existingKeysList, currentKeys)) { + setExistingKeys([...existingKeysList]); + } + } catch (error) { + console.error('🔑 [SecureStore Hook] Error checking SecureStore keys:', error); + } finally { + isCheckingKeysRef.current = false; + } + }, [knownKeys, arraysEqual, secureStorage]); + + // Initial load of existing keys + useEffect(() => { + if (secureStorage) { + refreshExistingKeys(); + } + }, [refreshExistingKeys, secureStorage]); + + // Set up polling for value changes (since SecureStore doesn't have listeners) + useEffect(() => { + if (!secureStorage || pollInterval <= 0) { + return; + } + + const interval = setInterval(async () => { + try { + const currentExistingKeys = existingKeysRef.current; + + // Skip if we're already checking keys + if (isCheckingKeysRef.current) { + return; + } + + // Check if any known keys have been added or removed + const newExistingKeys: string[] = []; + await Promise.all( + knownKeys.map(async (key) => { + try { + const value = await secureStorage.getItemAsync(key); + if (value !== null) { + newExistingKeys.push(key); + } + } catch (error) { + console.error('🔑 [SecureStore Hook] Error checking key during poll:', key, error); + } + }), + ); + + // Check if keys have changed (added/removed) using proper comparison + const keysChanged = !arraysEqual(newExistingKeys, currentExistingKeys); + + if (keysChanged) { + console.log('🔄 [SecureStore Hook] SecureStore keys changed!'); + console.log('🔄 [SecureStore Hook] Old keys:', currentExistingKeys.length); + console.log('🔄 [SecureStore Hook] New keys:', newExistingKeys.length); + setExistingKeys([...newExistingKeys]); + + // Invalidate all SecureStore queries to refresh data + queryClient.invalidateQueries({ + queryKey: storageQueryKeys.secure.root(), + }); + } else { + // Keys are the same, but check if any values have changed + for (const key of currentExistingKeys) { + try { + // Check if the query exists in the cache first + const queryExists = queryClient.getQueryCache().find({ queryKey: storageQueryKeys.secure.key(key) }); + + // If query doesn't exist (e.g., after cache clear), skip comparison + // The useQueries hook will recreate the query automatically + if (!queryExists) { + continue; + } + + // Get current value from SecureStore + const currentValue = await getSecureStorageValue(key); + + // Get cached value from React Query + const cachedData = queryClient.getQueryData(storageQueryKeys.secure.key(key)); + + // Only compare if we have cached data (avoid false positives after cache clear) + if (cachedData !== undefined) { + // Deep comparison using a more robust method + const valuesAreDifferent = !deepEqual(currentValue, cachedData); + + if (valuesAreDifferent) { + console.log('🔄 [SecureStore Hook] Value changed for key:', key); + + // Invalidate this specific query + queryClient.invalidateQueries({ + queryKey: storageQueryKeys.secure.key(key), + }); + } + } + } catch (error) { + console.error('🔑 [SecureStore Hook] Error checking value for key:', key, error); + } + } + } + } catch (error) { + console.error('🔑 [SecureStore Hook] Error polling SecureStore keys:', error); + } + }, pollInterval); + + return () => { + clearInterval(interval); + }; + }, [pollInterval, knownKeys, queryClient, getSecureStorageValue, arraysEqual, secureStorage]); + + // Create individual queries for each existing key + const queries = useQueries( + { + queries: existingKeys.map((key) => ({ + queryKey: storageQueryKeys.secure.key(key), + queryFn: async () => { + const value = await getSecureStorageValue(key); + return value; + }, + staleTime: pollInterval > 0 ? pollInterval / 2 : 0, // Half the poll interval + gcTime: 10 * 60 * 1000, // 10 minutes (longer than AsyncStorage for security) + networkMode: 'always' as const, + retry: 1, // Retry once for secure storage + retryDelay: 200, // 200ms delay between retries + })), + combine: (results) => { + const combinedResults = results.map((result, index) => ({ + key: existingKeys[index], + data: result.data, + isLoading: result.isLoading, + error: result.error, + })); + + return combinedResults; + }, + }, + queryClient, + ); + + // Return empty array if no secureStorage is provided + if (!secureStorage) { + return []; + } + + return queries; +} + +// Helper function for deep equality comparison +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + + if (a === null || b === null || a === undefined || b === undefined) { + return a === b; + } + + if (typeof a !== typeof b) return false; + + if (typeof a !== 'object') return a === b; + + // For objects, use JSON comparison as fallback but handle edge cases + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} diff --git a/src/react-query-external-sync/hooks/useStorageQueries.ts b/src/react-query-external-sync/hooks/useStorageQueries.ts new file mode 100644 index 0000000..95a8a76 --- /dev/null +++ b/src/react-query-external-sync/hooks/useStorageQueries.ts @@ -0,0 +1,136 @@ +import { QueryClient } from '@tanstack/react-query'; + +import { useDynamicAsyncStorageQueries } from './useDynamicAsyncStorageQueries'; +import { MmkvStorage, useDynamicMmkvQueries } from './useDynamicMmkvQueries'; +import { type SecureStoreStatic, useDynamicSecureStorageQueries } from './useDynamicSecureStorageQueries'; + +export interface UseStorageQueriesOptions { + /** + * The React Query client instance + */ + queryClient: QueryClient; + + /** + * MMKV storage instance to monitor all MMKV keys + * Pass your MMKV storage instance or undefined to disable + */ + mmkv?: MmkvStorage; + + /** + * Enable AsyncStorage monitoring + * Set to true to monitor all AsyncStorage keys + */ + asyncStorage?: boolean; + + /** + * SecureStorage configuration + * Pass an array of known keys (SecureStore doesn't expose getAllKeys for security) + * OR pass an object with storage instance and keys + */ + secureStorage?: + | string[] + | { + storage: SecureStoreStatic; + keys: string[]; + }; + + /** + * Optional polling interval for SecureStorage in milliseconds + * Defaults to 1000ms (1 second) + */ + secureStoragePollInterval?: number; +} + +export interface StorageQueryResults { + mmkv: ReturnType; + asyncStorage: ReturnType; + secureStorage: ReturnType; +} + +/** + * Unified hook for monitoring all device storage types with React Query + * + * This hook consolidates MMKV, AsyncStorage, and SecureStorage monitoring into one simple interface. + * Each storage type can be enabled/disabled independently based on your needs. + * + * @example + * // Monitor all storage types (legacy string array format) + * const storageQueries = useStorageQueries({ + * queryClient, + * mmkv: storage, + * asyncStorage: true, + * secureStorage: ['sessionToken', 'auth.session', 'auth.email'] + * }); + * + * @example + * // Monitor with custom SecureStore instance + * import * as SecureStore from 'expo-secure-store'; + * + * const storageQueries = useStorageQueries({ + * queryClient, + * mmkv: storage, + * asyncStorage: true, + * secureStorage: { + * storage: SecureStore, + * keys: ['sessionToken', 'auth.session', 'auth.email'] + * } + * }); + * + * @example + * // Monitor only MMKV storage + * const storageQueries = useStorageQueries({ + * queryClient, + * mmkv: storage + * }); + * + * @example + * // Monitor only specific secure storage keys + * const storageQueries = useStorageQueries({ + * queryClient, + * secureStorage: ['sessionToken', 'refreshToken'], + * secureStoragePollInterval: 2000 // Check every 2 seconds + * }); + */ +export function useStorageQueries({ + queryClient, + mmkv, + asyncStorage, + secureStorage, + secureStoragePollInterval, +}: UseStorageQueriesOptions): StorageQueryResults { + // Always call hooks but with conditional parameters + // MMKV queries - pass a dummy storage if not enabled + const mmkvQueries = useDynamicMmkvQueries({ + queryClient, + storage: mmkv || { + getAllKeys: () => [], + getString: () => undefined, + getNumber: () => undefined, + getBoolean: () => undefined, + addOnValueChangedListener: () => ({ remove: () => {} }), + }, + }); + + // AsyncStorage queries - always call but filter results + const asyncStorageQueries = useDynamicAsyncStorageQueries({ + queryClient, + }); + + // SecureStorage queries - handle both legacy array format and new object format + const secureStorageConfig = Array.isArray(secureStorage) + ? { storage: undefined, keys: secureStorage } + : secureStorage || { storage: undefined, keys: [] }; + + const secureStorageQueries = useDynamicSecureStorageQueries({ + queryClient, + secureStorage: secureStorageConfig.storage, + knownKeys: secureStorageConfig.keys, + pollInterval: secureStoragePollInterval, + }); + + return { + mmkv: mmkv ? mmkvQueries : [], + asyncStorage: asyncStorage ? asyncStorageQueries : [], + secureStorage: secureStorageConfig.keys.length ? secureStorageQueries : [], + }; +} diff --git a/src/react-query-external-sync/hydration.ts b/src/react-query-external-sync/hydration.ts index cf24363..656d543 100644 --- a/src/react-query-external-sync/hydration.ts +++ b/src/react-query-external-sync/hydration.ts @@ -6,14 +6,9 @@ import type { QueryClient, QueryFunction, QueryOptions, -} from "@tanstack/react-query"; +} from '@tanstack/react-query'; -import { - DehydratedMutation, - DehydratedQuery, - DehydratedState, - ObserverState, -} from "./types"; +import { DehydratedMutation, DehydratedQuery, DehydratedState, ObserverState } from './types'; type TransformerFn = (data: unknown) => unknown; export function Dehydrate(client: QueryClient): DehydratedState { @@ -26,7 +21,6 @@ export function Dehydrate(client: QueryClient): DehydratedState { .getQueryCache() .getAll() .flatMap((query) => [dehydrateQuery(query)]); - return { mutations, queries }; } export interface DehydrateOptions { diff --git a/src/react-query-external-sync/types.ts b/src/react-query-external-sync/types.ts index b8f6c7a..138100f 100644 --- a/src/react-query-external-sync/types.ts +++ b/src/react-query-external-sync/types.ts @@ -8,7 +8,7 @@ import { QueryMeta, QueryObserverOptions, QueryState, -} from "@tanstack/react-query"; +} from '@tanstack/react-query'; // Define a simplified version of DehydratedState that both versions can work with export interface SimpleDehydratedState { mutations: unknown[]; @@ -16,7 +16,7 @@ export interface SimpleDehydratedState { } export interface SyncMessage { - type: "dehydrated-state"; + type: 'dehydrated-state'; state: DehydratedState; isOnlineManagerOnline: boolean; persistentDeviceId: string; @@ -49,16 +49,10 @@ export interface ObserverState< TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey + TQueryKey extends QueryKey = QueryKey, > { queryHash: string; - options: QueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - >; + options: QueryObserverOptions; } export interface User { @@ -68,4 +62,5 @@ export interface User { platform?: string; // Device platform (iOS, Android, Web) isConnected?: boolean; // Whether the device is currently connected extraDeviceInfo?: string; // json string of additional device information as key-value pairs + envVariables?: Record; // Environment variables from the mobile app } diff --git a/src/react-query-external-sync/useMySocket.ts b/src/react-query-external-sync/useMySocket.ts index 94c4905..f6b3b0e 100644 --- a/src/react-query-external-sync/useMySocket.ts +++ b/src/react-query-external-sync/useMySocket.ts @@ -1,14 +1,15 @@ -import { useEffect, useRef, useState } from "react"; -import { io as socketIO, Socket } from "socket.io-client"; +import { useEffect, useRef, useState } from 'react'; +import { io as socketIO, Socket } from 'socket.io-client'; -import { getPlatformSpecificURL, PlatformOS } from "./platformUtils"; -import { log } from "./utils/logger"; +import { log } from './utils/logger'; +import { getPlatformSpecificURL, PlatformOS } from './platformUtils'; interface Props { deviceName: string; // Unique name to identify the device socketURL: string; // Base URL of the socket server (may be modified based on platform) persistentDeviceId: string | null; // Persistent device ID extraDeviceInfo?: Record; // Additional device information as key-value pairs + envVariables?: Record; // Environment variables from the mobile app platform: PlatformOS; // Platform identifier /** * Enable/disable logging for debugging purposes @@ -22,7 +23,7 @@ interface Props { * This way multiple components can share the same socket connection */ let globalSocketInstance: Socket | null = null; -let currentSocketURL = ""; +let currentSocketURL = ''; /** * Hook that handles socket connection for device-dashboard communication @@ -39,6 +40,7 @@ export function useMySocket({ socketURL, persistentDeviceId, extraDeviceInfo, + envVariables, platform, enableLogs = false, }: Props) { @@ -50,29 +52,6 @@ export function useMySocket({ // For logging clarity const logPrefix = `[${deviceName}]`; - // Define event handlers at function root level to satisfy linter - const onConnect = () => { - log(`${logPrefix} Socket connected successfully`, enableLogs); - setIsConnected(true); - }; - - const onDisconnect = (reason: string) => { - log(`${logPrefix} Socket disconnected. Reason: ${reason}`, enableLogs); - setIsConnected(false); - }; - - const onConnectError = (error: Error) => { - log( - `${logPrefix} Socket connection error: ${error.message}`, - enableLogs, - "error" - ); - }; - - const onConnectTimeout = () => { - log(`${logPrefix} Socket connection timeout`, enableLogs, "error"); - }; - // Main socket initialization - runs only once useEffect(() => { // Wait until we have a persistent device ID @@ -87,48 +66,54 @@ export function useMySocket({ initialized.current = true; + // Define event handlers inside useEffect to avoid dependency issues + const onConnect = () => { + log(`${logPrefix} Socket connected successfully`, enableLogs); + setIsConnected(true); + }; + + const onDisconnect = (reason: string) => { + log(`${logPrefix} Socket disconnected. Reason: ${reason}`, enableLogs); + setIsConnected(false); + }; + + const onConnectError = (error: Error) => { + log(`${logPrefix} Socket connection error: ${error.message}`, enableLogs, 'error'); + }; + + const onConnectTimeout = () => { + log(`${logPrefix} Socket connection timeout`, enableLogs, 'error'); + }; + // Get the platform-specific URL const platformUrl = getPlatformSpecificURL(socketURL, platform); currentSocketURL = platformUrl; - log( - `${logPrefix} Platform: ${platform}, using URL: ${platformUrl}`, - enableLogs - ); - try { // Use existing global socket or create a new one if (!globalSocketInstance) { - log( - `${logPrefix} Creating new socket instance to ${platformUrl}`, - enableLogs - ); globalSocketInstance = socketIO(platformUrl, { autoConnect: true, query: { deviceName, deviceId: persistentDeviceId, platform, - ...(extraDeviceInfo && Object.keys(extraDeviceInfo).length > 0 - ? { extraDeviceInfo: JSON.stringify(extraDeviceInfo) } - : {}), + extraDeviceInfo: JSON.stringify(extraDeviceInfo), + envVariables: JSON.stringify(envVariables), }, reconnection: false, - transports: ["websocket"], // Prefer websocket transport for React Native + transports: ['websocket'], // Prefer websocket transport for React Native }); } else { - log( - `${logPrefix} Reusing existing socket instance to ${platformUrl}`, - enableLogs - ); + log(`${logPrefix} Reusing existing socket instance to ${platformUrl}`, enableLogs); } socketRef.current = globalSocketInstance; setSocket(socketRef.current); // Setup error event listener - socketRef.current.on("connect_error", onConnectError); - socketRef.current.on("connect_timeout", onConnectTimeout); + socketRef.current.on('connect_error', onConnectError); + socketRef.current.on('connect_timeout', onConnectTimeout); // Check initial connection state if (socketRef.current.connected) { @@ -137,38 +122,31 @@ export function useMySocket({ } // Set up event handlers - socketRef.current.on("connect", onConnect); - socketRef.current.on("disconnect", onDisconnect); + socketRef.current.on('connect', onConnect); + socketRef.current.on('disconnect', onDisconnect); // Clean up event listeners on unmount but don't disconnect return () => { if (socketRef.current) { log(`${logPrefix} Cleaning up socket event listeners`, enableLogs); - socketRef.current.off("connect", onConnect); - socketRef.current.off("disconnect", onDisconnect); - socketRef.current.off("connect_error", onConnectError); - socketRef.current.off("connect_timeout", onConnectTimeout); + socketRef.current.off('connect', onConnect); + socketRef.current.off('disconnect', onDisconnect); + socketRef.current.off('connect_error', onConnectError); + socketRef.current.off('connect_timeout', onConnectTimeout); // Don't disconnect socket on component unmount // We want it to remain connected for the app's lifetime } }; } catch (error) { - log( - `${logPrefix} Failed to initialize socket: ${error}`, - enableLogs, - "error" - ); + log(`${logPrefix} Failed to initialize socket: ${error}`, enableLogs, 'error'); } + // ## DON'T ADD ANYTHING ELSE TO THE DEPENDENCY ARRAY ### + // eslint-disable-next-line react-hooks/exhaustive-deps }, [persistentDeviceId]); // Update the socket query parameters when deviceName changes useEffect(() => { - if ( - socketRef.current && - socketRef.current.io.opts.query && - persistentDeviceId - ) { - log(`${logPrefix} Updating device name in socket connection`, enableLogs); + if (socketRef.current && socketRef.current.io.opts.query && persistentDeviceId) { socketRef.current.io.opts.query = { ...socketRef.current.io.opts.query, deviceName, @@ -184,54 +162,35 @@ export function useMySocket({ const platformUrl = getPlatformSpecificURL(socketURL, platform); // Compare with last known URL to avoid direct property access - if ( - socketRef.current && - currentSocketURL !== platformUrl && - persistentDeviceId - ) { - log( - `${logPrefix} Socket URL changed from ${currentSocketURL} to ${platformUrl}`, - enableLogs - ); + if (socketRef.current && currentSocketURL !== platformUrl && persistentDeviceId) { + log(`${logPrefix} Socket URL changed from ${currentSocketURL} to ${platformUrl}`, enableLogs); try { // Only recreate socket if URL actually changed socketRef.current.disconnect(); currentSocketURL = platformUrl; - log( - `${logPrefix} Creating new socket connection to ${platformUrl}`, - enableLogs - ); + log(`${logPrefix} Creating new socket connection to ${platformUrl}`, enableLogs); globalSocketInstance = socketIO(platformUrl, { autoConnect: true, query: { deviceName, deviceId: persistentDeviceId, platform, + extraDeviceInfo: JSON.stringify(extraDeviceInfo), + envVariables: JSON.stringify(envVariables), }, reconnection: false, - transports: ["websocket"], // Prefer websocket transport for React Native + transports: ['websocket'], // Prefer websocket transport for React Native }); socketRef.current = globalSocketInstance; setSocket(socketRef.current); } catch (error) { - log( - `${logPrefix} Failed to update socket connection: ${error}`, - enableLogs, - "error" - ); + log(`${logPrefix} Failed to update socket connection: ${error}`, enableLogs, 'error'); } } - }, [ - socketURL, - deviceName, - logPrefix, - persistentDeviceId, - platform, - enableLogs, - ]); + }, [socketURL, deviceName, logPrefix, persistentDeviceId, platform, enableLogs, extraDeviceInfo, envVariables]); /** * Manually connect to the socket server diff --git a/src/react-query-external-sync/useSyncQueriesExternal.ts b/src/react-query-external-sync/useSyncQueriesExternal.ts index a429275..e5ccdd6 100644 --- a/src/react-query-external-sync/useSyncQueriesExternal.ts +++ b/src/react-query-external-sync/useSyncQueriesExternal.ts @@ -1,12 +1,27 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import type { QueryKey } from "@tanstack/query-core"; import { onlineManager, QueryClient } from "@tanstack/react-query"; -import { log } from "./utils/logger"; +import { + type AsyncStorageStatic, + useDynamicAsyncStorageQueries, +} from "./hooks/useDynamicAsyncStorageQueries"; +import { + MmkvStorage, + useDynamicMmkvQueries, +} from "./hooks/useDynamicMmkvQueries"; +import { useDynamicSecureStorageQueries } from "./hooks/useDynamicSecureStorageQueries"; +import { log, syncLogger } from "./utils/logger"; +import { + handleStorageRemoval, + handleStorageUpdate, + type StorageInterface, +} from "./utils/storageHandlers"; import { Dehydrate } from "./hydration"; import { PlatformOS } from "./platformUtils"; import { SyncMessage } from "./types"; import { useMySocket } from "./useMySocket"; +import { useDynamicEnv } from "./hooks/useDynamicEnvQueries"; /** * Query actions that can be performed on a query. @@ -89,6 +104,24 @@ function checkVersion(queryClient: QueryClient) { } } +/** + * SecureStore static interface (from expo-secure-store) + */ +interface SecureStoreStatic { + getItemAsync: (key: string) => Promise; + setItemAsync?: (key: string, value: string) => Promise; + deleteItemAsync?: (key: string) => Promise; +} + +/** + * Extended MMKV interface that includes set/delete methods + */ +interface MmkvWithSetDelete { + set?: (key: string, value: string | number | boolean) => void; + setString?: (key: string, value: string) => void; + delete?: (key: string) => void; +} + interface useSyncQueriesExternalProps { queryClient: QueryClient; deviceName: string; @@ -100,6 +133,12 @@ interface useSyncQueriesExternalProps { */ deviceId: string; extraDeviceInfo?: Record; // Additional device information as key-value pairs + /** + * Additional environment variables to include beyond the automatically collected EXPO_PUBLIC_ variables. + * The hook automatically collects all EXPO_PUBLIC_ prefixed environment variables. + * Use this parameter to add any additional env vars you want to send to the dashboard. + */ + envVariables?: Record; socketURL: string; platform: PlatformOS; // Required platform /** @@ -107,6 +146,117 @@ interface useSyncQueriesExternalProps { * @default false */ enableLogs?: boolean; + + /** + * Storage instances for different storage types + * When provided, these will automatically enable both external sync AND storage monitoring + * + * - mmkvStorage: MMKV storage instance (enables ['#storage', 'mmkv', 'key'] queries + monitoring) + * - asyncStorage: AsyncStorage instance (enables ['#storage', 'async', 'key'] queries + monitoring) + * - secureStorage: SecureStore instance (enables ['#storage', 'secure', 'key'] queries + monitoring) + * - secureStorageKeys: Array of SecureStore keys to monitor (required when using secureStorage) + * - secureStoragePollInterval: Polling interval for SecureStore monitoring (default: 1000ms) + */ + storage?: StorageInterface; // Legacy prop for backward compatibility + mmkvStorage?: StorageInterface | MmkvStorage; // MMKV storage instance (will be auto-adapted) + asyncStorage?: StorageInterface | AsyncStorageStatic; // AsyncStorage instance (will be auto-adapted) + secureStorage?: StorageInterface | SecureStoreStatic; // SecureStore instance (will be auto-adapted) + secureStorageKeys?: string[]; // Required when using secureStorage - keys to monitor + secureStoragePollInterval?: number; // Optional polling interval for SecureStore (default: 1000ms) +} + +/** + * Helper function to detect storage type and create an adapter if needed + */ +function createStorageAdapter( + storage: + | StorageInterface + | AsyncStorageStatic + | SecureStoreStatic + | MmkvStorage +): StorageInterface { + // Note: These debug logs are intentionally minimal to reduce noise + // They can be enabled for deep debugging if needed + + // Check if it's already a StorageInterface-compatible storage + if ("set" in storage && "delete" in storage) { + return storage as StorageInterface; + } + + // Check if it's MMKV by looking for MMKV-specific methods + if ( + "getString" in storage && + "getAllKeys" in storage && + "addOnValueChangedListener" in storage + ) { + // MMKV has different method names, create an adapter + const mmkvStorage = storage as MmkvStorage; + return { + set: (key: string, value: string | number | boolean) => { + // MMKV doesn't have a generic set method, we need to use the specific type methods + // For now, we'll convert everything to string and use setString + // Note: This assumes the MMKV instance has setString method + const mmkvWithSet = mmkvStorage as MmkvStorage & MmkvWithSetDelete; + if (mmkvWithSet.set) { + mmkvWithSet.set(key, value); + } else if (mmkvWithSet.setString) { + const stringValue = + typeof value === "string" ? value : JSON.stringify(value); + mmkvWithSet.setString(key, stringValue); + } else { + console.warn("⚠️ MMKV storage does not have set or setString method"); + } + }, + delete: (key: string) => { + const mmkvWithDelete = mmkvStorage as MmkvStorage & MmkvWithSetDelete; + if (mmkvWithDelete.delete) { + mmkvWithDelete.delete(key); + } else { + console.warn("⚠️ MMKV storage does not have delete method"); + } + }, + }; + } + + // Check if it's AsyncStorage by looking for setItem/removeItem methods + if ("setItem" in storage && "removeItem" in storage) { + // This is AsyncStorage, create an adapter + return { + set: (key: string, value: string | number | boolean) => { + const stringValue = + typeof value === "string" ? value : JSON.stringify(value); + return storage.setItem(key, stringValue); + }, + delete: (key: string) => { + return storage.removeItem(key); + }, + }; + } + + // Check if it's SecureStore by looking for setItemAsync/deleteItemAsync methods + if ("setItemAsync" in storage && "deleteItemAsync" in storage) { + // This is SecureStore, create an adapter + const secureStore = storage as SecureStoreStatic; + return { + set: (key: string, value: string | number | boolean) => { + const stringValue = + typeof value === "string" ? value : JSON.stringify(value); + if (secureStore.setItemAsync) { + return secureStore.setItemAsync(key, stringValue); + } + throw new Error("SecureStore setItemAsync method not available"); + }, + delete: (key: string) => { + if (secureStore.deleteItemAsync) { + return secureStore.deleteItemAsync(key); + } + throw new Error("SecureStore deleteItemAsync method not available"); + }, + }; + } + + // Fallback - assume it's already compatible + return storage as unknown as StorageInterface; } /** @@ -117,15 +267,57 @@ interface useSyncQueriesExternalProps { * - Responding to dashboard requests * - Processing query actions from the dashboard * - Sending query state updates to the dashboard + * - Automatically collecting all EXPO_PUBLIC_ environment variables + * - Merging additional user-provided environment variables + * - Supporting multiple storage types (MMKV, AsyncStorage, SecureStore) + * - Integrated storage monitoring (automatically monitors storage when instances are provided) + * + * @example + * // Basic usage with MMKV only (legacy) + * useSyncQueriesExternal({ + * queryClient, + * socketURL: 'http://localhost:42831', + * deviceName: 'iOS Simulator', + * platform: 'ios', + * deviceId: 'ios-sim-1', + * storage: mmkvStorage, // Your MMKV instance + * }); + * + * @example + * // Advanced usage with MMKV, AsyncStorage, and SecureStore + * // This automatically enables both external sync AND storage monitoring + * import AsyncStorage from '@react-native-async-storage/async-storage'; + * import * as SecureStore from 'expo-secure-store'; + * import { storage as mmkvStorage } from '~/lib/storage/mmkv'; + * + * useSyncQueriesExternal({ + * queryClient, + * socketURL: 'http://localhost:42831', + * deviceName: 'iOS Simulator', + * platform: 'ios', + * deviceId: 'ios-sim-1', + * mmkvStorage: mmkvStorage, // Enables ['#storage', 'mmkv', 'key'] queries + MMKV monitoring + * asyncStorage: AsyncStorage, // Enables ['#storage', 'async', 'key'] queries + AsyncStorage monitoring + * secureStorage: SecureStore, // Enables ['#storage', 'secure', 'key'] queries + SecureStore monitoring + * secureStorageKeys: ['sessionToken', 'auth.session', 'auth.email'], // Required for SecureStore monitoring + * enableLogs: true, + * }); */ export function useSyncQueriesExternal({ queryClient, deviceName, socketURL, extraDeviceInfo, + envVariables, platform, deviceId, enableLogs = false, + storage, + mmkvStorage, + asyncStorage, + secureStorage, + secureStorageKeys, + secureStoragePollInterval, }: useSyncQueriesExternalProps) { // ========================================================== // Validate deviceId @@ -136,27 +328,119 @@ export function useSyncQueriesExternal({ ); } + // ========================================================== + // Auto-collect environment variables + // ========================================================== + const envResults = useDynamicEnv(); + + // Convert env results to a simple key-value object + const autoCollectedEnvVars = useMemo(() => { + const envVars: Record = {}; + + envResults.forEach(({ key, data }) => { + // Include all available env vars + if (data !== undefined && data !== null) { + // Convert data to string for transmission + envVars[key] = typeof data === "string" ? data : JSON.stringify(data); + } + }); + + return envVars; + }, [envResults]); + + // Merge auto-collected env vars with user-provided ones (user-provided take precedence) + const mergedEnvVariables = useMemo(() => { + const merged = { + ...autoCollectedEnvVars, + ...(envVariables || {}), + }; + + return merged; + }, [autoCollectedEnvVars, envVariables]); + // ========================================================== // Persistent device ID - used to identify this device // across app restarts // ========================================================== const logPrefix = `[${deviceName}]`; + + // ========================================================== + // Integrated Storage Monitoring + // Automatically enable storage monitoring when storage instances are provided + // ========================================================== + + // MMKV monitoring - only if mmkvStorage is provided and has the required methods + const mmkvQueries = useDynamicMmkvQueries({ + queryClient, + storage: + mmkvStorage && "getAllKeys" in mmkvStorage + ? (mmkvStorage as MmkvStorage) + : { + getAllKeys: () => [], + getString: () => undefined, + getNumber: () => undefined, + getBoolean: () => undefined, + addOnValueChangedListener: () => ({ remove: () => {} }), + }, + }); + + // AsyncStorage monitoring - only if asyncStorage is provided + const asyncStorageQueries = useDynamicAsyncStorageQueries({ + queryClient, + asyncStorage: + asyncStorage && "getItem" in asyncStorage && "getAllKeys" in asyncStorage + ? (asyncStorage as AsyncStorageStatic) + : undefined, + enabled: !!asyncStorage, // Only enable when asyncStorage is provided + }); + + // SecureStorage monitoring - only if secureStorage and secureStorageKeys are provided + const secureStorageQueries = useDynamicSecureStorageQueries({ + queryClient, + secureStorage: + secureStorage && "getItemAsync" in secureStorage + ? (secureStorage as SecureStoreStatic) + : undefined, + knownKeys: secureStorageKeys || [], + pollInterval: secureStoragePollInterval || 1000, + }); + + // Use a ref to track previous connection state to avoid duplicate logs + const prevConnectedRef = useRef(false); + const prevEnvVarsRef = useRef>({}); + const storageLoggingDoneRef = useRef(false); + + // Log storage monitoring status once + useEffect(() => { + if (enableLogs && !storageLoggingDoneRef.current) { + // Removed redundant storage monitoring status log for cleaner output + storageLoggingDoneRef.current = true; + } + }, [ + mmkvStorage, + asyncStorage, + secureStorage, + secureStorageKeys, + enableLogs, + deviceName, + ]); + // ========================================================== // Socket connection - Handles connection to the socket server and // event listeners for the socket server + // Connect immediately since env vars are available synchronously // ========================================================== + const { connect, disconnect, isConnected, socket } = useMySocket({ deviceName, socketURL, persistentDeviceId: deviceId, extraDeviceInfo, + envVariables: mergedEnvVariables, platform, enableLogs, }); - // Use a ref to track previous connection state to avoid duplicate logs - const prevConnectedRef = useRef(false); - useEffect(() => { checkVersion(queryClient); @@ -170,6 +454,28 @@ export function useSyncQueriesExternal({ prevConnectedRef.current = isConnected; } + // Send updated env vars if they changed after connection (for failsafe scenarios) + if (isConnected && socket && mergedEnvVariables) { + const currentEnvVarsKey = JSON.stringify(mergedEnvVariables); + const prevEnvVarsKey = JSON.stringify(prevEnvVarsRef.current); + + if ( + currentEnvVarsKey !== prevEnvVarsKey && + Object.keys(mergedEnvVariables).length > + Object.keys(prevEnvVarsRef.current).length + ) { + log( + `${deviceName} Sending updated environment variables to dashboard (post-failsafe)`, + enableLogs + ); + socket.emit("env-vars-update", { + deviceId, + envVariables: mergedEnvVariables, + }); + prevEnvVarsRef.current = { ...mergedEnvVariables }; + } + } + // Don't proceed with setting up event handlers if not connected if (!isConnected || !socket) { return; @@ -223,23 +529,32 @@ export function useSyncQueriesExternal({ return; } - log( - `[${deviceName}] Received online-manager action: ${action}`, + // Start a sync operation for this online manager action + const operationId = syncLogger.startOperation( + "query-action", + { + deviceName, + deviceId, + platform, + }, enableLogs ); switch (action) { case "ACTION-ONLINE-MANAGER-ONLINE": { - log(`${logPrefix} Set online state: ONLINE`, enableLogs); onlineManager.setOnline(true); + syncLogger.logQueryAction(operationId, action, "online-manager"); break; } case "ACTION-ONLINE-MANAGER-OFFLINE": { - log(`${logPrefix} Set online state: OFFLINE`, enableLogs); onlineManager.setOnline(false); + syncLogger.logQueryAction(operationId, action, "online-manager"); break; } } + + // Complete the operation + syncLogger.completeOperation(operationId); } ); @@ -268,154 +583,335 @@ export function useSyncQueriesExternal({ return; } - log( - `${logPrefix} Received query action: ${action} for query ${queryHash}`, + // Start a sync operation for this query action + const operationId = syncLogger.startOperation( + "query-action", + { + deviceName, + deviceId, + platform, + }, enableLogs ); + // If action is clear cache do the action here before moving on if (action === "ACTION-CLEAR-MUTATION-CACHE") { queryClient.getMutationCache().clear(); - log(`${logPrefix} Cleared mutation cache`, enableLogs); + syncLogger.logQueryAction(operationId, action, "mutation-cache"); + syncLogger.completeOperation(operationId); return; } if (action === "ACTION-CLEAR-QUERY-CACHE") { queryClient.getQueryCache().clear(); - log(`${logPrefix} Cleared query cache`, enableLogs); + syncLogger.logQueryAction(operationId, action, "query-cache"); + syncLogger.completeOperation(operationId); return; } const activeQuery = queryClient.getQueryCache().get(queryHash); if (!activeQuery) { - log( - `${logPrefix} Query with hash ${queryHash} not found`, - enableLogs, - "warn" + syncLogger.logError( + operationId, + "Query Not Found", + `Query with hash ${queryHash} not found` ); + // Removed redundant log for cleaner output + syncLogger.completeOperation(operationId, false); return; } - switch (action) { - case "ACTION-DATA-UPDATE": { - log(`${logPrefix} Updating data for query:`, enableLogs); - queryClient.setQueryData(queryKey, data, { - updatedAt: Date.now(), - }); - break; - } + try { + switch (action) { + case "ACTION-DATA-UPDATE": { + // Check if this is a storage query + if ( + Array.isArray(queryKey) && + queryKey.length === 3 && + queryKey[0] === "#storage" + ) { + const storageType = queryKey[1] as string; + const storageKey = queryKey[2] as string; - case "ACTION-TRIGGER-ERROR": { - log(`${logPrefix} Triggering error state for query:`, enableLogs); - const error = new Error("Unknown error from devtools"); - - const __previousQueryOptions = activeQuery.options; - activeQuery.setState({ - status: "error", - error, - fetchMeta: { - ...activeQuery.state.fetchMeta, - // @ts-expect-error This does exist - __previousQueryOptions, - }, - }); - break; - } - case "ACTION-RESTORE-ERROR": { - log( - `${logPrefix} Restoring from error state for query:`, - enableLogs - ); - queryClient.resetQueries(activeQuery); - break; - } - case "ACTION-TRIGGER-LOADING": { - if (!activeQuery) return; - log(`${logPrefix} Triggering loading state for query:`, enableLogs); - const __previousQueryOptions = activeQuery.options; - // Trigger a fetch in order to trigger suspense as well. - activeQuery.fetch({ - ...__previousQueryOptions, - queryFn: () => { - return new Promise(() => { - // Never resolve - simulates perpetual loading + // Determine which storage instance to use based on storage type + let storageInstance: StorageInterface | undefined; + switch (storageType.toLowerCase()) { + case "mmkv": + const rawMmkvStorage = mmkvStorage || storage; + storageInstance = rawMmkvStorage + ? createStorageAdapter(rawMmkvStorage) + : undefined; + break; + case "asyncstorage": + case "async-storage": + case "async": + const rawAsyncStorage = asyncStorage || storage; + storageInstance = rawAsyncStorage + ? createStorageAdapter(rawAsyncStorage) + : undefined; + break; + case "securestorage": + case "secure-storage": + case "secure": + const rawSecureStorage = secureStorage || storage; + storageInstance = rawSecureStorage + ? createStorageAdapter(rawSecureStorage) + : undefined; + break; + default: + storageInstance = storage; + break; + } + + // Log the storage update with current and new values + const currentValue = queryClient.getQueryData(queryKey); + const storageTypeForLogger = + storageType.toLowerCase() === "mmkv" + ? "mmkv" + : storageType.toLowerCase().includes("async") + ? "asyncStorage" + : "secureStore"; + syncLogger.logStorageUpdate( + operationId, + storageTypeForLogger, + storageKey, + currentValue, + data + ); + + // This is a storage query, handle it with the storage handler + const wasStorageHandled = handleStorageUpdate( + queryKey, + data, + queryClient, + storageInstance, + enableLogs, + deviceName + ); + + // If storage handler couldn't handle it, fall back to regular update + if (!wasStorageHandled) { + queryClient.setQueryData(queryKey, data, { + updatedAt: Date.now(), + }); + } + } else { + // Not a storage query, handle as regular query data update + queryClient.setQueryData(queryKey, data, { + updatedAt: Date.now(), }); - }, - gcTime: -1, - }); - activeQuery.setState({ - data: undefined, - status: "pending", - fetchMeta: { - ...activeQuery.state.fetchMeta, - // @ts-expect-error This does exist - __previousQueryOptions, - }, - }); - break; - } - case "ACTION-RESTORE-LOADING": { - log( - `${logPrefix} Restoring from loading state for query:`, - enableLogs - ); - const previousState = activeQuery.state; - const previousOptions = activeQuery.state.fetchMeta - ? ( - activeQuery.state.fetchMeta as unknown as { - __previousQueryOptions: unknown; - } - ).__previousQueryOptions - : null; - - activeQuery.cancel({ silent: true }); - activeQuery.setState({ - ...previousState, - fetchStatus: "idle", - fetchMeta: null, - }); - - if (previousOptions) { - activeQuery.fetch(previousOptions); + } + + syncLogger.logQueryAction(operationId, action, queryHash); + break; + } + + case "ACTION-TRIGGER-ERROR": { + // Removed redundant log for cleaner output + const error = new Error("Unknown error from devtools"); + + const __previousQueryOptions = activeQuery.options; + activeQuery.setState({ + status: "error", + error, + fetchMeta: { + ...activeQuery.state.fetchMeta, + // @ts-expect-error This does exist + __previousQueryOptions, + }, + }); + syncLogger.logQueryAction(operationId, action, queryHash); + break; + } + case "ACTION-RESTORE-ERROR": { + // Removed redundant log for cleaner output + queryClient.resetQueries(activeQuery); + syncLogger.logQueryAction(operationId, action, queryHash); + break; + } + case "ACTION-TRIGGER-LOADING": { + if (!activeQuery) return; + // Removed redundant log for cleaner output + const __previousQueryOptions = activeQuery.options; + // Trigger a fetch in order to trigger suspense as well. + activeQuery.fetch({ + ...__previousQueryOptions, + queryFn: () => { + return new Promise(() => { + // Never resolve - simulates perpetual loading + }); + }, + gcTime: -1, + }); + activeQuery.setState({ + data: undefined, + status: "pending", + fetchMeta: { + ...activeQuery.state.fetchMeta, + // @ts-expect-error This does exist + __previousQueryOptions, + }, + }); + syncLogger.logQueryAction(operationId, action, queryHash); + break; + } + case "ACTION-RESTORE-LOADING": { + // Removed redundant log for cleaner output + const previousState = activeQuery.state; + const previousOptions = activeQuery.state.fetchMeta + ? ( + activeQuery.state.fetchMeta as unknown as { + __previousQueryOptions: unknown; + } + ).__previousQueryOptions + : null; + + activeQuery.cancel({ silent: true }); + activeQuery.setState({ + ...previousState, + fetchStatus: "idle", + fetchMeta: null, + }); + + if (previousOptions) { + activeQuery.fetch(previousOptions); + } + syncLogger.logQueryAction(operationId, action, queryHash); + break; + } + case "ACTION-RESET": { + // Removed redundant log for cleaner output + queryClient.resetQueries(activeQuery); + syncLogger.logQueryAction(operationId, action, queryHash); + break; + } + case "ACTION-REMOVE": { + // Check if this is a storage query + if ( + Array.isArray(queryKey) && + queryKey.length === 3 && + queryKey[0] === "#storage" + ) { + const storageType = queryKey[1] as string; + const storageKey = queryKey[2] as string; + + // Determine which storage instance to use based on storage type + let storageInstance: StorageInterface | undefined; + switch (storageType.toLowerCase()) { + case "mmkv": + const rawMmkvStorage = mmkvStorage || storage; + storageInstance = rawMmkvStorage + ? createStorageAdapter(rawMmkvStorage) + : undefined; + break; + case "asyncstorage": + case "async-storage": + case "async": + const rawAsyncStorage = asyncStorage || storage; + storageInstance = rawAsyncStorage + ? createStorageAdapter(rawAsyncStorage) + : undefined; + break; + case "securestorage": + case "secure-storage": + case "secure": + const rawSecureStorage = secureStorage || storage; + storageInstance = rawSecureStorage + ? createStorageAdapter(rawSecureStorage) + : undefined; + break; + default: + storageInstance = storage; + break; + } + + // Log the storage removal + const currentValue = queryClient.getQueryData(queryKey); + const storageTypeForLogger = + storageType.toLowerCase() === "mmkv" + ? "mmkv" + : storageType.toLowerCase().includes("async") + ? "asyncStorage" + : "secureStore"; + syncLogger.logStorageUpdate( + operationId, + storageTypeForLogger, + storageKey, + currentValue, + null + ); + + // This is a storage query, handle it with the storage removal handler + const wasStorageHandled = handleStorageRemoval( + queryKey, + queryClient, + storageInstance, + enableLogs, + deviceName + ); + + // If storage handler couldn't handle it, fall back to regular removal + if (!wasStorageHandled) { + queryClient.removeQueries(activeQuery); + } + } else { + // Not a storage query, handle as regular query removal + queryClient.removeQueries(activeQuery); + } + + syncLogger.logQueryAction(operationId, action, queryHash); + break; + } + case "ACTION-REFETCH": { + // Removed redundant log for cleaner output + const promise = activeQuery.fetch(); + promise.catch((error) => { + // Log fetch errors but don't propagate them + syncLogger.logError( + operationId, + "Refetch Error", + `Refetch failed for ${queryHash}`, + error + ); + log( + `[${deviceName}] Refetch error for ${queryHash}:`, + enableLogs, + "error" + ); + }); + syncLogger.logQueryAction(operationId, action, queryHash); + break; + } + case "ACTION-INVALIDATE": { + // Removed redundant log for cleaner output + queryClient.invalidateQueries(activeQuery); + syncLogger.logQueryAction(operationId, action, queryHash); + break; + } + case "ACTION-ONLINE-MANAGER-ONLINE": { + // Removed redundant log for cleaner output + onlineManager.setOnline(true); + syncLogger.logQueryAction(operationId, action, "online-manager"); + break; + } + case "ACTION-ONLINE-MANAGER-OFFLINE": { + // Removed redundant log for cleaner output + onlineManager.setOnline(false); + syncLogger.logQueryAction(operationId, action, "online-manager"); + break; } - break; - } - case "ACTION-RESET": { - log(`${logPrefix} Resetting query:`, enableLogs); - queryClient.resetQueries(activeQuery); - break; - } - case "ACTION-REMOVE": { - log(`${logPrefix} Removing query:`, enableLogs); - queryClient.removeQueries(activeQuery); - break; - } - case "ACTION-REFETCH": { - log(`${logPrefix} Refetching query:`, enableLogs); - const promise = activeQuery.fetch(); - promise.catch((error) => { - // Log fetch errors but don't propagate them - log( - `[${deviceName}] Refetch error for ${queryHash}:`, - enableLogs, - "error" - ); - }); - break; - } - case "ACTION-INVALIDATE": { - log(`${logPrefix} Invalidating query:`, enableLogs); - queryClient.invalidateQueries(activeQuery); - break; - } - case "ACTION-ONLINE-MANAGER-ONLINE": { - log(`${logPrefix} Setting online state: ONLINE`, enableLogs); - onlineManager.setOnline(true); - break; - } - case "ACTION-ONLINE-MANAGER-OFFLINE": { - log(`${logPrefix} Setting online state: OFFLINE`, enableLogs); - onlineManager.setOnline(false); - break; } + + // Complete the operation successfully + syncLogger.completeOperation(operationId); + } catch (error) { + // Complete the operation with error + syncLogger.logError( + operationId, + "Action Error", + `Failed to execute ${action}`, + error as Error + ); + syncLogger.completeOperation(operationId, false); } } ); @@ -447,7 +943,7 @@ export function useSyncQueriesExternal({ // Cleanup function to unsubscribe from all events // ========================================================== return () => { - log(`${logPrefix} Cleaning up event listeners`, enableLogs); + // Removed repetitive cleanup logging for cleaner output queryActionSubscription?.off(); initialStateSubscription?.off(); onlineManagerSubscription?.off(); @@ -461,6 +957,14 @@ export function useSyncQueriesExternal({ deviceId, enableLogs, logPrefix, + mergedEnvVariables, + storage, + mmkvStorage, + asyncStorage, + secureStorage, + secureStorageKeys, + secureStoragePollInterval, + platform, ]); return { connect, disconnect, isConnected, socket }; diff --git a/src/react-query-external-sync/utils/logger.ts b/src/react-query-external-sync/utils/logger.ts index 86f799f..1cbf525 100644 --- a/src/react-query-external-sync/utils/logger.ts +++ b/src/react-query-external-sync/utils/logger.ts @@ -1,27 +1,362 @@ /** * Log types supported by the logger */ -export type LogType = "log" | "warn" | "error"; +export type LogType = 'log' | 'warn' | 'error'; /** * Helper function for controlled logging * Only shows logs when enableLogs is true * Always shows warnings and errors regardless of enableLogs setting */ -export function log( - message: string, - enableLogs: boolean, - type: LogType = "log" -): void { - if (!enableLogs && type === "log") return; +export function log(message: string, enableLogs: boolean, type: LogType = 'log'): void { + if (!enableLogs && type === 'log') return; switch (type) { - case "warn": + case 'warn': console.warn(message); break; - case "error": + case 'error': console.error(message); break; default: console.log(message); } } + +/** + * Context for sync operations + */ +interface SyncContext { + deviceName: string; + deviceId: string; + platform: string; + requestId: string; + timestamp: number; +} + +/** + * Stats for tracking sync operations + */ +interface SyncOperationStats { + storageUpdates: { + mmkv: number; + asyncStorage: number; + secureStore: number; + }; + queryActions: { + dataUpdates: number; + refetches: number; + invalidations: number; + resets: number; + removes: number; + errors: number; + }; + connectionEvents: { + connects: number; + disconnects: number; + reconnects: number; + }; + errors: Array<{ + type: string; + message: string; + timestamp: number; + }>; +} + +/** + * Sync logger for external sync operations + * Provides clean, grouped logging similar to API route logging + */ +class ExternalSyncLogger { + private operations = new Map< + string, + { + context: SyncContext; + startTime: number; + stats: SyncOperationStats; + enableLogs: boolean; + } + >(); + + /** + * Start a new sync operation + */ + startOperation( + type: 'connection' | 'query-action' | 'storage-update' | 'sync-session', + context: Partial, + enableLogs: boolean = false, + ): string { + const requestId = this.generateRequestId(); + const fullContext: SyncContext = { + deviceName: context.deviceName || 'unknown', + deviceId: context.deviceId || 'unknown', + platform: context.platform || 'unknown', + requestId, + timestamp: Date.now(), + }; + + this.operations.set(requestId, { + context: fullContext, + startTime: Date.now(), + stats: { + storageUpdates: { mmkv: 0, asyncStorage: 0, secureStore: 0 }, + queryActions: { dataUpdates: 0, refetches: 0, invalidations: 0, resets: 0, removes: 0, errors: 0 }, + connectionEvents: { connects: 0, disconnects: 0, reconnects: 0 }, + errors: [], + }, + enableLogs, + }); + + if (enableLogs) { + const icon = this.getOperationIcon(type); + const readableTime = new Date(fullContext.timestamp).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, + }); + + log( + `┌─ 🌴 ${this.getOperationTitle(type)} • ${fullContext.deviceName} (${fullContext.platform}) • ${readableTime}`, + enableLogs, + ); + } + + return requestId; + } + + /** + * Log a storage update + */ + logStorageUpdate( + requestId: string, + storageType: 'mmkv' | 'asyncStorage' | 'secureStore', + key: string, + currentValue: unknown, + newValue: unknown, + ): void { + const operation = this.operations.get(requestId); + if (!operation) return; + + operation.stats.storageUpdates[storageType]++; + + if (operation.enableLogs) { + const icon = this.getStorageIcon(storageType); + const typeDisplay = + storageType === 'asyncStorage' ? 'AsyncStorage' : storageType === 'secureStore' ? 'SecureStore' : 'MMKV'; + log( + `├─ ${icon} ${typeDisplay}: ${key} | ${JSON.stringify(currentValue)} → ${JSON.stringify(newValue)}`, + operation.enableLogs, + ); + } + } + + /** + * Log a query action + */ + logQueryAction(requestId: string, action: string, queryHash: string, success: boolean = true): void { + const operation = this.operations.get(requestId); + if (!operation) return; + + // Update stats based on action type + switch (action) { + case 'ACTION-DATA-UPDATE': + operation.stats.queryActions.dataUpdates++; + break; + case 'ACTION-REFETCH': + operation.stats.queryActions.refetches++; + break; + case 'ACTION-INVALIDATE': + operation.stats.queryActions.invalidations++; + break; + case 'ACTION-RESET': + operation.stats.queryActions.resets++; + break; + case 'ACTION-REMOVE': + operation.stats.queryActions.removes++; + break; + default: + if (!success) operation.stats.queryActions.errors++; + break; + } + + if (operation.enableLogs) { + const icon = this.getActionIcon(action, success); + const actionName = this.getActionDisplayName(action); + log(`├─ ${icon} ${actionName}: ${queryHash}`, operation.enableLogs); + } + } + + /** + * Log a connection event + */ + logConnectionEvent(requestId: string, event: 'connect' | 'disconnect' | 'reconnect', details?: string): void { + const operation = this.operations.get(requestId); + if (!operation) return; + + operation.stats.connectionEvents[`${event}s` as keyof typeof operation.stats.connectionEvents]++; + + if (operation.enableLogs) { + const icon = event === 'connect' ? '🔗' : event === 'disconnect' ? '🔌' : '🔄'; + const message = details ? `${event.toUpperCase()}: ${details}` : event.toUpperCase(); + console.log(`├ ${icon} ${message}`); + } + } + + /** + * Log an error + */ + logError(requestId: string, type: string, message: string, error?: Error): void { + const operation = this.operations.get(requestId); + if (!operation) return; + + operation.stats.errors.push({ + type, + message, + timestamp: Date.now(), + }); + + if (operation.enableLogs) { + console.log(`├ ❌ ${type}: ${message}`); + if (error?.stack) { + console.log(`├ Stack: ${error.stack.split('\n')[1]?.trim()}`); + } + } + } + + /** + * Complete and log the operation summary + */ + completeOperation(requestId: string, success: boolean = true): void { + const operation = this.operations.get(requestId); + if (!operation) return; + + // Only show completion log if there was an error + if (operation.enableLogs && !success) { + log(`└─ ❌ Error`, operation.enableLogs); + log('', operation.enableLogs); // Add empty line for spacing + } else if (operation.enableLogs) { + // Just add spacing for successful operations without the "Complete" message + log('', operation.enableLogs); + } + + // Clean up without logging summary + this.operations.delete(requestId); + } + + private generateRequestId(): string { + return Math.random().toString(36).substring(2, 15); + } + + private getOperationIcon(type: string): string { + switch (type) { + case 'connection': + return '🔗'; + case 'query-action': + return '🔄'; + case 'storage-update': + return '💾'; + case 'sync-session': + return '🔄'; + default: + return '📋'; + } + } + + private getOperationTitle(type: string): string { + switch (type) { + case 'connection': + return 'Connection'; + case 'query-action': + return 'Query Action'; + case 'storage-update': + return 'Storage Update'; + case 'sync-session': + return 'Sync Session'; + default: + return 'Operation'; + } + } + + private getStorageIcon(storageType: string): string { + switch (storageType) { + case 'mmkv': + return '💾'; + case 'asyncStorage': + return '📱'; + case 'secureStore': + return '🔐'; + default: + return '📦'; + } + } + + private getActionIcon(action: string, success: boolean): string { + if (!success) return '🔴'; // Red for failures (#EF4444) + + switch (action) { + case 'ACTION-DATA-UPDATE': + return '🟢'; // Green for fresh/success (#039855) + case 'ACTION-REFETCH': + return '🔵'; // Blue for refetch (#1570EF) + case 'ACTION-INVALIDATE': + return '🟠'; // Orange for invalidate (#DC6803) + case 'ACTION-RESET': + return '⚫'; // Dark gray for reset (#475467) + case 'ACTION-REMOVE': + return '🟣'; // Pink/purple for remove (#DB2777) + case 'ACTION-TRIGGER-ERROR': + return '🔴'; // Red for error (#EF4444) + case 'ACTION-RESTORE-ERROR': + return '🟢'; // Green for restore (success variant) + case 'ACTION-TRIGGER-LOADING': + return '🔷'; // Light blue diamond for loading (#0891B2) + case 'ACTION-RESTORE-LOADING': + return '🔶'; // Orange diamond for restore loading (loading variant) + case 'ACTION-CLEAR-MUTATION-CACHE': + return '⚪'; // White for clear cache (neutral) + case 'ACTION-CLEAR-QUERY-CACHE': + return '⬜'; // White square for clear cache (neutral) + case 'ACTION-ONLINE-MANAGER-ONLINE': + return '🟢'; // Green for online (fresh) + case 'ACTION-ONLINE-MANAGER-OFFLINE': + return '🔴'; // Red for offline (error) + default: + return '⚪'; // White for generic (inactive #667085) + } + } + + private getActionDisplayName(action: string): string { + switch (action) { + case 'ACTION-DATA-UPDATE': + return 'Data Update'; + case 'ACTION-REFETCH': + return 'Refetch'; + case 'ACTION-INVALIDATE': + return 'Invalidate'; + case 'ACTION-RESET': + return 'Reset'; + case 'ACTION-REMOVE': + return 'Remove'; + case 'ACTION-TRIGGER-ERROR': + return 'Trigger Error'; + case 'ACTION-RESTORE-ERROR': + return 'Restore Error'; + case 'ACTION-TRIGGER-LOADING': + return 'Trigger Loading'; + case 'ACTION-RESTORE-LOADING': + return 'Restore Loading'; + default: + return action.replace('ACTION-', '').replace(/-/g, ' '); + } + } + + private formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = (ms / 1000).toFixed(1); + return `${seconds}s`; + } +} + +export const syncLogger = new ExternalSyncLogger(); diff --git a/src/react-query-external-sync/utils/storageHandlers.ts b/src/react-query-external-sync/utils/storageHandlers.ts new file mode 100644 index 0000000..f2b3048 --- /dev/null +++ b/src/react-query-external-sync/utils/storageHandlers.ts @@ -0,0 +1,327 @@ +import type { QueryClient, QueryKey } from "@tanstack/react-query"; + +import { log } from "./logger"; + +/** + * Storage interface that storage implementations should follow + * Supports both MMKV-style (primitives + strings) and AsyncStorage-style (strings only) + */ +export interface StorageInterface { + set: (key: string, value: string | number | boolean) => void | Promise; + delete: (key: string) => void | Promise; +} + +/** + * Unified storage handler that works with both MMKV and AsyncStorage + * This function updates the actual storage and then invalidates the React Query + */ +async function handleStorageOperation( + queryKey: QueryKey, + data: unknown, + queryClient: QueryClient, + storageKey: string, + storage: StorageInterface, + storageType: string, + enableLogs?: boolean, + deviceName?: string +): Promise { + try { + // Update the actual storage with the new data + if (data === null || data === undefined) { + // Delete the key if data is null/undefined + await storage.delete(storageKey); + } else if ( + typeof data === "string" || + typeof data === "number" || + typeof data === "boolean" + ) { + // Handle primitives - both MMKV and AsyncStorage can handle these + // (AsyncStorage will convert numbers/booleans to strings automatically) + await storage.set(storageKey, data); + } else { + // For objects/arrays, JSON stringify for both storage types + const jsonString = JSON.stringify(data); + await storage.set(storageKey, jsonString); + } + + // Manually invalidate the React Query since programmatic storage updates + // don't trigger the change listener automatically + queryClient.invalidateQueries({ queryKey }); + } catch (error) { + log( + `❌ Failed to update ${storageType} storage: ${error}`, + enableLogs || false, + "error" + ); + // Fall back to just updating the query data if storage fails + queryClient.setQueryData(queryKey, data, { + updatedAt: Date.now(), + }); + } +} + +/** + * Unified storage removal handler that works with both MMKV and AsyncStorage + * This function removes the key from actual storage and removes the query from React Query cache + */ +async function handleStorageRemovalOperation( + queryKey: QueryKey, + queryClient: QueryClient, + storageKey: string, + storage: StorageInterface, + storageType: string, + enableLogs?: boolean, + deviceName?: string +): Promise { + try { + // Remove the key from actual storage + await storage.delete(storageKey); + + // Remove the query from React Query cache + queryClient.removeQueries({ queryKey, exact: true }); + } catch (error) { + log( + `❌ Failed to remove ${storageType} storage key: ${error}`, + enableLogs || false, + "error" + ); + // Fall back to just removing the query from cache if storage fails + queryClient.removeQueries({ queryKey, exact: true }); + } +} + +/** + * Handles storage queries by detecting the storage type and delegating to the unified handler + * This function assumes the queryKey is already confirmed to be a storage query + * Expected format: ['#storage', 'storageType', 'key'] + * Supported storage types: 'mmkv', 'asyncstorage', 'async-storage', 'async', 'securestorage', 'secure-storage', 'secure' + * Returns true if it was handled, false if it should fall back to regular query update + */ +export function handleStorageUpdate( + queryKey: QueryKey, + data: unknown, + queryClient: QueryClient, + storage?: StorageInterface, + enableLogs?: boolean, + deviceName?: string +): boolean { + const storageType = queryKey[1] as string; + const storageKey = queryKey[2] as string; + + // Handle different storage types + switch (storageType.toLowerCase()) { + case "mmkv": + if (!storage) { + log( + `⚠️ MMKV storage not configured for key: ${storageKey}`, + enableLogs || false, + "warn" + ); + return false; + } + // Use unified handler for MMKV + handleStorageOperation( + queryKey, + data, + queryClient, + storageKey, + storage, + "MMKV", + enableLogs, + deviceName + ).catch((error) => { + log( + `❌ MMKV storage update failed: ${error}`, + enableLogs || false, + "error" + ); + // Fall back to regular query update if storage fails + queryClient.setQueryData(queryKey, data, { + updatedAt: Date.now(), + }); + }); + return true; + case "asyncstorage": + case "async-storage": + case "async": + if (!storage) { + log( + `⚠️ AsyncStorage not configured for key: ${storageKey}`, + enableLogs || false, + "warn" + ); + return false; + } + // Use unified handler for AsyncStorage + handleStorageOperation( + queryKey, + data, + queryClient, + storageKey, + storage, + "AsyncStorage", + enableLogs, + deviceName + ).catch((error) => { + log( + `❌ AsyncStorage update failed: ${error}`, + enableLogs || false, + "error" + ); + // Fall back to regular query update if storage fails + queryClient.setQueryData(queryKey, data, { + updatedAt: Date.now(), + }); + }); + return true; + case "securestorage": + case "secure-storage": + case "secure": + if (!storage) { + log( + `⚠️ SecureStore not configured for key: ${storageKey}`, + enableLogs || false, + "warn" + ); + return false; + } + // Use unified handler for SecureStore + handleStorageOperation( + queryKey, + data, + queryClient, + storageKey, + storage, + "SecureStore", + enableLogs, + deviceName + ).catch((error) => { + log( + `❌ SecureStore update failed: ${error}`, + enableLogs || false, + "error" + ); + // Fall back to regular query update if storage fails + queryClient.setQueryData(queryKey, data, { + updatedAt: Date.now(), + }); + }); + return true; + default: + // Unknown storage type, let the main function handle it as regular query + return false; + } +} + +/** + * Handles storage query removal by detecting the storage type and delegating to the unified removal handler + * This function assumes the queryKey is already confirmed to be a storage query + * Expected format: ['#storage', 'storageType', 'key'] + * Supported storage types: 'mmkv', 'asyncstorage', 'async-storage', 'async', 'securestorage', 'secure-storage', 'secure' + * Returns true if it was handled, false if it should fall back to regular query removal + */ +export function handleStorageRemoval( + queryKey: QueryKey, + queryClient: QueryClient, + storage?: StorageInterface, + enableLogs?: boolean, + deviceName?: string +): boolean { + const storageType = queryKey[1] as string; + const storageKey = queryKey[2] as string; + + // Handle different storage types + switch (storageType.toLowerCase()) { + case "mmkv": + if (!storage) { + log( + `⚠️ MMKV storage not configured for key: ${storageKey}`, + enableLogs || false, + "warn" + ); + return false; + } + // Use unified removal handler for MMKV + handleStorageRemovalOperation( + queryKey, + queryClient, + storageKey, + storage, + "MMKV", + enableLogs, + deviceName + ).catch((error) => { + log( + `❌ MMKV storage removal failed: ${error}`, + enableLogs || false, + "error" + ); + // Fall back to regular query removal if storage fails + queryClient.removeQueries({ queryKey, exact: true }); + }); + return true; + case "asyncstorage": + case "async-storage": + case "async": + if (!storage) { + log( + `⚠️ AsyncStorage not configured for key: ${storageKey}`, + enableLogs || false, + "warn" + ); + return false; + } + // Use unified removal handler for AsyncStorage + handleStorageRemovalOperation( + queryKey, + queryClient, + storageKey, + storage, + "AsyncStorage", + enableLogs, + deviceName + ).catch((error) => { + log( + `❌ AsyncStorage removal failed: ${error}`, + enableLogs || false, + "error" + ); + // Fall back to regular query removal if storage fails + queryClient.removeQueries({ queryKey, exact: true }); + }); + return true; + case "securestorage": + case "secure-storage": + case "secure": + if (!storage) { + log( + `⚠️ SecureStore not configured for key: ${storageKey}`, + enableLogs || false, + "warn" + ); + return false; + } + // Use unified removal handler for SecureStore + handleStorageRemovalOperation( + queryKey, + queryClient, + storageKey, + storage, + "SecureStore", + enableLogs, + deviceName + ).catch((error) => { + log( + `❌ SecureStore removal failed: ${error}`, + enableLogs || false, + "error" + ); + // Fall back to regular query removal if storage fails + queryClient.removeQueries({ queryKey, exact: true }); + }); + return true; + default: + // Unknown storage type, let the main function handle it as regular query + return false; + } +}