@viccydev/pi-fpa 0.3.1 → 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.
@@ -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
  },
@@ -442,17 +442,50 @@ export function buildCohortQuery(spec: CohortSpec): BuiltCohortQuery {
442
442
  return { sql, spendSql, asOfSql, horizons, bucket, groupBy };
443
443
  }
444
444
 
445
- /** Coverage catalog query used by fpa_data_catalog. */
446
- export function buildCoverageSql(): string {
447
- const parts = [
448
- `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`,
449
- `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`,
450
- `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`,
451
- `select 'apple_store', count(*), min(metric_date)::text, max(metric_date)::text, count(distinct adam_id), max(updated_at)::text from apple_app_daily`,
452
- `select 'product_events', count(*), min(event_date)::text, max(event_date)::text, count(distinct app_code), max(updated_at)::text from appsflyer_event_daily`,
453
- `select 'mixpanel_product', count(*), min(business_date)::text, max(business_date)::text, count(distinct app_code), max(updated_at)::text from mixpanel_app_daily`,
454
- ];
455
- 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
+ );
456
489
  }
457
490
 
458
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.1",
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",