@viccydev/pi-fpa 0.3.0 → 0.3.2

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.
@@ -4,7 +4,8 @@ import type { Period } from "../fpa-artifacts/contracts.ts";
4
4
  import { toNumber, type NumericLike } from "../fpa-data/calc.ts";
5
5
  import { runStructuredQuery } from "../fpa-data/runtime.ts";
6
6
  import type { SqlRow } from "../fpa-data/supabase.ts";
7
- import type { DashboardActualsSnapshot, DashboardDailyPoint, QueryReceipt } from "./source.ts";
7
+ import type { DashboardActualsSnapshot, DashboardDailyPoint, DashboardScopeMode, QueryReceipt } from "./source.ts";
8
+ import { ACTUALS_DATA_AS_OF_UNAVAILABLE } from "./source.ts";
8
9
 
9
10
  function dateInTimezone(timestamp: string, timezone: string, label: string): string {
10
11
  const instant = new Date(timestamp);
@@ -96,11 +97,16 @@ export interface DashboardActualsScope {
96
97
  slices: Array<{ app_id: string; store: string; channel_group: string }>;
97
98
  }
98
99
 
99
- export function mapDashboardSlices(rows: SqlRow[]) {
100
+ /**
101
+ * Rows carry app_code only in "app" scope mode. In "portfolio" mode the App axis was
102
+ * collapsed onto a placeholder before the query ran, so the caller supplies it back.
103
+ */
104
+ export function mapDashboardSlices(rows: SqlRow[], portfolioAppId?: string) {
100
105
  return rows
101
- .filter((row) => row.app_code != null && row.platform != null && row.media_source != null)
106
+ .filter((row) => row.platform != null && row.media_source != null)
107
+ .filter((row) => portfolioAppId !== undefined || row.app_code != null)
102
108
  .map((row) => ({
103
- app_id: String(row.app_code),
109
+ app_id: portfolioAppId ?? String(row.app_code),
104
110
  store: String(row.platform),
105
111
  channel_group: String(row.media_source),
106
112
  spend: value(row, "spend"),
@@ -109,6 +115,35 @@ export function mapDashboardSlices(rows: SqlRow[]) {
109
115
  }));
110
116
  }
111
117
 
118
+ /**
119
+ * The App axis carries no information when every approved slice shares one app_id.
120
+ * That is the only case where dropping the app_code predicate can be considered.
121
+ */
122
+ export function collapsedAppId(slices: DashboardActualsScope["slices"]): string | null {
123
+ const appIds = new Set(slices.map((slice) => slice.app_id));
124
+ return appIds.size === 1 ? (slices[0]?.app_id ?? null) : null;
125
+ }
126
+
127
+ /** True when the value is never used as an app_code in ua_spend, i.e. it is a placeholder. */
128
+ async function isPlaceholderAppId(appId: string, signal?: AbortSignal): Promise<boolean> {
129
+ const probe = await runStructuredQuery(
130
+ { dataset: "ua_spend", metrics: ["spend"], dimensions: ["app_code"], filters: { app_code: appId }, limit: 1 },
131
+ signal,
132
+ );
133
+ return probe.rows.length === 0;
134
+ }
135
+
136
+ export async function resolveScopeMode(
137
+ slices: DashboardActualsScope["slices"],
138
+ signal?: AbortSignal,
139
+ ): Promise<{ mode: DashboardScopeMode; portfolioAppId: string | null }> {
140
+ const collapsed = collapsedAppId(slices);
141
+ if (collapsed === null) return { mode: "app", portfolioAppId: null };
142
+ // A single real app still scopes on app_code; only a value absent from the mart is a placeholder.
143
+ if (!(await isPlaceholderAppId(collapsed, signal))) return { mode: "app", portfolioAppId: null };
144
+ return { mode: "portfolio", portfolioAppId: collapsed };
145
+ }
146
+
112
147
  export async function loadDashboardActuals(
113
148
  period: Period,
114
149
  scope: DashboardActualsScope,
@@ -123,30 +158,41 @@ export async function loadDashboardActuals(
123
158
  };
124
159
  const exactUaScope = uniqueSlices.map((slice) => ({ app_code: slice.app_id, platform: slice.store, media_source: slice.channel_group }));
125
160
  const exactUaPortfolioScope = [...new Map(uniqueSlices.map((slice) => [`${slice.app_id}\u0000${slice.store}`, { app_code: slice.app_id, platform: slice.store }])).values()];
126
- const current = await runStructuredQuery({ ...common, exactUaScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 }, signal);
161
+ const { mode, portfolioAppId } = await resolveScopeMode(uniqueSlices, signal);
162
+ const portfolio = mode === "portfolio";
163
+ // In portfolio mode the App axis is a placeholder absent from ua_spend, so scoping on
164
+ // app_code would match nothing. Drop it and aggregate every app at platform x media_source.
165
+ const sliceScope = portfolio
166
+ ? { exactUaChannelScope: [...new Map(uniqueSlices.map((slice) => [`${slice.store}\u0000${slice.channel_group}`, { platform: slice.store, media_source: slice.channel_group }])).values()] }
167
+ : { exactUaScope };
168
+ const discoveryScope = portfolio
169
+ ? { filters: { platform: [...new Set(uniqueSlices.map((slice) => slice.store))] } }
170
+ : { exactUaPortfolioScope };
171
+ const sliceDimensions = portfolio ? ["platform", "media_source"] : ["app_code", "platform", "media_source"];
172
+ const current = await runStructuredQuery({ ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 }, signal);
127
173
  const currentRow = current.rows[0];
128
174
  const coverageMin = currentRow?.date_min == null ? null : String(currentRow.date_min);
129
175
  const coverageMax = currentRow?.date_max == null ? null : String(currentRow.date_max);
130
176
  const comparisonDates = coverageMax ? comparisonDatesForCoverage(dates.currentFrom, coverageMax) : null;
131
177
  const [comparison, daily, approvedSlices, discoveredSlices] = await Promise.all([
132
178
  comparisonDates
133
- ? runStructuredQuery({ ...common, exactUaScope, dateFrom: comparisonDates.comparisonFrom, dateTo: comparisonDates.comparisonTo, limit: 1 }, signal)
179
+ ? runStructuredQuery({ ...common, ...sliceScope, dateFrom: comparisonDates.comparisonFrom, dateTo: comparisonDates.comparisonTo, limit: 1 }, signal)
134
180
  : Promise.resolve(null),
135
- runStructuredQuery({ ...common, exactUaScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, timeGrain: "day", limit: 1000 }, signal),
181
+ runStructuredQuery({ ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, timeGrain: "day", limit: 1000 }, signal),
136
182
  runStructuredQuery({
137
183
  ...common,
138
184
  dateFrom: dates.currentFrom,
139
185
  dateTo: dates.currentTo,
140
- exactUaScope,
141
- dimensions: ["app_code", "platform", "media_source"],
186
+ ...sliceScope,
187
+ dimensions: sliceDimensions,
142
188
  limit: 500,
143
189
  }, signal),
144
190
  runStructuredQuery({
145
191
  ...common,
146
192
  dateFrom: dates.currentFrom,
147
193
  dateTo: dates.currentTo,
148
- exactUaPortfolioScope,
149
- dimensions: ["app_code", "platform", "media_source"],
194
+ ...discoveryScope,
195
+ dimensions: sliceDimensions,
150
196
  limit: 1000,
151
197
  }, signal),
152
198
  ]);
@@ -159,16 +205,23 @@ export async function loadDashboardActuals(
159
205
  receipt("ua_spend.slices.approved", approvedSlices.built.sql, approvedSlices.rows.length),
160
206
  receipt("ua_spend.slices.discovery", discoveredSlices.built.sql, discoveredSlices.rows.length),
161
207
  ];
162
- const approvedKeys = new Set(exactUaScope.map((scope) => `${scope.app_code}\u0000${scope.platform}\u0000${scope.media_source}`));
208
+ const approvedKeys = new Set(
209
+ portfolio
210
+ ? uniqueSlices.map((slice) => `${slice.store}\u0000${slice.channel_group}`)
211
+ : exactUaScope.map((scope) => `${scope.app_code}\u0000${scope.platform}\u0000${scope.media_source}`),
212
+ );
213
+ const rowKey = (row: SqlRow): string =>
214
+ portfolio ? `${row.platform}\u0000${row.media_source}` : `${row.app_code}\u0000${row.platform}\u0000${row.media_source}`;
163
215
  const sliceRows = [
164
216
  ...approvedSlices.rows,
165
- ...discoveredSlices.rows.filter((row) => !approvedKeys.has(`${row.app_code}\u0000${row.platform}\u0000${row.media_source}`)),
217
+ ...discoveredSlices.rows.filter((row) => !approvedKeys.has(rowKey(row))),
166
218
  ];
167
219
 
168
220
  return {
169
221
  period,
170
- data_as_of: coverageMax ?? "unavailable",
222
+ data_as_of: coverageMax ?? ACTUALS_DATA_AS_OF_UNAVAILABLE,
171
223
  reporting_currency: "USD",
224
+ scope_mode: mode,
172
225
  coverage: {
173
226
  date_min: coverageMin,
174
227
  date_max: coverageMax,
@@ -179,7 +232,7 @@ export async function loadDashboardActuals(
179
232
  ? { spend: value(comparisonRow, "spend"), revenue: value(comparisonRow, "revenue") }
180
233
  : null,
181
234
  daily: completeDailySeries(daily.rows, dates.currentFrom, coverageMax),
182
- slices: mapDashboardSlices(sliceRows),
235
+ slices: mapDashboardSlices(sliceRows, portfolioAppId ?? undefined),
183
236
  query_receipts: queryReceipts,
184
237
  };
185
238
  }
@@ -7,7 +7,7 @@ import {
7
7
  type ForecastAllocation,
8
8
  type ForecastSlice,
9
9
  } from "../fpa-artifacts/contracts.ts";
10
- import { validateActualsSnapshot, type DashboardActualsSnapshot, type DashboardActualSlice } from "./source.ts";
10
+ import { validateActualsSnapshot, ACTUALS_DATA_AS_OF_UNAVAILABLE, type DashboardActualsSnapshot, type DashboardActualSlice } from "./source.ts";
11
11
 
12
12
  export type Tone = "positive" | "negative" | "warning" | "neutral";
13
13
  export type TableCell = string | null | { value: string | null; tone?: Tone };
@@ -123,6 +123,29 @@ function formatRoas(value: number | null): string | null {
123
123
  return value === null ? null : value.toFixed(2);
124
124
  }
125
125
 
126
+ /** Shown when a ratio is undefined by construction rather than missing from the data. */
127
+ const NOT_APPLICABLE = "—";
128
+
129
+ /**
130
+ * ROAS over a zero budget is undefined, not missing. Rendering it as "no data" makes a
131
+ * deliberately stopped channel look like a broken join, so the two are kept distinct.
132
+ */
133
+ function roasCell(roas: number | null, spend: number | null, tone?: Tone): TableCell {
134
+ if (roas === null && spend === 0) return NOT_APPLICABLE;
135
+ return numericCell(formatRoas(roas), tone);
136
+ }
137
+
138
+ /** A slice with no planned budget has nothing to deviate from. */
139
+ function deviationCell(deviation: number | null, forecastSpend: number | null, tone?: Tone): TableCell {
140
+ if (deviation === null && forecastSpend === 0) return NOT_APPLICABLE;
141
+ return numericCell(formatSignedPercent(deviation), tone);
142
+ }
143
+
144
+ /** Rows are channel × store; the store is what disambiguates a channel run on both platforms. */
145
+ function sliceUnitLabel(allocation: ForecastAllocation): string {
146
+ return `${allocation.channel_group} · ${allocation.store}`;
147
+ }
148
+
126
149
  function formatSignedPercent(value: number | null, suffix = ""): string | null {
127
150
  if (value === null) return null;
128
151
  const sign = value > 0 ? "+" : "";
@@ -211,8 +234,9 @@ function buildStrategyTable(
211
234
  ): GroupedTableDataset {
212
235
  const grouped = new Map<string, JoinedSlice[]>();
213
236
  for (const slice of joined) grouped.set(slice.allocation.app_id, [...(grouped.get(slice.allocation.app_id) ?? []), slice]);
237
+ const appAxisIsInformative = grouped.size > 1;
214
238
  const columns = [
215
- { key: "unit", label: "App / 渠道" },
239
+ { key: "unit", label: appAxisIsInformative ? "App / 渠道 · 平台" : "渠道 · 平台" },
216
240
  { key: "forecast", label: "预测 ROAS", align: "right" as const },
217
241
  { key: "actual", label: "实际 ROAS", align: "right" as const },
218
242
  { key: "deviation", label: "偏差", align: "right" as const },
@@ -220,7 +244,7 @@ function buildStrategyTable(
220
244
  { key: "exec", label: "执行状态" },
221
245
  ];
222
246
  return {
223
- label: "预测 vs 实际 · 执行策略(按 App",
247
+ label: `预测 vs 实际 · 执行策略(按${appAxisIsInformative ? " App × 渠道" : "渠道 · 平台"})`,
224
248
  description:
225
249
  `同周期付费 ROAS;偏差绝对值 ≥ ${(forecast.calibration_policy.deviation_warning_abs_gte * 100).toFixed(0)}% 标黄,` +
226
250
  `> ${(forecast.calibration_policy.deviation_trigger_abs_gt * 100).toFixed(0)}% 触发校准。`,
@@ -249,20 +273,21 @@ function buildStrategyTable(
249
273
  key: appId,
250
274
  label: appId,
251
275
  summary: {
252
- forecast: formatRoas(appForecast),
253
- actual: formatRoas(appActual),
254
- deviation: numericCell(formatSignedPercent(appDeviation), appDeviation !== null && Math.abs(appDeviation) >= forecast.calibration_policy.deviation_warning_abs_gte ? "warning" : undefined),
276
+ forecast: roasCell(appForecast, forecastSpend),
277
+ actual: roasCell(appActual, actualSpend),
278
+ deviation: deviationCell(appDeviation, forecastSpend, appDeviation !== null && Math.abs(appDeviation) >= forecast.calibration_policy.deviation_warning_abs_gte ? "warning" : undefined),
255
279
  decision: summaryTone ? { value: summaryAction, tone: summaryTone } : summaryAction,
256
280
  exec: executionGroupLabel(executionReceipt, slices),
257
281
  },
258
282
  rows: slices.map((slice) => {
259
283
  const stop = slice.actualRoas !== null && slice.actualRoas < forecast.calibration_policy.stop_loss_roas_lt;
260
284
  const warn = slice.deviation !== null && Math.abs(slice.deviation) >= forecast.calibration_policy.deviation_warning_abs_gte;
285
+ const plannedSpend = slice.forecast.metrics.spend?.base ?? null;
261
286
  return {
262
- unit: slice.allocation.channel_group,
263
- forecast: formatRoas(slice.forecastRoas),
264
- actual: numericCell(formatRoas(slice.actualRoas), stop ? "negative" : undefined),
265
- deviation: numericCell(formatSignedPercent(slice.deviation), stop ? "negative" : warn ? "warning" : undefined),
287
+ unit: sliceUnitLabel(slice.allocation),
288
+ forecast: roasCell(slice.forecastRoas, plannedSpend),
289
+ actual: roasCell(slice.actualRoas, slice.actual?.spend ?? null, stop ? "negative" : undefined),
290
+ deviation: deviationCell(slice.deviation, plannedSpend, stop ? "negative" : warn ? "warning" : undefined),
266
291
  decision: actionCell(slice.allocation.action),
267
292
  exec: executionLabel(executionReceipt, slice.execution),
268
293
  };
@@ -276,7 +301,7 @@ function buildAlerts(joined: JoinedSlice[], forecast: ApprovedCycleForecastInput
276
301
  const rows: Array<Record<string, TableCell>> = [];
277
302
  for (const slice of joined) {
278
303
  if (slice.actualRoas === null) continue;
279
- const unit = `${slice.allocation.app_id} × ${slice.allocation.channel_group}`;
304
+ const unit = `${slice.allocation.app_id} × ${sliceUnitLabel(slice.allocation)}`;
280
305
  if (slice.actualRoas < forecast.calibration_policy.stop_loss_roas_lt) {
281
306
  rows.push({
282
307
  level: { value: "严重", tone: "negative" },
@@ -344,6 +369,7 @@ export function projectDashboard(input: ProjectionInput): DashboardBuild {
344
369
  const stopCount = joined.filter((slice) => slice.actualRoas !== null && slice.actualRoas < forecast.calibration_policy.stop_loss_roas_lt).length;
345
370
  const deviationCount = alerts.rows.length - stopCount;
346
371
  const window = `${formatPeriodDate(forecast.target_period.start_inclusive, forecast.target_period.timezone, locale)} → ${formatPeriodDate(forecast.target_period.end_exclusive, forecast.target_period.timezone, locale)}(end exclusive)`;
372
+ const asOfNote = actuals.data_as_of === ACTUALS_DATA_AS_OF_UNAVAILABLE ? "本周期暂无 Actuals" : `Actuals 截至 ${actuals.data_as_of}`;
347
373
 
348
374
  const widgets: DashboardWidget[] = [
349
375
  {
@@ -366,7 +392,7 @@ export function projectDashboard(input: ProjectionInput): DashboardBuild {
366
392
  progress: { fraction: clampFraction(revenueAttainment), label: formatSignedPercent(revenueAttainment)?.replace(/^\+/, "") ?? "无数据", tone: progressTone(revenueAttainment) },
367
393
  description: `冻结预测 ${formatCurrency(forecastRevenue, forecast.reporting_currency, locale)}`,
368
394
  }),
369
- footnote: `${window};Actuals as of ${actuals.data_as_of}`,
395
+ footnote: `${window};${asOfNote}`,
370
396
  },
371
397
  },
372
398
  {
@@ -474,6 +500,13 @@ export function projectDashboard(input: ProjectionInput): DashboardBuild {
474
500
  warnings: [
475
501
  ...(forecast.status !== "complete" ? [`Approved forecast status is ${forecast.status}.`] : []),
476
502
  ...(!forecast.approval_conditions_satisfied ? ["Approved forecast conditions are not satisfied."] : []),
503
+ ...(actuals.scope_mode === "portfolio"
504
+ ? [
505
+ `App axis "${forecast.approved_allocation[0]?.app_id}" is not an app_code in ua_spend; ` +
506
+ "Actuals were aggregated across every app at the store × channel grain. " +
507
+ "Verify this matches the declared forecast scope before reading App-level conclusions into it.",
508
+ ]
509
+ : []),
477
510
  ...(execution?.verification_status === "reported" ? ["Execution is reported and not externally verified."] : []),
478
511
  ...(missingExecutionSlices > 0 ? [`Execution receipt is missing ${missingExecutionSlices} approved slices.`] : []),
479
512
  ...(extraExecutionSlices > 0 ? [`Execution receipt contains ${extraExecutionSlices} slices outside the approved allocation.`] : []),
@@ -15,6 +15,19 @@ export interface DashboardActualSlice {
15
15
  source_rows: number;
16
16
  }
17
17
 
18
+ /** Sentinel used when no Actuals row exists yet, so no calendar date can be reported. */
19
+ export const ACTUALS_DATA_AS_OF_UNAVAILABLE = "unavailable";
20
+
21
+ /**
22
+ * How Actuals were scoped against the frozen forecast.
23
+ *
24
+ * "app" - every slice matched ua_spend on app_code × platform × media_source.
25
+ * "portfolio" - the forecast collapsed the App axis onto a placeholder that does not
26
+ * exist in ua_spend, so Actuals were aggregated across every app at the
27
+ * platform × media_source grain.
28
+ */
29
+ export type DashboardScopeMode = "app" | "portfolio";
30
+
18
31
  export interface DashboardDailyPoint {
19
32
  date: string;
20
33
  spend: number | null;
@@ -25,6 +38,7 @@ export interface DashboardActualsSnapshot {
25
38
  period: Period;
26
39
  data_as_of: string;
27
40
  reporting_currency: string;
41
+ scope_mode: DashboardScopeMode;
28
42
  coverage: {
29
43
  date_min: string | null;
30
44
  date_max: string | null;
@@ -127,10 +141,13 @@ export function validateActualsSnapshot(value: unknown): DashboardActualsSnapsho
127
141
 
128
142
  const reportingCurrency = string(source.reporting_currency, "actuals.reporting_currency").toUpperCase();
129
143
  if (!/^[A-Z]{3}$/.test(reportingCurrency)) throw new Error("actuals.reporting_currency must be an ISO-4217 currency code.");
144
+ const scopeMode = source.scope_mode === undefined ? "app" : string(source.scope_mode, "actuals.scope_mode");
145
+ if (scopeMode !== "app" && scopeMode !== "portfolio") throw new Error('actuals.scope_mode must be "app" or "portfolio".');
130
146
  return {
131
147
  period: period(source.period, "actuals.period"),
132
148
  data_as_of: string(source.data_as_of, "actuals.data_as_of"),
133
149
  reporting_currency: reportingCurrency,
150
+ scope_mode: scopeMode,
134
151
  coverage: {
135
152
  date_min: coverage.date_min === null ? null : string(coverage.date_min, "actuals.coverage.date_min"),
136
153
  date_max: coverage.date_max === null ? null : string(coverage.date_max, "actuals.coverage.date_max"),
@@ -23,16 +23,34 @@ import { CATALOG_CAVEATS, DATASETS, DATASET_IDS } from "./registry.ts";
23
23
  import {
24
24
  buildCohortQuery,
25
25
  buildCoverageSql,
26
+ buildCoverageStatsSql,
26
27
  buildCohortSizeCoverageSql,
28
+ COVERAGE_TARGETS,
27
29
  MAX_LIMIT,
28
30
  type QuerySpec,
29
31
  } from "./sql.ts";
30
32
  import { runStructuredQuery } from "./runtime.ts";
31
- import { runQuery } from "./supabase.ts";
33
+ import { runQuery, type SqlRow } from "./supabase.ts";
32
34
 
33
35
  const MAX_TOOL_TEXT_CHARS = 100_000;
34
36
  const MAX_DISPLAY_ROWS = 200;
35
37
 
38
+ /** Full-scan catalog stats get a shorter leash than a real query; they are context, not answers. */
39
+ const CATALOG_STATS_TIMEOUT_MS = 12_000;
40
+
41
+ /**
42
+ * Resolve to null when supplementary work fails, so one slow table degrades a field
43
+ * instead of the whole tool. A caller-initiated abort still propagates.
44
+ */
45
+ async function bestEffort<T>(work: Promise<T>, signal?: AbortSignal): Promise<T | null> {
46
+ try {
47
+ return await work;
48
+ } catch (error) {
49
+ if (signal?.aborted) throw error;
50
+ return null;
51
+ }
52
+ }
53
+
36
54
  const DatasetIdSchema = StringEnum(DATASET_IDS as [string, ...string[]], {
37
55
  description: "Dataset id from fpa_data_catalog",
38
56
  });
@@ -134,9 +152,19 @@ export default function fpaDataExtension(pi: ExtensionAPI): void {
134
152
  ),
135
153
  executionMode: "parallel",
136
154
  async execute(_toolCallId, params, signal) {
137
- const [coverage, cohortSize, apps] = await Promise.all([
138
- runQuery(buildCoverageSql(), { signal }),
139
- runQuery(buildCohortSizeCoverageSql(), { signal }),
155
+ // Date coverage is the part callers must have, and min/max over an indexed date
156
+ // column is cheap. Run one query per dataset so a single slow table cannot time
157
+ // out the whole catalog, then gather the scan-bound stats separately.
158
+ const coverageRows = await Promise.all(
159
+ COVERAGE_TARGETS.map(async (target) => (await runQuery(buildCoverageSql(target), { signal }))[0] ?? { dataset: target.dataset }),
160
+ );
161
+ const [statsRows, cohortSize, apps] = await Promise.all([
162
+ Promise.all(
163
+ COVERAGE_TARGETS.map((target) =>
164
+ bestEffort(runQuery(buildCoverageStatsSql(target), { signal, timeoutMs: CATALOG_STATS_TIMEOUT_MS }), signal),
165
+ ),
166
+ ),
167
+ bestEffort(runQuery(buildCohortSizeCoverageSql(), { signal, timeoutMs: CATALOG_STATS_TIMEOUT_MS }), signal),
140
168
  params.include_apps
141
169
  ? runQuery(
142
170
  "select app_code, string_agg(distinct platform, ',' order by platform) as platforms " +
@@ -146,6 +174,12 @@ export default function fpaDataExtension(pi: ExtensionAPI): void {
146
174
  )
147
175
  : Promise.resolve<SqlRow[]>([]),
148
176
  ]);
177
+ const coverage = coverageRows.map((row, index) => {
178
+ const stats = statsRows[index]?.[0];
179
+ return stats
180
+ ? { ...row, ...stats }
181
+ : { ...row, row_count: null, apps: null, updated_at: null, stats_unavailable: "Row counts timed out; date coverage above is unaffected." };
182
+ });
149
183
 
150
184
  return toolResult(
151
185
  {
@@ -165,7 +199,7 @@ export default function fpaDataExtension(pi: ExtensionAPI): void {
165
199
  notes: d.notes,
166
200
  })),
167
201
  coverage,
168
- cohort_size_available: cohortSize[0] ?? null,
202
+ cohort_size_available: cohortSize?.[0] ?? null,
169
203
  caveats: CATALOG_CAVEATS,
170
204
  ...(params.include_apps ? { apps } : {}),
171
205
  },
@@ -27,6 +27,12 @@ export interface QuerySpec {
27
27
  exactUaScope?: Array<{ app_code: string; platform: string; media_source: string }>;
28
28
  /** Internal exact App + Store portfolio scope used to discover unplanned paid channels. */
29
29
  exactUaPortfolioScope?: Array<{ app_code: string; platform: string }>;
30
+ /**
31
+ * Internal exact Store + Channel scope for forecasts whose App axis is a portfolio
32
+ * placeholder. The placeholder never matches ua_spend.app_code, so the App predicate
33
+ * is dropped and Actuals are aggregated across every app.
34
+ */
35
+ exactUaChannelScope?: Array<{ platform: string; media_source: string }>;
30
36
  sort?: { by: string; direction?: "asc" | "desc" };
31
37
  limit?: number;
32
38
  }
@@ -233,6 +239,20 @@ export function buildQuery(spec: QuerySpec): BuiltQuery {
233
239
  filterKeys.push("app_code", "platform");
234
240
  notes.push("Applied exact App + Store portfolio scope.");
235
241
  }
242
+ if (spec.exactUaChannelScope !== undefined) {
243
+ if (dataset.id !== "ua_spend") throw new Error("exactUaChannelScope is supported only for ua_spend.");
244
+ if (spec.exactUaChannelScope.length === 0 || spec.exactUaChannelScope.length > 500) throw new Error("exactUaChannelScope must contain between 1 and 500 Store + Channel pairs.");
245
+ const dimensionSql = Object.fromEntries(dataset.dimensions.map((dimension) => [dimension.name, dimension.sql]));
246
+ const predicates = spec.exactUaChannelScope.map((scope, index) => {
247
+ for (const [name, value] of Object.entries(scope)) {
248
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`exactUaChannelScope[${index}].${name} must be a non-empty string.`);
249
+ }
250
+ return `(${dimensionSql.platform} = ${escapeLiteral(scope.platform)} and ${dimensionSql.media_source} = ${escapeLiteral(scope.media_source)})`;
251
+ });
252
+ where.push(`(${predicates.join(" or ")})`);
253
+ filterKeys.push("platform", "media_source");
254
+ notes.push("Applied exact Store + Channel scope across every app (portfolio-placeholder forecast).");
255
+ }
236
256
 
237
257
  const breakdown = resolveBreakdown(dataset, dimensionNames, filterKeys);
238
258
  if (breakdown.predicate) {
@@ -422,17 +442,50 @@ export function buildCohortQuery(spec: CohortSpec): BuiltCohortQuery {
422
442
  return { sql, spendSql, asOfSql, horizons, bucket, groupBy };
423
443
  }
424
444
 
425
- /** Coverage catalog query used by fpa_data_catalog. */
426
- export function buildCoverageSql(): string {
427
- const parts = [
428
- `select 'ua_spend' as dataset, count(*) as row_count, min(install_date)::text as date_min, max(install_date)::text as date_max, count(distinct app_code) as apps, max(updated_at)::text as updated_at from appsflyer_ua_campaign_daily`,
429
- `select 'revenue_activity', count(*), min(event_date)::text, max(event_date)::text, count(distinct app_code), max(updated_at)::text from appsflyer_install_cohort_daily`,
430
- `select 'cohort (fpa_cohort)', count(*), min(install_date)::text, max(install_date)::text, count(distinct app_code), max(updated_at)::text from appsflyer_install_cohort_daily`,
431
- `select 'apple_store', count(*), min(metric_date)::text, max(metric_date)::text, count(distinct adam_id), max(updated_at)::text from apple_app_daily`,
432
- `select 'product_events', count(*), min(event_date)::text, max(event_date)::text, count(distinct app_code), max(updated_at)::text from appsflyer_event_daily`,
433
- `select 'mixpanel_product', count(*), min(business_date)::text, max(business_date)::text, count(distinct app_code), max(updated_at)::text from mixpanel_app_daily`,
434
- ];
435
- return assertReadOnly(parts.join(" union all "));
445
+ /** One row of the fpa_data_catalog coverage block. */
446
+ export interface CoverageTarget {
447
+ dataset: string;
448
+ table: string;
449
+ dateColumn: string;
450
+ appColumn: string;
451
+ }
452
+
453
+ export const COVERAGE_TARGETS: CoverageTarget[] = [
454
+ { dataset: "ua_spend", table: "appsflyer_ua_campaign_daily", dateColumn: "install_date", appColumn: "app_code" },
455
+ { dataset: "revenue_activity", table: "appsflyer_install_cohort_daily", dateColumn: "event_date", appColumn: "app_code" },
456
+ { dataset: "cohort (fpa_cohort)", table: "appsflyer_install_cohort_daily", dateColumn: "install_date", appColumn: "app_code" },
457
+ { dataset: "apple_store", table: "apple_app_daily", dateColumn: "metric_date", appColumn: "adam_id" },
458
+ { dataset: "product_events", table: "appsflyer_event_daily", dateColumn: "event_date", appColumn: "app_code" },
459
+ { dataset: "mixpanel_product", table: "mixpanel_app_daily", dateColumn: "business_date", appColumn: "app_code" },
460
+ ];
461
+
462
+ /**
463
+ * Answerable date range for one dataset.
464
+ *
465
+ * min()/max() alone let Postgres walk the date index instead of scanning the table.
466
+ * Mixing them with count(*) in a single statement — as the old six-way UNION did —
467
+ * forces a sequential scan per arm, which is what pushed fpa_data_catalog past its
468
+ * timeout as the mart grew.
469
+ */
470
+ export function buildCoverageSql(target: CoverageTarget): string {
471
+ return assertReadOnly(
472
+ `select ${escapeLiteral(target.dataset)} as dataset, ` +
473
+ `min(${target.dateColumn})::text as date_min, max(${target.dateColumn})::text as date_max ` +
474
+ `from ${target.table}`,
475
+ );
476
+ }
477
+
478
+ /**
479
+ * Row counts and freshness for one dataset. These need a full scan (count(distinct)
480
+ * most of all), so callers treat them as best-effort and report null when they time out
481
+ * rather than failing the whole catalog.
482
+ */
483
+ export function buildCoverageStatsSql(target: CoverageTarget): string {
484
+ return assertReadOnly(
485
+ `select count(*) as row_count, count(distinct ${target.appColumn}) as apps, ` +
486
+ `max(updated_at)::text as updated_at ` +
487
+ `from ${target.table}`,
488
+ );
436
489
  }
437
490
 
438
491
  /** Cohort-size availability window, reported by fpa_data_catalog. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Full-cycle FP&A planning, strategy, forecast, and review prompts, skills, and data tools for Pi",
6
6
  "license": "UNLICENSED",