UNPKG

ak-fetch

Version:

Production-ready HTTP client for bulk operations with connection pooling, exponential backoff, streaming, and comprehensive error handling

191 lines (166 loc) 6.35 kB
/** * @fileoverview Preset transformations for popular APIs * These transforms run BEFORE user-defined transforms in the pipeline */ import murmurhash from 'murmurhash'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; import stringify from 'json-stable-stringify'; dayjs.extend(utc); const MAX_STR_LEN = 255; /** * Truncate string to maximum length * @param {string} str * @returns {string} */ function truncate(str) { return str.length > MAX_STR_LEN ? str.substring(0, MAX_STR_LEN) : str; } /** * Mixpanel event transformation * Converts generic JSON data to Mixpanel's expected event format * @param {any} record - Raw event data * @returns {Object} - Transformed Mixpanel event */ function mixpanelEventTransform(record) { // Valid Mixpanel operations for user profiles const validOperations = ["$set", "$set_once", "$add", "$union", "$append", "$remove", "$unset"]; // Reserved profile properties that get special handling const specialProps = [ "name", "first_name", "last_name", "email", "phone", "avatar", "created", "insert_id", "city", "region", "lib_version", "os", "os_version", "browser", "browser_version", "app_build_number", "app_version_string", "device", "screen_height", "screen_width", "screen_dpi", "current_url", "initial_referrer", "initial_referring_domain", "referrer", "referring_domain", "search_engine", "manufacturer", "brand", "model", "watch_model", "carrier", "radio", "wifi", "bluetooth_enabled", "bluetooth_version", "has_nfc", "has_telephone", "google_play_services", "duration", "country", "country_code" ]; // Properties that stay outside of $set const outsideProps = ["distinct_id", "group_id", "token", "group_key", "ip"]; // 1. Fix "wrong shape": ensure record.properties exists if (!record.properties) { record.properties = { ...record }; for (const key of Object.keys(record)) { if (key !== "properties" && key !== "event") { delete record[key]; } } } // 2. Normalize time/timestamp to UNIX epoch (ms) // Handle both 'time' and 'timestamp' fields, prefer 'time' if (record.properties.timestamp && !record.properties.time) { record.properties.time = record.properties.timestamp; delete record.properties.timestamp; } if (record.properties.time && Number.isNaN(Number(record.properties.time))) { record.properties.time = dayjs.utc(record.properties.time).valueOf(); } // 3. Add $insert_id if missing if (!record.properties.$insert_id) { try { const tuple = [ record.event, record.properties.distinct_id || "", record.properties.time, ].join("-"); record.properties.$insert_id = murmurhash.v3(tuple).toString(); } catch { record.properties.$insert_id = String(record.properties.distinct_id); } } // 4. Handle distinct_id (required by Mixpanel) if (record.properties.user_id && !record.properties.distinct_id) { record.properties.distinct_id = record.properties.user_id; } // 5. Rename well-known keys to Mixpanel's $-prefixed versions ["user_id", "device_id", "source"].forEach((orig) => { if (record.properties[orig]) { record.properties[`$${orig}`] = record.properties[orig]; delete record.properties[orig]; } }); // 6. Promote "special" props for (const key of Object.keys(record.properties)) { if (specialProps.includes(key)) { if (key === "country") { record.properties.mp_country_code = record.properties[key]; } else { record.properties[`$${key}`] = record.properties[key]; } delete record.properties[key]; } } // 7. Ensure distinct_id, $user_id, $device_id are strings ["distinct_id", "$user_id", "$device_id"].forEach((k) => { if (record.properties[k] != null) { record.properties[k] = String(record.properties[k]); } }); // 8. Truncate all string property values for (const [k, v] of Object.entries(record.properties)) { if (typeof v === "string") { record.properties[k] = truncate(v); } } delete record.properties.event; // Remove 'event' key if it exists return record; } /** * Registry of available presets */ const PRESET_REGISTRY = { 'mixpanel': mixpanelEventTransform, // Future presets will be added here: // 'amplitude': amplitudeTransform, // 'pendo': pendoTransform, }; /** * Get available preset names * @returns {string[]} Array of preset names */ function getAvailablePresets() { return Object.keys(PRESET_REGISTRY); } /** * Validate and get preset transform function * @param {string} presetName - Name of the preset * @returns {Function} Transform function * @throws {Error} If preset name is invalid */ function getPresetTransform(presetName) { if (!presetName || typeof presetName !== 'string') { throw new Error('Preset name must be a non-empty string'); } const transform = PRESET_REGISTRY[presetName.toLowerCase()]; if (!transform) { const available = getAvailablePresets().join(', '); throw new Error(`Invalid preset '${presetName}'. Available presets: ${available}`); } return transform; } /** * Apply preset transformation to a data record * @param {Object} record - Data record to transform * @param {string} presetName - Name of the preset to apply * @param {Function} [errorHandler] - Optional error handler function * @returns {Object} Transformed record */ function applyPresetTransform(record, presetName, errorHandler) { try { const transform = getPresetTransform(presetName); return transform(record); } catch (error) { if (errorHandler && typeof errorHandler === 'function') { errorHandler(error, record); return record; // Return original record if error handler doesn't throw } throw error; } } export { PRESET_REGISTRY, getAvailablePresets, getPresetTransform, applyPresetTransform };