@shware/analytics 11.7.0 → 11.9.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.
package/README.md CHANGED
@@ -341,6 +341,51 @@ What the SDK guarantees for this to hold:
341
341
  `setVisitor` hands the server's value to the third-party user setters, and PostHog is identified
342
342
  by it once a user is known.
343
343
 
344
+ ## Ad performance (`@shware/analytics/ads`)
345
+
346
+ Spend, impressions, clicks and the platforms' own conversions, read from the ad platforms'
347
+ reporting APIs as typed `AdPerformanceRow`s — one per ad and hour — for a host to store next to its
348
+ sessions and compute ROAS, cost per session and the click → session landing rate.
349
+
350
+ ```ts
351
+ import { fetchMetaAdPerformance } from '@shware/analytics/ads';
352
+
353
+ const rows = await fetchMetaAdPerformance({
354
+ accessToken: env.META_ADS_ACCESS_TOKEN, // a system user token with ads_read
355
+ accountId: '1234567890', // with or without act_
356
+ since: '2026-10-01', // days in the ad account's time zone
357
+ until: '2026-10-07',
358
+ });
359
+ ```
360
+
361
+ - **Hourly rows only.** `hour_start` is a UTC instant, so a sum over any span in any time zone is
362
+ a sum of whole rows and lines up with the sessions grouped the same way, whatever time zone each
363
+ ad account reports in. A table never mixes a day row with the hours inside it: an upsert would
364
+ overwrite one with the other on their shared start, and the rest would count twice. A platform
365
+ that reports only days spreads each day evenly over its hours.
366
+ - **The attribution vocabulary.** `channel`, `medium` and `channel_group` are the values
367
+ `classifyTouch` gives the platform's ad clicks (`meta / cpc / paid_social`), so rows join
368
+ sessions and attribution with no mapping. Join on `channel` and `channel_group` together: the
369
+ same channel also brings organic traffic (Google search, ChatGPT answers).
370
+ - **Clicks are link clicks** to the site (Meta's `inline_link_clicks`), the number to compare
371
+ with sessions; Meta's `clicks` also counts likes and expands.
372
+ - **Two conversion counts.** `conversions` / `conversion_value` are the platform's under each ad's
373
+ attribution setting, view-through included — what its own dashboard shows.
374
+ `click_conversions` / `click_conversion_value` are the click-through part (Meta's 7-day click),
375
+ the one to set against a click-based attribution.
376
+ - **Re-fetch and upsert.** Platforms restate the past: a purchase is credited to the hour of the
377
+ click up to 7 days later, and spend is corrected for invalid traffic. Re-fetch today and
378
+ yesterday hourly, the last 8 days daily (the 7-day window plus a day of time zone slack) and the
379
+ last 30 weekly, upserting on (`platform`, `account_id`, `ad_id`, `hour_start`).
380
+ - **Retention.** Meta keeps the hourly breakdown for 13 months; `since` earlier than that throws
381
+ rather than returning the empty answer Meta gives. Long ranges are fetched in 7-day windows, as a
382
+ single request over months times out on Meta's side.
383
+ - **Older history.** `fetchMetaAdPerformanceHistory` reads the days before the hourly 13 months
384
+ (Meta keeps 37) and spreads each day evenly over its hours, so the table stays hourly: whole-day
385
+ sums are Meta's exactly, the hours inside such a day are an even split. Run it once, up to the
386
+ day before the first hourly day stored — a day must never hold both spread and real hourly rows,
387
+ as an hour without delivery is absent from the hourly data and its spread share would remain.
388
+
344
389
  ## UTM params
345
390
 
346
391
  Typed as `UTMParams` (exported from `@shware/analytics`). Value unions follow the
@@ -0,0 +1,11 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_ads_time = require("./time.cjs");
3
+ const require_ads_meta = require("./meta.cjs");
4
+ exports.MetaAdsApiError = require_ads_meta.MetaAdsApiError;
5
+ exports.addDays = require_ads_time.addDays;
6
+ exports.addMonths = require_ads_time.addMonths;
7
+ exports.fetchMetaAdPerformance = require_ads_meta.fetchMetaAdPerformance;
8
+ exports.fetchMetaAdPerformanceHistory = require_ads_meta.fetchMetaAdPerformanceHistory;
9
+ exports.spread = require_ads_meta.spread;
10
+ exports.todayIn = require_ads_time.todayIn;
11
+ exports.zonedHourToUtc = require_ads_time.zonedHourToUtc;
@@ -0,0 +1,4 @@
1
+ import { AdPerformanceRow, AdPlatform, ReportDate } from "./types.cjs";
2
+ import { MetaAdPerformanceOptions, MetaAdsApiError, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread } from "./meta.cjs";
3
+ import { addDays, addMonths, todayIn, zonedHourToUtc } from "./time.cjs";
4
+ export { type AdPerformanceRow, type AdPlatform, MetaAdPerformanceOptions, MetaAdsApiError, type ReportDate, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread, todayIn, zonedHourToUtc };
@@ -0,0 +1,4 @@
1
+ import { AdPerformanceRow, AdPlatform, ReportDate } from "./types.mjs";
2
+ import { MetaAdPerformanceOptions, MetaAdsApiError, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread } from "./meta.mjs";
3
+ import { addDays, addMonths, todayIn, zonedHourToUtc } from "./time.mjs";
4
+ export { type AdPerformanceRow, type AdPlatform, MetaAdPerformanceOptions, MetaAdsApiError, type ReportDate, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread, todayIn, zonedHourToUtc };
@@ -0,0 +1,3 @@
1
+ import { addDays, addMonths, todayIn, zonedHourToUtc } from "./time.mjs";
2
+ import { MetaAdsApiError, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread } from "./meta.mjs";
3
+ export { MetaAdsApiError, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread, todayIn, zonedHourToUtc };
@@ -0,0 +1,323 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_attribution_classify = require("../attribution/classify.cjs");
3
+ require("../attribution/index.cjs");
4
+ const require_ads_time = require("./time.cjs");
5
+ let _shware_utils = require("@shware/utils");
6
+ let zod_mini = require("zod/mini");
7
+ let node_crypto = require("node:crypto");
8
+ //#region src/ads/meta.ts
9
+ /**
10
+ * Meta ad delivery through the Marketing API's Insights endpoint, over plain `fetch`: one row per
11
+ * ad and hour, in the attribution vocabulary (see `AdPerformanceRow`).
12
+ *
13
+ * The token needs `ads_read` on the ad account — a system user token with the account assigned
14
+ * at "View performance". It is sent in the `Authorization` header and never put in a URL: paging
15
+ * follows the `after` cursor rather than Meta's `paging.next`, which embeds the token.
16
+ *
17
+ * https://developers.facebook.com/docs/marketing-api/insights
18
+ * https://developers.facebook.com/docs/marketing-api/insights/breakdowns
19
+ */
20
+ const GRAPH = `https://graph.facebook.com/v24.0`;
21
+ /** What an ad click is in the attribution vocabulary: `meta / cpc`, a paid social touch. */
22
+ const CHANNEL = "meta";
23
+ const MEDIUM = "cpc";
24
+ /**
25
+ * The click window of the click-through columns. Meta's longest since 2021, and the click part of
26
+ * its default setting (7-day click, 1-day view).
27
+ */
28
+ const CLICK_WINDOW = "7d_click";
29
+ const VIEW_WINDOW = "1d_view";
30
+ const HOURLY = "hourly_stats_aggregated_by_advertiser_time_zone";
31
+ /**
32
+ * How far back Meta answers the hourly breakdown, in months. Past it Meta does not fail — it
33
+ * returns no rows — so a backfill would silently store a gap.
34
+ * https://developers.facebook.com/docs/marketing-api/insights/best-practices
35
+ */
36
+ const HOURLY_RETENTION_MONTHS = 13;
37
+ /** How far back Meta answers Insights without the hourly breakdown, in months. */
38
+ const DAILY_RETENTION_MONTHS = 37;
39
+ /**
40
+ * Days per Insights request. A synchronous request over a long range of ad rows times out on
41
+ * Meta's side (`code 1, subcode 99`, "An unknown error occurred", as a 500): 17 months of daily
42
+ * ad rows did. A range is split into windows this long, each paged on its own.
43
+ */
44
+ const CHUNK_DAYS = 7;
45
+ /** Days per request for daily rows: 24 times fewer rows than hourly ones per day. */
46
+ const DAILY_CHUNK_DAYS = 30;
47
+ /** Decimal places of a day's numbers spread over its hours. */
48
+ const SPREAD_SCALE = 1e6;
49
+ const FIELDS = [
50
+ "ad_id",
51
+ "ad_name",
52
+ "adset_id",
53
+ "adset_name",
54
+ "campaign_id",
55
+ "campaign_name",
56
+ "spend",
57
+ "impressions",
58
+ "inline_link_clicks",
59
+ "actions",
60
+ "action_values"
61
+ ];
62
+ /** A Graph API error, with Meta's code, for a caller deciding whether to retry. */
63
+ var MetaAdsApiError = class extends Error {
64
+ constructor(message, status, code, subcode, fbtraceId) {
65
+ super(message);
66
+ this.status = status;
67
+ this.code = code;
68
+ this.subcode = subcode;
69
+ this.fbtraceId = fbtraceId;
70
+ this.name = "MetaAdsApiError";
71
+ }
72
+ };
73
+ const actionSchema = (0, zod_mini.object)({
74
+ action_type: (0, zod_mini.string)(),
75
+ /**
76
+ * The count under the ad's own attribution setting. Absent when the setting does not count any
77
+ * of it — a view-only conversion of an ad set to 1-day click — which only shows in the windows.
78
+ */
79
+ value: (0, zod_mini.optional)((0, zod_mini.string)()),
80
+ [CLICK_WINDOW]: (0, zod_mini.optional)((0, zod_mini.string)()),
81
+ [VIEW_WINDOW]: (0, zod_mini.optional)((0, zod_mini.string)())
82
+ });
83
+ const insightSchema = (0, zod_mini.object)({
84
+ ad_id: (0, zod_mini.string)(),
85
+ ad_name: (0, zod_mini.optional)((0, zod_mini.string)()),
86
+ adset_id: (0, zod_mini.optional)((0, zod_mini.string)()),
87
+ adset_name: (0, zod_mini.optional)((0, zod_mini.string)()),
88
+ campaign_id: (0, zod_mini.string)(),
89
+ campaign_name: (0, zod_mini.optional)((0, zod_mini.string)()),
90
+ spend: (0, zod_mini.optional)((0, zod_mini.string)()),
91
+ impressions: (0, zod_mini.optional)((0, zod_mini.string)()),
92
+ inline_link_clicks: (0, zod_mini.optional)((0, zod_mini.string)()),
93
+ actions: (0, zod_mini.optional)((0, zod_mini.array)(actionSchema)),
94
+ action_values: (0, zod_mini.optional)((0, zod_mini.array)(actionSchema)),
95
+ date_start: (0, zod_mini.string)(),
96
+ [HOURLY]: (0, zod_mini.optional)((0, zod_mini.string)())
97
+ });
98
+ const pageSchema = (0, zod_mini.object)({
99
+ data: (0, zod_mini.array)(insightSchema),
100
+ paging: (0, zod_mini.optional)((0, zod_mini.object)({
101
+ cursors: (0, zod_mini.optional)((0, zod_mini.object)({ after: (0, zod_mini.optional)((0, zod_mini.string)()) })),
102
+ next: (0, zod_mini.optional)((0, zod_mini.string)())
103
+ }))
104
+ });
105
+ const accountSchema = (0, zod_mini.object)({
106
+ currency: (0, zod_mini.string)(),
107
+ timezone_name: (0, zod_mini.string)()
108
+ });
109
+ const errorSchema = (0, zod_mini.object)({ error: (0, zod_mini.object)({
110
+ message: (0, zod_mini.string)(),
111
+ code: (0, zod_mini.optional)((0, zod_mini.number)()),
112
+ error_subcode: (0, zod_mini.optional)((0, zod_mini.number)()),
113
+ fbtrace_id: (0, zod_mini.optional)((0, zod_mini.string)())
114
+ }) });
115
+ async function graphGet(path, params, options, parse) {
116
+ const query = new URLSearchParams(params);
117
+ if (options.appSecret) query.set("appsecret_proof", (0, node_crypto.createHmac)("sha256", options.appSecret).update(options.accessToken).digest("hex"));
118
+ const response = await (0, _shware_utils.fetch)(`${GRAPH}/${path}?${query}`, { headers: { authorization: `Bearer ${options.accessToken}` } });
119
+ const body = await response.json().catch(() => null);
120
+ if (!response.ok) {
121
+ const parsed = (0, zod_mini.safeParse)(errorSchema, body);
122
+ const error = parsed.success ? parsed.data.error : void 0;
123
+ throw new MetaAdsApiError(error?.message ?? `Meta Graph API responded ${response.status}`, response.status, error?.code, error?.error_subcode, error?.fbtrace_id);
124
+ }
125
+ return parse(body);
126
+ }
127
+ function parseWith(schema, what) {
128
+ return (body) => {
129
+ const parsed = (0, zod_mini.safeParse)(schema, body);
130
+ if (!parsed.success) {
131
+ const issues = parsed.error.issues;
132
+ const first = issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
133
+ throw new Error(`Unexpected Meta ${what} response (${issues.length} issues): ${first}${issues.length > 3 ? "; …" : ""}`);
134
+ }
135
+ return parsed.data;
136
+ };
137
+ }
138
+ function count(value) {
139
+ return value === void 0 ? 0 : Number(value);
140
+ }
141
+ /** The action's total and its click-through part; nulls when Meta reported no such action. */
142
+ function conversion(actions, actionType) {
143
+ const action = actions?.find((a) => a.action_type === actionType);
144
+ if (!action) return {
145
+ total: null,
146
+ click: null
147
+ };
148
+ return {
149
+ total: Number(action.value ?? 0),
150
+ click: Number(action[CLICK_WINDOW] ?? 0)
151
+ };
152
+ }
153
+ /** `"13:00:00 - 13:59:59"` → 13. */
154
+ function hourOf(bucket) {
155
+ const hour = Number(bucket?.slice(0, 2));
156
+ if (!Number.isInteger(hour) || hour < 0 || hour > 23) throw new Error(`Unexpected Meta hourly bucket: ${bucket}`);
157
+ return hour;
158
+ }
159
+ /** A row for one hour: what Meta reports of an ad, its numbers as given. */
160
+ function toRow(insight, hourStart, accountId, currency, actionType) {
161
+ const conversions = conversion(insight.actions, actionType);
162
+ const values = conversion(insight.action_values, actionType);
163
+ return {
164
+ platform: "meta",
165
+ account_id: accountId,
166
+ campaign_id: insight.campaign_id,
167
+ campaign_name: insight.campaign_name ?? null,
168
+ ad_group_id: insight.adset_id ?? null,
169
+ ad_group_name: insight.adset_name ?? null,
170
+ ad_id: insight.ad_id,
171
+ ad_name: insight.ad_name ?? null,
172
+ channel: CHANNEL,
173
+ medium: MEDIUM,
174
+ channel_group: require_attribution_classify.channelGroupOf(CHANNEL, MEDIUM),
175
+ hour_start: hourStart.toISOString(),
176
+ currency,
177
+ spend: Number(insight.spend ?? 0),
178
+ impressions: count(insight.impressions),
179
+ clicks: count(insight.inline_link_clicks),
180
+ conversions: conversions.total,
181
+ conversion_value: values.total,
182
+ click_conversions: conversions.click,
183
+ click_conversion_value: values.click
184
+ };
185
+ }
186
+ /**
187
+ * `value` in `parts` equal shares rounded to `SPREAD_SCALE`, the rounding left in the last share
188
+ * so that the shares add back to `value` exactly as Meta reported it.
189
+ */
190
+ function spread(value, parts) {
191
+ const share = Math.round(value / parts * SPREAD_SCALE) / SPREAD_SCALE;
192
+ const last = Math.round((value - share * (parts - 1)) * SPREAD_SCALE) / SPREAD_SCALE;
193
+ return [...Array.from({ length: parts - 1 }, () => share), last];
194
+ }
195
+ /**
196
+ * A day row as one row per hour of the account's calendar day — 24, or 23 / 25 across a
197
+ * daylight-saving change — each number spread evenly (nulls stay null).
198
+ */
199
+ function spreadDay(day, date, timeZone) {
200
+ const start = require_ads_time.zonedHourToUtc(date, 0, timeZone).getTime();
201
+ const end = require_ads_time.zonedHourToUtc(require_ads_time.addDays(date, 1), 0, timeZone).getTime();
202
+ const hours = Math.round((end - start) / 36e5);
203
+ const shares = (value) => value === null ? null : spread(value, hours);
204
+ const spend = spread(day.spend, hours);
205
+ const impressions = spread(day.impressions, hours);
206
+ const clicks = spread(day.clicks, hours);
207
+ const conversions = shares(day.conversions);
208
+ const conversionValue = shares(day.conversion_value);
209
+ const clickConversions = shares(day.click_conversions);
210
+ const clickConversionValue = shares(day.click_conversion_value);
211
+ return Array.from({ length: hours }, (_, hour) => ({
212
+ ...day,
213
+ hour_start: new Date(start + hour * 36e5).toISOString(),
214
+ spend: spend[hour],
215
+ impressions: impressions[hour],
216
+ clicks: clicks[hour],
217
+ conversions: conversions?.[hour] ?? null,
218
+ conversion_value: conversionValue?.[hour] ?? null,
219
+ click_conversions: clickConversions?.[hour] ?? null,
220
+ click_conversion_value: clickConversionValue?.[hour] ?? null
221
+ }));
222
+ }
223
+ async function accountOf(options) {
224
+ const id = options.accountId.replace(/^act_/, "");
225
+ return {
226
+ id,
227
+ ...await graphGet(`act_${id}`, { fields: "currency,timezone_name" }, options, parseWith(accountSchema, "ad account"))
228
+ };
229
+ }
230
+ /** Every ad row of the account between `since` and `until`, one request per window of `chunkDays`. */
231
+ async function insights(options, accountId, since, until, { chunkDays, hourly }) {
232
+ const rows = [];
233
+ for (let from = since; from <= until; from = require_ads_time.addDays(from, chunkDays)) {
234
+ const last = require_ads_time.addDays(from, chunkDays - 1);
235
+ const to = last < until ? last : until;
236
+ let after;
237
+ do {
238
+ const params = {
239
+ level: "ad",
240
+ fields: FIELDS.join(","),
241
+ time_range: JSON.stringify({
242
+ since: from,
243
+ until: to
244
+ }),
245
+ time_increment: "1",
246
+ action_attribution_windows: JSON.stringify([CLICK_WINDOW, VIEW_WINDOW]),
247
+ use_unified_attribution_setting: "true",
248
+ limit: "500"
249
+ };
250
+ if (hourly) params.breakdowns = HOURLY;
251
+ if (after) params.after = after;
252
+ const page = await graphGet(`act_${accountId}/insights`, params, options, parseWith(pageSchema, "insights"));
253
+ rows.push(...page.data);
254
+ after = page.paging?.next ? page.paging.cursors?.after : void 0;
255
+ } while (after);
256
+ }
257
+ return rows;
258
+ }
259
+ /**
260
+ * Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and
261
+ * archived ads included — one row per ad and hour (hours without delivery are absent). The hours
262
+ * are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each
263
+ * ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the
264
+ * 7-day click window.
265
+ *
266
+ * Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the
267
+ * click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days
268
+ * daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,
269
+ * `ad_id`, `hour_start`).
270
+ *
271
+ * @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly
272
+ * breakdown and would answer with no rows rather than an error.
273
+ */
274
+ async function fetchMetaAdPerformance(options) {
275
+ const actionType = options.conversionActionType ?? "purchase";
276
+ const account = await accountOf(options);
277
+ const since = require_ads_time.addDays(options.since, 0);
278
+ const until = require_ads_time.addDays(options.until, 0);
279
+ const earliest = require_ads_time.addMonths(require_ads_time.todayIn(account.timezone_name), -13);
280
+ if (since < earliest) throw new RangeError(`Meta keeps hourly insights for ${HOURLY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`);
281
+ return (await insights(options, account.id, since, until, {
282
+ chunkDays: CHUNK_DAYS,
283
+ hourly: true
284
+ })).map((insight) => toRow(insight, require_ads_time.zonedHourToUtc(insight.date_start, hourOf(insight[HOURLY]), account.timezone_name), account.id, account.currency, actionType));
285
+ }
286
+ /**
287
+ * The delivery older than Meta's 13 months of hourly data, as hourly rows: Meta's daily ad rows,
288
+ * each spread evenly over the hours of the account's day (`spread`), so that one table stays
289
+ * hourly from end to end. Sums over whole days are Meta's exactly; the hours within a day are an
290
+ * even split, not when the ads actually ran.
291
+ *
292
+ * Only for days the hourly data no longer covers, and only once: a day stored this way must
293
+ * never also get hourly rows (an hour without delivery is not reported hourly, so its spread
294
+ * share would stay and count twice). Fetch up to the day before the first hourly day stored.
295
+ * These days are final; they need no re-fetch.
296
+ *
297
+ * @throws RangeError when `until` is within the 13 months of hourly data (use
298
+ * `fetchMetaAdPerformance`), or `since` is past the 37 months Meta keeps at all.
299
+ */
300
+ async function fetchMetaAdPerformanceHistory(options) {
301
+ const actionType = options.conversionActionType ?? "purchase";
302
+ const account = await accountOf(options);
303
+ const since = require_ads_time.addDays(options.since, 0);
304
+ const until = require_ads_time.addDays(options.until, 0);
305
+ const today = require_ads_time.todayIn(account.timezone_name);
306
+ const hourlyFrom = require_ads_time.addMonths(today, -13);
307
+ const earliest = require_ads_time.addMonths(today, -37);
308
+ if (until >= hourlyFrom) throw new RangeError(`Meta has hourly insights from ${hourlyFrom}: until ${until} must be before it, fetch those days with fetchMetaAdPerformance`);
309
+ if (since < earliest) throw new RangeError(`Meta keeps insights for ${DAILY_RETENTION_MONTHS} months: since ${since} is before ${earliest}`);
310
+ return (await insights(options, account.id, since, until, {
311
+ chunkDays: DAILY_CHUNK_DAYS,
312
+ hourly: false
313
+ })).flatMap((insight) => {
314
+ return spreadDay(toRow(insight, require_ads_time.zonedHourToUtc(insight.date_start, 0, account.timezone_name), account.id, account.currency, actionType), insight.date_start, account.timezone_name);
315
+ });
316
+ }
317
+ //#endregion
318
+ exports.MetaAdsApiError = MetaAdsApiError;
319
+ exports.fetchMetaAdPerformance = fetchMetaAdPerformance;
320
+ exports.fetchMetaAdPerformanceHistory = fetchMetaAdPerformanceHistory;
321
+ exports.spread = spread;
322
+
323
+ //# sourceMappingURL=meta.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"meta.cjs","names":["object","string","optional","array","number","createHmac","fetch","safeParse","channelGroupOf","zonedHourToUtc","addDays","addMonths","todayIn"],"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":"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"}
@@ -0,0 +1,68 @@
1
+ import { AdPerformanceRow, ReportDate } from "./types.cjs";
2
+ //#region src/ads/meta.d.ts
3
+ export interface MetaAdPerformanceOptions {
4
+ /** A token with `ads_read` on the account. */
5
+ accessToken: string;
6
+ /** The ad account id, with or without its `act_` prefix. */
7
+ accountId: string;
8
+ /** First day, in the ad account's time zone. */
9
+ since: ReportDate;
10
+ /** Last day, inclusive, in the ad account's time zone. */
11
+ until: ReportDate;
12
+ /**
13
+ * The `action_type` counted as the conversion, default `purchase` (Meta's total of the pixel,
14
+ * Conversions API and in-app purchases of an event, deduplicated).
15
+ */
16
+ conversionActionType?: string;
17
+ /**
18
+ * The app secret, when the app requires `appsecret_proof` (App settings → Advanced → "Require
19
+ * app secret"). The proof is computed here; the secret itself is never sent.
20
+ */
21
+ appSecret?: string;
22
+ }
23
+ /** A Graph API error, with Meta's code, for a caller deciding whether to retry. */
24
+ export declare class MetaAdsApiError extends Error {
25
+ readonly status: number;
26
+ readonly code: number | undefined;
27
+ readonly subcode: number | undefined;
28
+ readonly fbtraceId: string | undefined;
29
+ constructor(message: string, status: number, code: number | undefined, subcode: number | undefined, fbtraceId: string | undefined);
30
+ }
31
+ /**
32
+ * `value` in `parts` equal shares rounded to `SPREAD_SCALE`, the rounding left in the last share
33
+ * so that the shares add back to `value` exactly as Meta reported it.
34
+ */
35
+ export declare function spread(value: number, parts: number): number[];
36
+ /**
37
+ * Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and
38
+ * archived ads included — one row per ad and hour (hours without delivery are absent). The hours
39
+ * are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each
40
+ * ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the
41
+ * 7-day click window.
42
+ *
43
+ * Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the
44
+ * click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days
45
+ * daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,
46
+ * `ad_id`, `hour_start`).
47
+ *
48
+ * @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly
49
+ * breakdown and would answer with no rows rather than an error.
50
+ */
51
+ export declare function fetchMetaAdPerformance(options: MetaAdPerformanceOptions): Promise<AdPerformanceRow[]>;
52
+ /**
53
+ * The delivery older than Meta's 13 months of hourly data, as hourly rows: Meta's daily ad rows,
54
+ * each spread evenly over the hours of the account's day (`spread`), so that one table stays
55
+ * hourly from end to end. Sums over whole days are Meta's exactly; the hours within a day are an
56
+ * even split, not when the ads actually ran.
57
+ *
58
+ * Only for days the hourly data no longer covers, and only once: a day stored this way must
59
+ * never also get hourly rows (an hour without delivery is not reported hourly, so its spread
60
+ * share would stay and count twice). Fetch up to the day before the first hourly day stored.
61
+ * These days are final; they need no re-fetch.
62
+ *
63
+ * @throws RangeError when `until` is within the 13 months of hourly data (use
64
+ * `fetchMetaAdPerformance`), or `since` is past the 37 months Meta keeps at all.
65
+ */
66
+ export declare function fetchMetaAdPerformanceHistory(options: MetaAdPerformanceOptions): Promise<AdPerformanceRow[]>;
67
+ //#endregion
68
+ //# sourceMappingURL=meta.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"meta.d.cts","names":[],"sources":["../../src/ads/meta.ts"],"mappings":";;iBAiFiB;;EAEf;;EAEA;;EAEA,OAAO;;EAEP,OAAO;;;;;EAKP;;;;;EAKA;;;qBAIW,wBAAwB;WAGxB;WACA;WACA;WACA;EALX,YACE,iBACS,gBACA,0BACA,6BACA;;;;;;wBAiKG,OAAO,eAAe;;;;;;;;;;;;;;;;wBA4GhB,uBACpB,SAAS,2BACR,QAAQ;;;;;;;;;;;;;;;wBA2CW,8BACpB,SAAS,2BACR,QAAQ"}
@@ -0,0 +1,68 @@
1
+ import { AdPerformanceRow, ReportDate } from "./types.mjs";
2
+ //#region src/ads/meta.d.ts
3
+ export interface MetaAdPerformanceOptions {
4
+ /** A token with `ads_read` on the account. */
5
+ accessToken: string;
6
+ /** The ad account id, with or without its `act_` prefix. */
7
+ accountId: string;
8
+ /** First day, in the ad account's time zone. */
9
+ since: ReportDate;
10
+ /** Last day, inclusive, in the ad account's time zone. */
11
+ until: ReportDate;
12
+ /**
13
+ * The `action_type` counted as the conversion, default `purchase` (Meta's total of the pixel,
14
+ * Conversions API and in-app purchases of an event, deduplicated).
15
+ */
16
+ conversionActionType?: string;
17
+ /**
18
+ * The app secret, when the app requires `appsecret_proof` (App settings → Advanced → "Require
19
+ * app secret"). The proof is computed here; the secret itself is never sent.
20
+ */
21
+ appSecret?: string;
22
+ }
23
+ /** A Graph API error, with Meta's code, for a caller deciding whether to retry. */
24
+ export declare class MetaAdsApiError extends Error {
25
+ readonly status: number;
26
+ readonly code: number | undefined;
27
+ readonly subcode: number | undefined;
28
+ readonly fbtraceId: string | undefined;
29
+ constructor(message: string, status: number, code: number | undefined, subcode: number | undefined, fbtraceId: string | undefined);
30
+ }
31
+ /**
32
+ * `value` in `parts` equal shares rounded to `SPREAD_SCALE`, the rounding left in the last share
33
+ * so that the shares add back to `value` exactly as Meta reported it.
34
+ */
35
+ export declare function spread(value: number, parts: number): number[];
36
+ /**
37
+ * Hourly delivery of every ad of the account that ran between `since` and `until` — deleted and
38
+ * archived ads included — one row per ad and hour (hours without delivery are absent). The hours
39
+ * are Meta's, in the account's time zone, turned into UTC instants. Conversions are those of each
40
+ * ad's attribution setting (`use_unified_attribution_setting`), their click-through part from the
41
+ * 7-day click window.
42
+ *
43
+ * Meta restates past hours as late conversions arrive — a purchase is credited to the hour of the
44
+ * click, up to 7 days after it — and corrects spend for invalid traffic: re-fetch the last 8 days
45
+ * daily (the 7-day window plus a day of time zone slack) and upsert on (`platform`, `account_id`,
46
+ * `ad_id`, `hour_start`).
47
+ *
48
+ * @throws RangeError when `since` is more than 13 months back, where Meta keeps no hourly
49
+ * breakdown and would answer with no rows rather than an error.
50
+ */
51
+ export declare function fetchMetaAdPerformance(options: MetaAdPerformanceOptions): Promise<AdPerformanceRow[]>;
52
+ /**
53
+ * The delivery older than Meta's 13 months of hourly data, as hourly rows: Meta's daily ad rows,
54
+ * each spread evenly over the hours of the account's day (`spread`), so that one table stays
55
+ * hourly from end to end. Sums over whole days are Meta's exactly; the hours within a day are an
56
+ * even split, not when the ads actually ran.
57
+ *
58
+ * Only for days the hourly data no longer covers, and only once: a day stored this way must
59
+ * never also get hourly rows (an hour without delivery is not reported hourly, so its spread
60
+ * share would stay and count twice). Fetch up to the day before the first hourly day stored.
61
+ * These days are final; they need no re-fetch.
62
+ *
63
+ * @throws RangeError when `until` is within the 13 months of hourly data (use
64
+ * `fetchMetaAdPerformance`), or `since` is past the 37 months Meta keeps at all.
65
+ */
66
+ export declare function fetchMetaAdPerformanceHistory(options: MetaAdPerformanceOptions): Promise<AdPerformanceRow[]>;
67
+ //#endregion
68
+ //# sourceMappingURL=meta.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"meta.d.mts","names":[],"sources":["../../src/ads/meta.ts"],"mappings":";;iBAiFiB;;EAEf;;EAEA;;EAEA,OAAO;;EAEP,OAAO;;;;;EAKP;;;;;EAKA;;;qBAIW,wBAAwB;WAGxB;WACA;WACA;WACA;EALX,YACE,iBACS,gBACA,0BACA,6BACA;;;;;;wBAiKG,OAAO,eAAe;;;;;;;;;;;;;;;;wBA4GhB,uBACpB,SAAS,2BACR,QAAQ;;;;;;;;;;;;;;;wBA2CW,8BACpB,SAAS,2BACR,QAAQ"}