@shware/analytics 11.8.0 → 11.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"meta.mjs","names":[],"sources":["../../src/ads/meta.ts"],"sourcesContent":["/**\n * Meta ad delivery through the Marketing API's Insights endpoint, over plain `fetch`: one row per\n * ad and hour, in the attribution vocabulary (see `AdPerformanceRow`).\n *\n * The token needs `ads_read` on the ad account — a system user token with the account assigned\n * at \"View performance\". It is sent in the `Authorization` header and never put in a URL: paging\n * follows the `after` cursor rather than Meta's `paging.next`, which embeds the token.\n *\n * https://developers.facebook.com/docs/marketing-api/insights\n * https://developers.facebook.com/docs/marketing-api/insights/breakdowns\n */\nimport { createHmac } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport {\n type infer as Infer,\n type ZodMiniType,\n array,\n number,\n object,\n optional,\n safeParse,\n string,\n} from 'zod/mini';\nimport { channelGroupOf } from '../attribution';\nimport { addDays, addMonths, todayIn, zonedHourToUtc } from './time';\nimport type { AdPerformanceRow, ReportDate } from './types';\n\n/** Same Graph API version as the Conversions API sender. */\nconst API_VERSION = 'v24.0';\nconst GRAPH = `https://graph.facebook.com/${API_VERSION}`;\n\n/** What an ad click is in the attribution vocabulary: `meta / cpc`, a paid social touch. */\nconst CHANNEL = 'meta';\nconst MEDIUM = 'cpc';\n\n/**\n * The click window of the click-through columns. Meta's longest since 2021, and the click part of\n * its default setting (7-day click, 1-day view).\n */\nconst CLICK_WINDOW = '7d_click';\nconst VIEW_WINDOW = '1d_view';\n\nconst HOURLY = 'hourly_stats_aggregated_by_advertiser_time_zone';\n\n/**\n * How far back Meta answers the hourly breakdown, in months. Past it Meta does not fail — it\n * returns no rows — so a backfill would silently store a gap.\n * https://developers.facebook.com/docs/marketing-api/insights/best-practices\n */\nconst HOURLY_RETENTION_MONTHS = 13;\n\n/**\n * Days per Insights request. A synchronous request over a long range of ad rows times out on\n * Meta's side (`code 1, subcode 99`, \"An unknown error occurred\", as a 500): 17 months of daily\n * ad rows did. A range is split into windows this long, each paged on its own.\n */\nconst CHUNK_DAYS = 7;\n\nconst FIELDS = [\n 'ad_id',\n 'ad_name',\n 'adset_id',\n 'adset_name',\n 'campaign_id',\n 'campaign_name',\n 'spend',\n 'impressions',\n 'inline_link_clicks',\n 'actions',\n 'action_values',\n];\n\nexport interface MetaAdPerformanceOptions {\n /** A token with `ads_read` on the account. */\n accessToken: string;\n /** The ad account id, with or without its `act_` prefix. */\n accountId: string;\n /** First day, in the ad account's time zone. */\n since: ReportDate;\n /** Last day, inclusive, in the ad account's time zone. */\n until: ReportDate;\n /**\n * The `action_type` counted as the conversion, default `purchase` (Meta's total of the pixel,\n * Conversions API and in-app purchases of an event, deduplicated).\n */\n conversionActionType?: string;\n /**\n * The app secret, when the app requires `appsecret_proof` (App settings → Advanced → \"Require\n * app secret\"). The proof is computed here; the secret itself is never sent.\n */\n appSecret?: string;\n}\n\n/** A Graph API error, with Meta's code, for a caller deciding whether to retry. */\nexport class MetaAdsApiError extends Error {\n constructor(\n message: string,\n readonly status: number,\n readonly code: number | undefined,\n readonly subcode: number | undefined,\n readonly fbtraceId: string | undefined\n ) {\n super(message);\n this.name = 'MetaAdsApiError';\n }\n}\n\nconst actionSchema = object({\n action_type: string(),\n /**\n * The count under the ad's own attribution setting. Absent when the setting does not count any\n * of it — a view-only conversion of an ad set to 1-day click — which only shows in the windows.\n */\n value: optional(string()),\n [CLICK_WINDOW]: optional(string()),\n [VIEW_WINDOW]: optional(string()),\n});\n\nconst insightSchema = object({\n ad_id: string(),\n ad_name: optional(string()),\n adset_id: optional(string()),\n adset_name: optional(string()),\n campaign_id: string(),\n campaign_name: optional(string()),\n spend: optional(string()),\n impressions: optional(string()),\n inline_link_clicks: optional(string()),\n actions: optional(array(actionSchema)),\n action_values: optional(array(actionSchema)),\n date_start: string(),\n [HOURLY]: optional(string()),\n});\n\nconst pageSchema = object({\n data: array(insightSchema),\n paging: optional(\n object({ cursors: optional(object({ after: optional(string()) })), next: optional(string()) })\n ),\n});\n\nconst accountSchema = object({ currency: string(), timezone_name: string() });\n\nconst errorSchema = object({\n error: object({\n message: string(),\n code: optional(number()),\n error_subcode: optional(number()),\n fbtrace_id: optional(string()),\n }),\n});\n\ntype Insight = Infer<typeof insightSchema>;\ntype Action = Infer<typeof actionSchema>;\n\nasync function graphGet<T>(\n path: string,\n params: Record<string, string>,\n options: MetaAdPerformanceOptions,\n parse: (body: unknown) => T\n): Promise<T> {\n const query = new URLSearchParams(params);\n if (options.appSecret) {\n query.set(\n 'appsecret_proof',\n createHmac('sha256', options.appSecret).update(options.accessToken).digest('hex')\n );\n }\n const response = await fetch(`${GRAPH}/${path}?${query}`, {\n headers: { authorization: `Bearer ${options.accessToken}` },\n });\n const body: unknown = await response.json().catch(() => null);\n if (!response.ok) {\n const parsed = safeParse(errorSchema, body);\n const error = parsed.success ? parsed.data.error : undefined;\n throw new MetaAdsApiError(\n error?.message ?? `Meta Graph API responded ${response.status}`,\n response.status,\n error?.code,\n error?.error_subcode,\n error?.fbtrace_id\n );\n }\n return parse(body);\n}\n\nfunction parseWith<T>(schema: ZodMiniType<T>, what: string) {\n return (body: unknown): T => {\n const parsed = safeParse(schema, body);\n if (!parsed.success) {\n const issues = parsed.error.issues;\n const first = issues\n .slice(0, 3)\n .map((issue) => `${issue.path.join('.')}: ${issue.message}`)\n .join('; ');\n throw new Error(\n `Unexpected Meta ${what} response (${issues.length} issues): ${first}${issues.length > 3 ? '; …' : ''}`\n );\n }\n return parsed.data;\n };\n}\n\nfunction count(value: string | undefined): number {\n return value === undefined ? 0 : Number(value);\n}\n\n/** The action's total and its click-through part; nulls when Meta reported no such action. */\nfunction conversion(actions: Action[] | undefined, actionType: string) {\n const action = actions?.find((a) => a.action_type === actionType);\n if (!action) return { total: null, click: null };\n return { total: Number(action.value ?? 0), click: Number(action[CLICK_WINDOW] ?? 0) };\n}\n\n/** `\"13:00:00 - 13:59:59\"` → 13. */\nfunction hourOf(bucket: string | undefined): number {\n const hour = Number(bucket?.slice(0, 2));\n if (!Number.isInteger(hour) || hour < 0 || hour > 23) {\n throw new Error(`Unexpected Meta hourly bucket: ${bucket}`);\n }\n return hour;\n}\n\nfunction toRow(\n insight: Insight,\n accountId: string,\n currency: string,\n timeZone: string,\n actionType: string\n): AdPerformanceRow {\n const start = zonedHourToUtc(insight.date_start, hourOf(insight[HOURLY]), timeZone);\n const conversions = conversion(insight.actions, actionType);\n const values = conversion(insight.action_values, actionType);\n return {\n platform: 'meta',\n account_id: accountId,\n campaign_id: insight.campaign_id,\n campaign_name: insight.campaign_name ?? null,\n ad_group_id: insight.adset_id ?? null,\n ad_group_name: insight.adset_name ?? null,\n ad_id: insight.ad_id,\n ad_name: insight.ad_name ?? null,\n channel: CHANNEL,\n medium: MEDIUM,\n channel_group: channelGroupOf(CHANNEL, MEDIUM),\n hour_start: start.toISOString(),\n currency,\n spend: Number(insight.spend ?? 0),\n impressions: count(insight.impressions),\n clicks: count(insight.inline_link_clicks),\n conversions: conversions.total,\n conversion_value: values.total,\n click_conversions: conversions.click,\n click_conversion_value: values.click,\n };\n}\n\n/**\n * Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and\n * archived ads included — one row per ad and hour (hours without delivery are absent). The hours\n * are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each\n * ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the\n * 7-day click window.\n *\n * Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the\n * click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days\n * daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,\n * `ad_id`, `hour_start`).\n *\n * @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly\n * breakdown and would answer with no rows rather than an error.\n */\nexport async function fetchMetaAdPerformance(\n options: MetaAdPerformanceOptions\n): Promise<AdPerformanceRow[]> {\n const accountId = options.accountId.replace(/^act_/, '');\n const actionType = options.conversionActionType ?? 'purchase';\n\n const account = await graphGet(\n `act_${accountId}`,\n { fields: 'currency,timezone_name' },\n options,\n parseWith(accountSchema, 'ad account')\n );\n\n // Normalized, so `2025-9-8` compares as `2025-09-08`.\n const since = addDays(options.since, 0);\n const until = addDays(options.until, 0);\n const earliest = addMonths(todayIn(account.timezone_name), -HOURLY_RETENTION_MONTHS);\n if (since < earliest) {\n throw new RangeError(\n `Meta keeps hourly insights for ${HOURLY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`\n );\n }\n\n const rows: AdPerformanceRow[] = [];\n for (let from = since; from <= until; from = addDays(from, CHUNK_DAYS)) {\n const last = addDays(from, CHUNK_DAYS - 1);\n const to = last < until ? last : until;\n let after: string | undefined;\n do {\n const params: Record<string, string> = {\n level: 'ad',\n fields: FIELDS.join(','),\n time_range: JSON.stringify({ since: from, until: to }),\n // One row per day. Without it the hourly breakdown folds the whole range into 24\n // hour-of-day buckets, all dated `since`.\n time_increment: '1',\n breakdowns: HOURLY,\n action_attribution_windows: JSON.stringify([CLICK_WINDOW, VIEW_WINDOW]),\n use_unified_attribution_setting: 'true',\n limit: '500',\n };\n if (after) params.after = after;\n const page = await graphGet(\n `act_${accountId}/insights`,\n params,\n options,\n parseWith(pageSchema, 'insights')\n );\n for (const insight of page.data) {\n rows.push(toRow(insight, accountId, account.currency, account.timezone_name, actionType));\n }\n // `next` is absent on the last page; the cursor alone is not a signal, Meta returns it there too.\n after = page.paging?.next ? page.paging.cursors?.after : undefined;\n } while (after);\n }\n\n return rows;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AA6BA,MAAM,QAAQ;;AAGd,MAAM,UAAU;AAChB,MAAM,SAAS;;;;;AAMf,MAAM,eAAe;AACrB,MAAM,cAAc;AAEpB,MAAM,SAAS;;;;;;AAOf,MAAM,0BAA0B;;;;;;AAOhC,MAAM,aAAa;AAEnB,MAAM,SAAS;CACb;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;AAwBA,IAAa,kBAAb,cAAqC,MAAM;CACzC,YACE,SACA,QACA,MACA,SACA,WACA;EACA,MAAM,OAAO;EALJ,KAAA,SAAA;EACA,KAAA,OAAA;EACA,KAAA,UAAA;EACA,KAAA,YAAA;EAGT,KAAK,OAAO;CACd;AACF;AAEA,MAAM,eAAe,OAAO;CAC1B,aAAa,OAAO;;;;;CAKpB,OAAO,SAAS,OAAO,CAAC;EACvB,eAAe,SAAS,OAAO,CAAC;EAChC,cAAc,SAAS,OAAO,CAAC;AAClC,CAAC;AAED,MAAM,gBAAgB,OAAO;CAC3B,OAAO,OAAO;CACd,SAAS,SAAS,OAAO,CAAC;CAC1B,UAAU,SAAS,OAAO,CAAC;CAC3B,YAAY,SAAS,OAAO,CAAC;CAC7B,aAAa,OAAO;CACpB,eAAe,SAAS,OAAO,CAAC;CAChC,OAAO,SAAS,OAAO,CAAC;CACxB,aAAa,SAAS,OAAO,CAAC;CAC9B,oBAAoB,SAAS,OAAO,CAAC;CACrC,SAAS,SAAS,MAAM,YAAY,CAAC;CACrC,eAAe,SAAS,MAAM,YAAY,CAAC;CAC3C,YAAY,OAAO;EAClB,SAAS,SAAS,OAAO,CAAC;AAC7B,CAAC;AAED,MAAM,aAAa,OAAO;CACxB,MAAM,MAAM,aAAa;CACzB,QAAQ,SACN,OAAO;EAAE,SAAS,SAAS,OAAO,EAAE,OAAO,SAAS,OAAO,CAAC,EAAE,CAAC,CAAC;EAAG,MAAM,SAAS,OAAO,CAAC;CAAE,CAAC,CAC/F;AACF,CAAC;AAED,MAAM,gBAAgB,OAAO;CAAE,UAAU,OAAO;CAAG,eAAe,OAAO;AAAE,CAAC;AAE5E,MAAM,cAAc,OAAO,EACzB,OAAO,OAAO;CACZ,SAAS,OAAO;CAChB,MAAM,SAAS,OAAO,CAAC;CACvB,eAAe,SAAS,OAAO,CAAC;CAChC,YAAY,SAAS,OAAO,CAAC;AAC/B,CAAC,EACH,CAAC;AAKD,eAAe,SACb,MACA,QACA,SACA,OACY;CACZ,MAAM,QAAQ,IAAI,gBAAgB,MAAM;CACxC,IAAI,QAAQ,WACV,MAAM,IACJ,mBACA,WAAW,UAAU,QAAQ,SAAS,CAAC,CAAC,OAAO,QAAQ,WAAW,CAAC,CAAC,OAAO,KAAK,CAClF;CAEF,MAAM,WAAW,MAAM,MAAM,GAAG,MAAM,GAAG,KAAK,GAAG,SAAS,EACxD,SAAS,EAAE,eAAe,UAAU,QAAQ,cAAc,EAC5D,CAAC;CACD,MAAM,OAAgB,MAAM,SAAS,KAAK,CAAC,CAAC,YAAY,IAAI;CAC5D,IAAI,CAAC,SAAS,IAAI;EAChB,MAAM,SAAS,UAAU,aAAa,IAAI;EAC1C,MAAM,QAAQ,OAAO,UAAU,OAAO,KAAK,QAAQ,KAAA;EACnD,MAAM,IAAI,gBACR,OAAO,WAAW,4BAA4B,SAAS,UACvD,SAAS,QACT,OAAO,MACP,OAAO,eACP,OAAO,UACT;CACF;CACA,OAAO,MAAM,IAAI;AACnB;AAEA,SAAS,UAAa,QAAwB,MAAc;CAC1D,QAAQ,SAAqB;EAC3B,MAAM,SAAS,UAAU,QAAQ,IAAI;EACrC,IAAI,CAAC,OAAO,SAAS;GACnB,MAAM,SAAS,OAAO,MAAM;GAC5B,MAAM,QAAQ,OACX,MAAM,GAAG,CAAC,CAAC,CACX,KAAK,UAAU,GAAG,MAAM,KAAK,KAAK,GAAG,EAAE,IAAI,MAAM,SAAS,CAAC,CAC3D,KAAK,IAAI;GACZ,MAAM,IAAI,MACR,mBAAmB,KAAK,aAAa,OAAO,OAAO,YAAY,QAAQ,OAAO,SAAS,IAAI,QAAQ,IACrG;EACF;EACA,OAAO,OAAO;CAChB;AACF;AAEA,SAAS,MAAM,OAAmC;CAChD,OAAO,UAAU,KAAA,IAAY,IAAI,OAAO,KAAK;AAC/C;;AAGA,SAAS,WAAW,SAA+B,YAAoB;CACrE,MAAM,SAAS,SAAS,MAAM,MAAM,EAAE,gBAAgB,UAAU;CAChE,IAAI,CAAC,QAAQ,OAAO;EAAE,OAAO;EAAM,OAAO;CAAK;CAC/C,OAAO;EAAE,OAAO,OAAO,OAAO,SAAS,CAAC;EAAG,OAAO,OAAO,OAAO,iBAAiB,CAAC;CAAE;AACtF;;AAGA,SAAS,OAAO,QAAoC;CAClD,MAAM,OAAO,OAAO,QAAQ,MAAM,GAAG,CAAC,CAAC;CACvC,IAAI,CAAC,OAAO,UAAU,IAAI,KAAK,OAAO,KAAK,OAAO,IAChD,MAAM,IAAI,MAAM,kCAAkC,QAAQ;CAE5D,OAAO;AACT;AAEA,SAAS,MACP,SACA,WACA,UACA,UACA,YACkB;CAClB,MAAM,QAAQ,eAAe,QAAQ,YAAY,OAAO,QAAQ,OAAO,GAAG,QAAQ;CAClF,MAAM,cAAc,WAAW,QAAQ,SAAS,UAAU;CAC1D,MAAM,SAAS,WAAW,QAAQ,eAAe,UAAU;CAC3D,OAAO;EACL,UAAU;EACV,YAAY;EACZ,aAAa,QAAQ;EACrB,eAAe,QAAQ,iBAAiB;EACxC,aAAa,QAAQ,YAAY;EACjC,eAAe,QAAQ,cAAc;EACrC,OAAO,QAAQ;EACf,SAAS,QAAQ,WAAW;EAC5B,SAAS;EACT,QAAQ;EACR,eAAe,eAAe,SAAS,MAAM;EAC7C,YAAY,MAAM,YAAY;EAC9B;EACA,OAAO,OAAO,QAAQ,SAAS,CAAC;EAChC,aAAa,MAAM,QAAQ,WAAW;EACtC,QAAQ,MAAM,QAAQ,kBAAkB;EACxC,aAAa,YAAY;EACzB,kBAAkB,OAAO;EACzB,mBAAmB,YAAY;EAC/B,wBAAwB,OAAO;CACjC;AACF;;;;;;;;;;;;;;;;AAiBA,eAAsB,uBACpB,SAC6B;CAC7B,MAAM,YAAY,QAAQ,UAAU,QAAQ,SAAS,EAAE;CACvD,MAAM,aAAa,QAAQ,wBAAwB;CAEnD,MAAM,UAAU,MAAM,SACpB,OAAO,aACP,EAAE,QAAQ,yBAAyB,GACnC,SACA,UAAU,eAAe,YAAY,CACvC;CAGA,MAAM,QAAQ,QAAQ,QAAQ,OAAO,CAAC;CACtC,MAAM,QAAQ,QAAQ,QAAQ,OAAO,CAAC;CACtC,MAAM,WAAW,UAAU,QAAQ,QAAQ,aAAa,GAAG,GAAwB;CACnF,IAAI,QAAQ,UACV,MAAM,IAAI,WACR,kCAAkC,wBAAwB,iBAAiB,MAAM,aAAa,UAChG;CAGF,MAAM,OAA2B,CAAC;CAClC,KAAK,IAAI,OAAO,OAAO,QAAQ,OAAO,OAAO,QAAQ,MAAM,UAAU,GAAG;EACtE,MAAM,OAAO,QAAQ,MAAM,CAAc;EACzC,MAAM,KAAK,OAAO,QAAQ,OAAO;EACjC,IAAI;EACJ,GAAG;GACD,MAAM,SAAiC;IACrC,OAAO;IACP,QAAQ,OAAO,KAAK,GAAG;IACvB,YAAY,KAAK,UAAU;KAAE,OAAO;KAAM,OAAO;IAAG,CAAC;IAGrD,gBAAgB;IAChB,YAAY;IACZ,4BAA4B,KAAK,UAAU,CAAC,cAAc,WAAW,CAAC;IACtE,iCAAiC;IACjC,OAAO;GACT;GACA,IAAI,OAAO,OAAO,QAAQ;GAC1B,MAAM,OAAO,MAAM,SACjB,OAAO,UAAU,YACjB,QACA,SACA,UAAU,YAAY,UAAU,CAClC;GACA,KAAK,MAAM,WAAW,KAAK,MACzB,KAAK,KAAK,MAAM,SAAS,WAAW,QAAQ,UAAU,QAAQ,eAAe,UAAU,CAAC;GAG1F,QAAQ,KAAK,QAAQ,OAAO,KAAK,OAAO,SAAS,QAAQ,KAAA;EAC3D,SAAS;CACX;CAEA,OAAO;AACT"}
1
+ {"version":3,"file":"meta.mjs","names":[],"sources":["../../src/ads/meta.ts"],"sourcesContent":["/**\n * Meta ad delivery through the Marketing API's Insights endpoint, over plain `fetch`: one row per\n * ad and hour, in the attribution vocabulary (see `AdPerformanceRow`).\n *\n * The token needs `ads_read` on the ad account — a system user token with the account assigned\n * at \"View performance\". It is sent in the `Authorization` header and never put in a URL: paging\n * follows the `after` cursor rather than Meta's `paging.next`, which embeds the token.\n *\n * https://developers.facebook.com/docs/marketing-api/insights\n * https://developers.facebook.com/docs/marketing-api/insights/breakdowns\n */\nimport { createHmac } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport {\n type infer as Infer,\n type ZodMiniType,\n array,\n number,\n object,\n optional,\n safeParse,\n string,\n} from 'zod/mini';\nimport { channelGroupOf } from '../attribution';\nimport { addDays, addMonths, todayIn, zonedHourToUtc } from './time';\nimport type { AdPerformanceRow, ReportDate } from './types';\n\n/** Same Graph API version as the Conversions API sender. */\nconst API_VERSION = 'v24.0';\nconst GRAPH = `https://graph.facebook.com/${API_VERSION}`;\n\n/** What an ad click is in the attribution vocabulary: `meta / cpc`, a paid social touch. */\nconst CHANNEL = 'meta';\nconst MEDIUM = 'cpc';\n\n/**\n * The click window of the click-through columns. Meta's longest since 2021, and the click part of\n * its default setting (7-day click, 1-day view).\n */\nconst CLICK_WINDOW = '7d_click';\nconst VIEW_WINDOW = '1d_view';\n\nconst HOURLY = 'hourly_stats_aggregated_by_advertiser_time_zone';\n\n/**\n * How far back Meta answers the hourly breakdown, in months. Past it Meta does not fail — it\n * returns no rows — so a backfill would silently store a gap.\n * https://developers.facebook.com/docs/marketing-api/insights/best-practices\n */\nconst HOURLY_RETENTION_MONTHS = 13;\n\n/** How far back Meta answers Insights without the hourly breakdown, in months. */\nconst DAILY_RETENTION_MONTHS = 37;\n\n/**\n * Days per Insights request. A synchronous request over a long range of ad rows times out on\n * Meta's side (`code 1, subcode 99`, \"An unknown error occurred\", as a 500): 17 months of daily\n * ad rows did. A range is split into windows this long, each paged on its own.\n */\nconst CHUNK_DAYS = 7;\n\n/** Days per request for daily rows: 24 times fewer rows than hourly ones per day. */\nconst DAILY_CHUNK_DAYS = 30;\n\n/** Decimal places of a day's numbers spread over its hours. */\nconst SPREAD_SCALE = 1e6;\n\nconst FIELDS = [\n 'ad_id',\n 'ad_name',\n 'adset_id',\n 'adset_name',\n 'campaign_id',\n 'campaign_name',\n 'spend',\n 'impressions',\n 'inline_link_clicks',\n 'actions',\n 'action_values',\n];\n\nexport interface MetaAdPerformanceOptions {\n /** A token with `ads_read` on the account. */\n accessToken: string;\n /** The ad account id, with or without its `act_` prefix. */\n accountId: string;\n /** First day, in the ad account's time zone. */\n since: ReportDate;\n /** Last day, inclusive, in the ad account's time zone. */\n until: ReportDate;\n /**\n * The `action_type` counted as the conversion, default `purchase` (Meta's total of the pixel,\n * Conversions API and in-app purchases of an event, deduplicated).\n */\n conversionActionType?: string;\n /**\n * The app secret, when the app requires `appsecret_proof` (App settings → Advanced → \"Require\n * app secret\"). The proof is computed here; the secret itself is never sent.\n */\n appSecret?: string;\n}\n\n/** A Graph API error, with Meta's code, for a caller deciding whether to retry. */\nexport class MetaAdsApiError extends Error {\n constructor(\n message: string,\n readonly status: number,\n readonly code: number | undefined,\n readonly subcode: number | undefined,\n readonly fbtraceId: string | undefined\n ) {\n super(message);\n this.name = 'MetaAdsApiError';\n }\n}\n\nconst actionSchema = object({\n action_type: string(),\n /**\n * The count under the ad's own attribution setting. Absent when the setting does not count any\n * of it — a view-only conversion of an ad set to 1-day click — which only shows in the windows.\n */\n value: optional(string()),\n [CLICK_WINDOW]: optional(string()),\n [VIEW_WINDOW]: optional(string()),\n});\n\nconst insightSchema = object({\n ad_id: string(),\n ad_name: optional(string()),\n adset_id: optional(string()),\n adset_name: optional(string()),\n campaign_id: string(),\n campaign_name: optional(string()),\n spend: optional(string()),\n impressions: optional(string()),\n inline_link_clicks: optional(string()),\n actions: optional(array(actionSchema)),\n action_values: optional(array(actionSchema)),\n date_start: string(),\n [HOURLY]: optional(string()),\n});\n\nconst pageSchema = object({\n data: array(insightSchema),\n paging: optional(\n object({ cursors: optional(object({ after: optional(string()) })), next: optional(string()) })\n ),\n});\n\nconst accountSchema = object({ currency: string(), timezone_name: string() });\n\nconst errorSchema = object({\n error: object({\n message: string(),\n code: optional(number()),\n error_subcode: optional(number()),\n fbtrace_id: optional(string()),\n }),\n});\n\ntype Insight = Infer<typeof insightSchema>;\ntype Action = Infer<typeof actionSchema>;\n\nasync function graphGet<T>(\n path: string,\n params: Record<string, string>,\n options: MetaAdPerformanceOptions,\n parse: (body: unknown) => T\n): Promise<T> {\n const query = new URLSearchParams(params);\n if (options.appSecret) {\n query.set(\n 'appsecret_proof',\n createHmac('sha256', options.appSecret).update(options.accessToken).digest('hex')\n );\n }\n const response = await fetch(`${GRAPH}/${path}?${query}`, {\n headers: { authorization: `Bearer ${options.accessToken}` },\n });\n const body: unknown = await response.json().catch(() => null);\n if (!response.ok) {\n const parsed = safeParse(errorSchema, body);\n const error = parsed.success ? parsed.data.error : undefined;\n throw new MetaAdsApiError(\n error?.message ?? `Meta Graph API responded ${response.status}`,\n response.status,\n error?.code,\n error?.error_subcode,\n error?.fbtrace_id\n );\n }\n return parse(body);\n}\n\nfunction parseWith<T>(schema: ZodMiniType<T>, what: string) {\n return (body: unknown): T => {\n const parsed = safeParse(schema, body);\n if (!parsed.success) {\n const issues = parsed.error.issues;\n const first = issues\n .slice(0, 3)\n .map((issue) => `${issue.path.join('.')}: ${issue.message}`)\n .join('; ');\n throw new Error(\n `Unexpected Meta ${what} response (${issues.length} issues): ${first}${issues.length > 3 ? '; …' : ''}`\n );\n }\n return parsed.data;\n };\n}\n\nfunction count(value: string | undefined): number {\n return value === undefined ? 0 : Number(value);\n}\n\n/** The action's total and its click-through part; nulls when Meta reported no such action. */\nfunction conversion(actions: Action[] | undefined, actionType: string) {\n const action = actions?.find((a) => a.action_type === actionType);\n if (!action) return { total: null, click: null };\n return { total: Number(action.value ?? 0), click: Number(action[CLICK_WINDOW] ?? 0) };\n}\n\n/** `\"13:00:00 - 13:59:59\"` → 13. */\nfunction hourOf(bucket: string | undefined): number {\n const hour = Number(bucket?.slice(0, 2));\n if (!Number.isInteger(hour) || hour < 0 || hour > 23) {\n throw new Error(`Unexpected Meta hourly bucket: ${bucket}`);\n }\n return hour;\n}\n\n/** A row for one hour: what Meta reports of an ad, its numbers as given. */\nfunction toRow(\n insight: Insight,\n hourStart: Date,\n accountId: string,\n currency: string,\n actionType: string\n): AdPerformanceRow {\n const conversions = conversion(insight.actions, actionType);\n const values = conversion(insight.action_values, actionType);\n return {\n platform: 'meta',\n account_id: accountId,\n campaign_id: insight.campaign_id,\n campaign_name: insight.campaign_name ?? null,\n ad_group_id: insight.adset_id ?? null,\n ad_group_name: insight.adset_name ?? null,\n ad_id: insight.ad_id,\n ad_name: insight.ad_name ?? null,\n channel: CHANNEL,\n medium: MEDIUM,\n channel_group: channelGroupOf(CHANNEL, MEDIUM),\n hour_start: hourStart.toISOString(),\n currency,\n spend: Number(insight.spend ?? 0),\n impressions: count(insight.impressions),\n clicks: count(insight.inline_link_clicks),\n conversions: conversions.total,\n conversion_value: values.total,\n click_conversions: conversions.click,\n click_conversion_value: values.click,\n };\n}\n\n/**\n * `value` in `parts` equal shares rounded to `SPREAD_SCALE`, the rounding left in the last share\n * so that the shares add back to `value` exactly as Meta reported it.\n */\nexport function spread(value: number, parts: number): number[] {\n const share = Math.round((value / parts) * SPREAD_SCALE) / SPREAD_SCALE;\n const last = Math.round((value - share * (parts - 1)) * SPREAD_SCALE) / SPREAD_SCALE;\n return [...Array.from({ length: parts - 1 }, () => share), last];\n}\n\n/**\n * A day row as one row per hour of the account's calendar day — 24, or 23 / 25 across a\n * daylight-saving change — each number spread evenly (nulls stay null).\n */\nfunction spreadDay(day: AdPerformanceRow, date: string, timeZone: string): AdPerformanceRow[] {\n const start = zonedHourToUtc(date, 0, timeZone).getTime();\n const end = zonedHourToUtc(addDays(date, 1), 0, timeZone).getTime();\n const hours = Math.round((end - start) / 3_600_000);\n const shares = (value: number | null) => (value === null ? null : spread(value, hours));\n const spend = spread(day.spend, hours);\n const impressions = spread(day.impressions, hours);\n const clicks = spread(day.clicks, hours);\n const conversions = shares(day.conversions);\n const conversionValue = shares(day.conversion_value);\n const clickConversions = shares(day.click_conversions);\n const clickConversionValue = shares(day.click_conversion_value);\n return Array.from({ length: hours }, (_, hour) => ({\n ...day,\n hour_start: new Date(start + hour * 3_600_000).toISOString(),\n spend: spend[hour],\n impressions: impressions[hour],\n clicks: clicks[hour],\n conversions: conversions?.[hour] ?? null,\n conversion_value: conversionValue?.[hour] ?? null,\n click_conversions: clickConversions?.[hour] ?? null,\n click_conversion_value: clickConversionValue?.[hour] ?? null,\n }));\n}\n\ninterface Account {\n id: string;\n currency: string;\n timezone_name: string;\n}\n\nasync function accountOf(options: MetaAdPerformanceOptions): Promise<Account> {\n const id = options.accountId.replace(/^act_/, '');\n const account = await graphGet(\n `act_${id}`,\n { fields: 'currency,timezone_name' },\n options,\n parseWith(accountSchema, 'ad account')\n );\n return { id, ...account };\n}\n\n/** Every ad row of the account between `since` and `until`, one request per window of `chunkDays`. */\nasync function insights(\n options: MetaAdPerformanceOptions,\n accountId: string,\n since: string,\n until: string,\n { chunkDays, hourly }: { chunkDays: number; hourly: boolean }\n): Promise<Insight[]> {\n const rows: Insight[] = [];\n for (let from = since; from <= until; from = addDays(from, chunkDays)) {\n const last = addDays(from, chunkDays - 1);\n const to = last < until ? last : until;\n let after: string | undefined;\n do {\n const params: Record<string, string> = {\n level: 'ad',\n fields: FIELDS.join(','),\n time_range: JSON.stringify({ since: from, until: to }),\n // One row per day. Without it the hourly breakdown folds the whole range into 24\n // hour-of-day buckets, all dated `since`, and a daily request returns the range's total.\n time_increment: '1',\n action_attribution_windows: JSON.stringify([CLICK_WINDOW, VIEW_WINDOW]),\n use_unified_attribution_setting: 'true',\n limit: '500',\n };\n if (hourly) params.breakdowns = HOURLY;\n if (after) params.after = after;\n const page = await graphGet(\n `act_${accountId}/insights`,\n params,\n options,\n parseWith(pageSchema, 'insights')\n );\n rows.push(...page.data);\n // `next` is absent on the last page; the cursor alone is not a signal, Meta returns it there too.\n after = page.paging?.next ? page.paging.cursors?.after : undefined;\n } while (after);\n }\n return rows;\n}\n\n/**\n * Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and\n * archived ads included — one row per ad and hour (hours without delivery are absent). The hours\n * are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each\n * ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the\n * 7-day click window.\n *\n * Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the\n * click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days\n * daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,\n * `ad_id`, `hour_start`).\n *\n * @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly\n * breakdown and would answer with no rows rather than an error.\n */\nexport async function fetchMetaAdPerformance(\n options: MetaAdPerformanceOptions\n): Promise<AdPerformanceRow[]> {\n const actionType = options.conversionActionType ?? 'purchase';\n const account = await accountOf(options);\n\n // Normalized, so `2025-9-8` compares as `2025-09-08`.\n const since = addDays(options.since, 0);\n const until = addDays(options.until, 0);\n const earliest = addMonths(todayIn(account.timezone_name), -HOURLY_RETENTION_MONTHS);\n if (since < earliest) {\n throw new RangeError(\n `Meta keeps hourly insights for ${HOURLY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`\n );\n }\n\n const rows = await insights(options, account.id, since, until, {\n chunkDays: CHUNK_DAYS,\n hourly: true,\n });\n return rows.map((insight) =>\n toRow(\n insight,\n zonedHourToUtc(insight.date_start, hourOf(insight[HOURLY]), account.timezone_name),\n account.id,\n account.currency,\n actionType\n )\n );\n}\n\n/**\n * The delivery older than Meta's 13 months of hourly data, as hourly rows: Meta's daily ad rows,\n * each spread evenly over the hours of the account's day (`spread`), so that one table stays\n * hourly from end to end. Sums over whole days are Meta's exactly; the hours within a day are an\n * even split, not when the ads actually ran.\n *\n * Only for days the hourly data no longer covers, and only once: a day stored this way must\n * never also get hourly rows (an hour without delivery is not reported hourly, so its spread\n * share would stay and count twice). Fetch up to the day before the first hourly day stored.\n * These days are final; they need no re-fetch.\n *\n * @throws RangeError when `until` is within the 13 months of hourly data (use\n * `fetchMetaAdPerformance`), or `since` is past the 37 months Meta keeps at all.\n */\nexport async function fetchMetaAdPerformanceHistory(\n options: MetaAdPerformanceOptions\n): Promise<AdPerformanceRow[]> {\n const actionType = options.conversionActionType ?? 'purchase';\n const account = await accountOf(options);\n\n const since = addDays(options.since, 0);\n const until = addDays(options.until, 0);\n const today = todayIn(account.timezone_name);\n const hourlyFrom = addMonths(today, -HOURLY_RETENTION_MONTHS);\n const earliest = addMonths(today, -DAILY_RETENTION_MONTHS);\n if (until >= hourlyFrom) {\n throw new RangeError(\n `Meta has hourly insights from ${hourlyFrom}: until ${until} must be before it, fetch those days with fetchMetaAdPerformance`\n );\n }\n if (since < earliest) {\n throw new RangeError(\n `Meta keeps insights for ${DAILY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`\n );\n }\n\n const days = await insights(options, account.id, since, until, {\n chunkDays: DAILY_CHUNK_DAYS,\n hourly: false,\n });\n return days.flatMap((insight) => {\n const day = toRow(\n insight,\n zonedHourToUtc(insight.date_start, 0, account.timezone_name),\n account.id,\n account.currency,\n actionType\n );\n return spreadDay(day, insight.date_start, account.timezone_name);\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AA6BA,MAAM,QAAQ;;AAGd,MAAM,UAAU;AAChB,MAAM,SAAS;;;;;AAMf,MAAM,eAAe;AACrB,MAAM,cAAc;AAEpB,MAAM,SAAS;;;;;;AAOf,MAAM,0BAA0B;;AAGhC,MAAM,yBAAyB;;;;;;AAO/B,MAAM,aAAa;;AAGnB,MAAM,mBAAmB;;AAGzB,MAAM,eAAe;AAErB,MAAM,SAAS;CACb;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;AAwBA,IAAa,kBAAb,cAAqC,MAAM;CACzC,YACE,SACA,QACA,MACA,SACA,WACA;EACA,MAAM,OAAO;EALJ,KAAA,SAAA;EACA,KAAA,OAAA;EACA,KAAA,UAAA;EACA,KAAA,YAAA;EAGT,KAAK,OAAO;CACd;AACF;AAEA,MAAM,eAAe,OAAO;CAC1B,aAAa,OAAO;;;;;CAKpB,OAAO,SAAS,OAAO,CAAC;EACvB,eAAe,SAAS,OAAO,CAAC;EAChC,cAAc,SAAS,OAAO,CAAC;AAClC,CAAC;AAED,MAAM,gBAAgB,OAAO;CAC3B,OAAO,OAAO;CACd,SAAS,SAAS,OAAO,CAAC;CAC1B,UAAU,SAAS,OAAO,CAAC;CAC3B,YAAY,SAAS,OAAO,CAAC;CAC7B,aAAa,OAAO;CACpB,eAAe,SAAS,OAAO,CAAC;CAChC,OAAO,SAAS,OAAO,CAAC;CACxB,aAAa,SAAS,OAAO,CAAC;CAC9B,oBAAoB,SAAS,OAAO,CAAC;CACrC,SAAS,SAAS,MAAM,YAAY,CAAC;CACrC,eAAe,SAAS,MAAM,YAAY,CAAC;CAC3C,YAAY,OAAO;EAClB,SAAS,SAAS,OAAO,CAAC;AAC7B,CAAC;AAED,MAAM,aAAa,OAAO;CACxB,MAAM,MAAM,aAAa;CACzB,QAAQ,SACN,OAAO;EAAE,SAAS,SAAS,OAAO,EAAE,OAAO,SAAS,OAAO,CAAC,EAAE,CAAC,CAAC;EAAG,MAAM,SAAS,OAAO,CAAC;CAAE,CAAC,CAC/F;AACF,CAAC;AAED,MAAM,gBAAgB,OAAO;CAAE,UAAU,OAAO;CAAG,eAAe,OAAO;AAAE,CAAC;AAE5E,MAAM,cAAc,OAAO,EACzB,OAAO,OAAO;CACZ,SAAS,OAAO;CAChB,MAAM,SAAS,OAAO,CAAC;CACvB,eAAe,SAAS,OAAO,CAAC;CAChC,YAAY,SAAS,OAAO,CAAC;AAC/B,CAAC,EACH,CAAC;AAKD,eAAe,SACb,MACA,QACA,SACA,OACY;CACZ,MAAM,QAAQ,IAAI,gBAAgB,MAAM;CACxC,IAAI,QAAQ,WACV,MAAM,IACJ,mBACA,WAAW,UAAU,QAAQ,SAAS,CAAC,CAAC,OAAO,QAAQ,WAAW,CAAC,CAAC,OAAO,KAAK,CAClF;CAEF,MAAM,WAAW,MAAM,MAAM,GAAG,MAAM,GAAG,KAAK,GAAG,SAAS,EACxD,SAAS,EAAE,eAAe,UAAU,QAAQ,cAAc,EAC5D,CAAC;CACD,MAAM,OAAgB,MAAM,SAAS,KAAK,CAAC,CAAC,YAAY,IAAI;CAC5D,IAAI,CAAC,SAAS,IAAI;EAChB,MAAM,SAAS,UAAU,aAAa,IAAI;EAC1C,MAAM,QAAQ,OAAO,UAAU,OAAO,KAAK,QAAQ,KAAA;EACnD,MAAM,IAAI,gBACR,OAAO,WAAW,4BAA4B,SAAS,UACvD,SAAS,QACT,OAAO,MACP,OAAO,eACP,OAAO,UACT;CACF;CACA,OAAO,MAAM,IAAI;AACnB;AAEA,SAAS,UAAa,QAAwB,MAAc;CAC1D,QAAQ,SAAqB;EAC3B,MAAM,SAAS,UAAU,QAAQ,IAAI;EACrC,IAAI,CAAC,OAAO,SAAS;GACnB,MAAM,SAAS,OAAO,MAAM;GAC5B,MAAM,QAAQ,OACX,MAAM,GAAG,CAAC,CAAC,CACX,KAAK,UAAU,GAAG,MAAM,KAAK,KAAK,GAAG,EAAE,IAAI,MAAM,SAAS,CAAC,CAC3D,KAAK,IAAI;GACZ,MAAM,IAAI,MACR,mBAAmB,KAAK,aAAa,OAAO,OAAO,YAAY,QAAQ,OAAO,SAAS,IAAI,QAAQ,IACrG;EACF;EACA,OAAO,OAAO;CAChB;AACF;AAEA,SAAS,MAAM,OAAmC;CAChD,OAAO,UAAU,KAAA,IAAY,IAAI,OAAO,KAAK;AAC/C;;AAGA,SAAS,WAAW,SAA+B,YAAoB;CACrE,MAAM,SAAS,SAAS,MAAM,MAAM,EAAE,gBAAgB,UAAU;CAChE,IAAI,CAAC,QAAQ,OAAO;EAAE,OAAO;EAAM,OAAO;CAAK;CAC/C,OAAO;EAAE,OAAO,OAAO,OAAO,SAAS,CAAC;EAAG,OAAO,OAAO,OAAO,iBAAiB,CAAC;CAAE;AACtF;;AAGA,SAAS,OAAO,QAAoC;CAClD,MAAM,OAAO,OAAO,QAAQ,MAAM,GAAG,CAAC,CAAC;CACvC,IAAI,CAAC,OAAO,UAAU,IAAI,KAAK,OAAO,KAAK,OAAO,IAChD,MAAM,IAAI,MAAM,kCAAkC,QAAQ;CAE5D,OAAO;AACT;;AAGA,SAAS,MACP,SACA,WACA,WACA,UACA,YACkB;CAClB,MAAM,cAAc,WAAW,QAAQ,SAAS,UAAU;CAC1D,MAAM,SAAS,WAAW,QAAQ,eAAe,UAAU;CAC3D,OAAO;EACL,UAAU;EACV,YAAY;EACZ,aAAa,QAAQ;EACrB,eAAe,QAAQ,iBAAiB;EACxC,aAAa,QAAQ,YAAY;EACjC,eAAe,QAAQ,cAAc;EACrC,OAAO,QAAQ;EACf,SAAS,QAAQ,WAAW;EAC5B,SAAS;EACT,QAAQ;EACR,eAAe,eAAe,SAAS,MAAM;EAC7C,YAAY,UAAU,YAAY;EAClC;EACA,OAAO,OAAO,QAAQ,SAAS,CAAC;EAChC,aAAa,MAAM,QAAQ,WAAW;EACtC,QAAQ,MAAM,QAAQ,kBAAkB;EACxC,aAAa,YAAY;EACzB,kBAAkB,OAAO;EACzB,mBAAmB,YAAY;EAC/B,wBAAwB,OAAO;CACjC;AACF;;;;;AAMA,SAAgB,OAAO,OAAe,OAAyB;CAC7D,MAAM,QAAQ,KAAK,MAAO,QAAQ,QAAS,YAAY,IAAI;CAC3D,MAAM,OAAO,KAAK,OAAO,QAAQ,SAAS,QAAQ,MAAM,YAAY,IAAI;CACxE,OAAO,CAAC,GAAG,MAAM,KAAK,EAAE,QAAQ,QAAQ,EAAE,SAAS,KAAK,GAAG,IAAI;AACjE;;;;;AAMA,SAAS,UAAU,KAAuB,MAAc,UAAsC;CAC5F,MAAM,QAAQ,eAAe,MAAM,GAAG,QAAQ,CAAC,CAAC,QAAQ;CACxD,MAAM,MAAM,eAAe,QAAQ,MAAM,CAAC,GAAG,GAAG,QAAQ,CAAC,CAAC,QAAQ;CAClE,MAAM,QAAQ,KAAK,OAAO,MAAM,SAAS,IAAS;CAClD,MAAM,UAAU,UAA0B,UAAU,OAAO,OAAO,OAAO,OAAO,KAAK;CACrF,MAAM,QAAQ,OAAO,IAAI,OAAO,KAAK;CACrC,MAAM,cAAc,OAAO,IAAI,aAAa,KAAK;CACjD,MAAM,SAAS,OAAO,IAAI,QAAQ,KAAK;CACvC,MAAM,cAAc,OAAO,IAAI,WAAW;CAC1C,MAAM,kBAAkB,OAAO,IAAI,gBAAgB;CACnD,MAAM,mBAAmB,OAAO,IAAI,iBAAiB;CACrD,MAAM,uBAAuB,OAAO,IAAI,sBAAsB;CAC9D,OAAO,MAAM,KAAK,EAAE,QAAQ,MAAM,IAAI,GAAG,UAAU;EACjD,GAAG;EACH,YAAY,IAAI,KAAK,QAAQ,OAAO,IAAS,CAAC,CAAC,YAAY;EAC3D,OAAO,MAAM;EACb,aAAa,YAAY;EACzB,QAAQ,OAAO;EACf,aAAa,cAAc,SAAS;EACpC,kBAAkB,kBAAkB,SAAS;EAC7C,mBAAmB,mBAAmB,SAAS;EAC/C,wBAAwB,uBAAuB,SAAS;CAC1D,EAAE;AACJ;AAQA,eAAe,UAAU,SAAqD;CAC5E,MAAM,KAAK,QAAQ,UAAU,QAAQ,SAAS,EAAE;CAOhD,OAAO;EAAE;EAAI,GAAG,MANM,SACpB,OAAO,MACP,EAAE,QAAQ,yBAAyB,GACnC,SACA,UAAU,eAAe,YAAY,CACvC;CACwB;AAC1B;;AAGA,eAAe,SACb,SACA,WACA,OACA,OACA,EAAE,WAAW,UACO;CACpB,MAAM,OAAkB,CAAC;CACzB,KAAK,IAAI,OAAO,OAAO,QAAQ,OAAO,OAAO,QAAQ,MAAM,SAAS,GAAG;EACrE,MAAM,OAAO,QAAQ,MAAM,YAAY,CAAC;EACxC,MAAM,KAAK,OAAO,QAAQ,OAAO;EACjC,IAAI;EACJ,GAAG;GACD,MAAM,SAAiC;IACrC,OAAO;IACP,QAAQ,OAAO,KAAK,GAAG;IACvB,YAAY,KAAK,UAAU;KAAE,OAAO;KAAM,OAAO;IAAG,CAAC;IAGrD,gBAAgB;IAChB,4BAA4B,KAAK,UAAU,CAAC,cAAc,WAAW,CAAC;IACtE,iCAAiC;IACjC,OAAO;GACT;GACA,IAAI,QAAQ,OAAO,aAAa;GAChC,IAAI,OAAO,OAAO,QAAQ;GAC1B,MAAM,OAAO,MAAM,SACjB,OAAO,UAAU,YACjB,QACA,SACA,UAAU,YAAY,UAAU,CAClC;GACA,KAAK,KAAK,GAAG,KAAK,IAAI;GAEtB,QAAQ,KAAK,QAAQ,OAAO,KAAK,OAAO,SAAS,QAAQ,KAAA;EAC3D,SAAS;CACX;CACA,OAAO;AACT;;;;;;;;;;;;;;;;AAiBA,eAAsB,uBACpB,SAC6B;CAC7B,MAAM,aAAa,QAAQ,wBAAwB;CACnD,MAAM,UAAU,MAAM,UAAU,OAAO;CAGvC,MAAM,QAAQ,QAAQ,QAAQ,OAAO,CAAC;CACtC,MAAM,QAAQ,QAAQ,QAAQ,OAAO,CAAC;CACtC,MAAM,WAAW,UAAU,QAAQ,QAAQ,aAAa,GAAG,GAAwB;CACnF,IAAI,QAAQ,UACV,MAAM,IAAI,WACR,kCAAkC,wBAAwB,iBAAiB,MAAM,aAAa,UAChG;CAOF,QAAO,MAJY,SAAS,SAAS,QAAQ,IAAI,OAAO,OAAO;EAC7D,WAAW;EACX,QAAQ;CACV,CAAC,EAAA,CACW,KAAK,YACf,MACE,SACA,eAAe,QAAQ,YAAY,OAAO,QAAQ,OAAO,GAAG,QAAQ,aAAa,GACjF,QAAQ,IACR,QAAQ,UACR,UACF,CACF;AACF;;;;;;;;;;;;;;;AAgBA,eAAsB,8BACpB,SAC6B;CAC7B,MAAM,aAAa,QAAQ,wBAAwB;CACnD,MAAM,UAAU,MAAM,UAAU,OAAO;CAEvC,MAAM,QAAQ,QAAQ,QAAQ,OAAO,CAAC;CACtC,MAAM,QAAQ,QAAQ,QAAQ,OAAO,CAAC;CACtC,MAAM,QAAQ,QAAQ,QAAQ,aAAa;CAC3C,MAAM,aAAa,UAAU,OAAO,GAAwB;CAC5D,MAAM,WAAW,UAAU,OAAO,GAAuB;CACzD,IAAI,SAAS,YACX,MAAM,IAAI,WACR,iCAAiC,WAAW,UAAU,MAAM,iEAC9D;CAEF,IAAI,QAAQ,UACV,MAAM,IAAI,WACR,2BAA2B,uBAAuB,iBAAiB,MAAM,aAAa,UACxF;CAOF,QAAO,MAJY,SAAS,SAAS,QAAQ,IAAI,OAAO,OAAO;EAC7D,WAAW;EACX,QAAQ;CACV,CAAC,EAAA,CACW,SAAS,YAAY;EAQ/B,OAAO,UAPK,MACV,SACA,eAAe,QAAQ,YAAY,GAAG,QAAQ,aAAa,GAC3D,QAAQ,IACR,QAAQ,UACR,UAEiB,GAAG,QAAQ,YAAY,QAAQ,aAAa;CACjE,CAAC;AACH"}
@@ -1,7 +1,7 @@
1
1
  import { ChannelGroup } from "../attribution/vocabulary.cjs";
2
2
  //#region src/ads/types.d.ts
3
3
  /** The ad platforms whose reporting APIs this module reads. */
4
- export type AdPlatform = 'meta';
4
+ export type AdPlatform = 'meta' | 'google';
5
5
  /**
6
6
  * One row of ad delivery: one ad over one hour. The row a host stores in its `ad_performance`
7
7
  * table, unique on (`platform`, `account_id`, `ad_id`, `hour_start`).
@@ -18,13 +18,17 @@ export type AdPlatform = 'meta';
18
18
  export interface AdPerformanceRow {
19
19
  /** The reporting API the row came from; `channel` is what it joins attribution on. */
20
20
  platform: AdPlatform;
21
- /** The platform's account id, without a prefix (Meta's `act_` is stripped). */
21
+ /** The platform's account id, without a prefix or dashes (Meta's `act_`, Google's `123-`). */
22
22
  account_id: string;
23
23
  campaign_id: string;
24
24
  campaign_name: string | null;
25
- /** The level between campaign and ad: Meta's ad set. */
25
+ /** The level between campaign and ad: Meta's ad set, Google's ad group. */
26
26
  ad_group_id: string | null;
27
27
  ad_group_name: string | null;
28
+ /**
29
+ * The finest level the platform reports hours at: the ad on Meta; on Google the ad group, or the
30
+ * campaign for Performance Max, which has none.
31
+ */
28
32
  ad_id: string;
29
33
  ad_name: string | null;
30
34
  channel: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.cts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
1
+ {"version":3,"file":"types.d.cts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;;;;;EAKA;EACA;EACA;EACA;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
@@ -1,7 +1,7 @@
1
1
  import { ChannelGroup } from "../attribution/vocabulary.mjs";
2
2
  //#region src/ads/types.d.ts
3
3
  /** The ad platforms whose reporting APIs this module reads. */
4
- export type AdPlatform = 'meta';
4
+ export type AdPlatform = 'meta' | 'google';
5
5
  /**
6
6
  * One row of ad delivery: one ad over one hour. The row a host stores in its `ad_performance`
7
7
  * table, unique on (`platform`, `account_id`, `ad_id`, `hour_start`).
@@ -18,13 +18,17 @@ export type AdPlatform = 'meta';
18
18
  export interface AdPerformanceRow {
19
19
  /** The reporting API the row came from; `channel` is what it joins attribution on. */
20
20
  platform: AdPlatform;
21
- /** The platform's account id, without a prefix (Meta's `act_` is stripped). */
21
+ /** The platform's account id, without a prefix or dashes (Meta's `act_`, Google's `123-`). */
22
22
  account_id: string;
23
23
  campaign_id: string;
24
24
  campaign_name: string | null;
25
- /** The level between campaign and ad: Meta's ad set. */
25
+ /** The level between campaign and ad: Meta's ad set, Google's ad group. */
26
26
  ad_group_id: string | null;
27
27
  ad_group_name: string | null;
28
+ /**
29
+ * The finest level the platform reports hours at: the ad on Meta; on Google the ad group, or the
30
+ * campaign for Performance Max, which has none.
31
+ */
28
32
  ad_id: string;
29
33
  ad_name: string | null;
30
34
  channel: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.mts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
1
+ {"version":3,"file":"types.d.mts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;;;;;EAKA;EACA;EACA;EACA;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shware/analytics",
3
- "version": "11.8.0",
3
+ "version": "11.10.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",