@viccydev/pi-fpa 0.2.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.
Files changed (29) hide show
  1. package/README.md +135 -0
  2. package/extensions/fpa-data/calc.ts +544 -0
  3. package/extensions/fpa-data/index.ts +478 -0
  4. package/extensions/fpa-data/registry.ts +303 -0
  5. package/extensions/fpa-data/sql.ts +412 -0
  6. package/extensions/fpa-data/supabase.ts +96 -0
  7. package/package.json +54 -0
  8. package/prompts/fpa-plan-cycle.md +44 -0
  9. package/prompts/fpa-review-cycle.md +33 -0
  10. package/skills/fpa-analyze-drivers/SKILL.md +30 -0
  11. package/skills/fpa-analyze-drivers/references/artifact-contract.md +34 -0
  12. package/skills/fpa-apply-core-rules/SKILL.md +34 -0
  13. package/skills/fpa-apply-core-rules/references/core-rules.md +96 -0
  14. package/skills/fpa-diagnose-actuals/SKILL.md +30 -0
  15. package/skills/fpa-diagnose-actuals/references/artifact-contract.md +38 -0
  16. package/skills/fpa-execute-approved-strategy/SKILL.md +40 -0
  17. package/skills/fpa-execute-approved-strategy/references/artifact-contract.md +29 -0
  18. package/skills/fpa-forecast-approved-strategy/SKILL.md +39 -0
  19. package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +36 -0
  20. package/skills/fpa-plan-cycle/SKILL.md +29 -0
  21. package/skills/fpa-plan-cycle/references/artifact-contract.md +41 -0
  22. package/skills/fpa-recommend-strategy/SKILL.md +29 -0
  23. package/skills/fpa-recommend-strategy/references/artifact-contract.md +30 -0
  24. package/skills/fpa-review-cycle/SKILL.md +32 -0
  25. package/skills/fpa-review-cycle/references/artifact-contract.md +30 -0
  26. package/skills/fpa-review-strategy/SKILL.md +28 -0
  27. package/skills/fpa-review-strategy/references/artifact-contract.md +25 -0
  28. package/skills/fpa-simulate-strategies/SKILL.md +32 -0
  29. package/skills/fpa-simulate-strategies/references/artifact-contract.md +35 -0
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Metric and dataset registry for the FP&A Supabase mart.
3
+ *
4
+ * Every queryable dataset, dimension, and measure is declared here so the SQL
5
+ * builder can only ever reference vetted expressions. The LLM never supplies
6
+ * SQL or arithmetic — it picks names from this registry.
7
+ */
8
+
9
+ export type MeasureUnit = "usd" | "count" | "users" | "ratio";
10
+
11
+ export interface DimensionDef {
12
+ name: string;
13
+ sql: string;
14
+ description: string;
15
+ }
16
+
17
+ export interface MeasureDef {
18
+ name: string;
19
+ sql: string;
20
+ description: string;
21
+ unit: MeasureUnit;
22
+ }
23
+
24
+ export interface DerivedDef {
25
+ name: string;
26
+ description: string;
27
+ numerator: string;
28
+ denominator: string;
29
+ /** Multiplier applied to numerator/denominator, e.g. 1000 for CPM. */
30
+ scale?: number;
31
+ unit: MeasureUnit;
32
+ }
33
+
34
+ export interface EventNameExpansion {
35
+ /** JSONB column expanded with jsonb_each when the event_name dimension is used. */
36
+ jsonbColumn: string;
37
+ /** Measure name -> aggregate SQL over the expanded pair e(k, v). */
38
+ measures: Record<string, string>;
39
+ }
40
+
41
+ export interface BreakdownRule {
42
+ column: string;
43
+ /** Value used when none of the dimension-specific variants apply. */
44
+ base: string;
45
+ /** Dimension name -> required breakdown value. */
46
+ byDimension: Record<string, string>;
47
+ }
48
+
49
+ export interface DatasetDef {
50
+ id: string;
51
+ table: string;
52
+ dateColumn: string;
53
+ description: string;
54
+ dimensions: DimensionDef[];
55
+ measures: MeasureDef[];
56
+ derived: DerivedDef[];
57
+ notes: string[];
58
+ breakdown?: BreakdownRule;
59
+ eventName?: EventNameExpansion;
60
+ /** Extra FROM clause fragments (lateral joins) required by the measures. */
61
+ lateral?: string;
62
+ }
63
+
64
+ const AF_METRIC_FIELDS: Record<string, string> = {
65
+ event_count: "event_count",
66
+ unique_users: "unique_appsflyer_user_count",
67
+ gross_sales: "gross_sales_usd",
68
+ gross_refunds: "gross_refund_usd",
69
+ net_proceeds: "net_proceeds_usd",
70
+ net_refund_proceeds: "net_refund_proceeds_usd",
71
+ };
72
+
73
+ function afEventMeasures(): Record<string, string> {
74
+ const out: Record<string, string> = {};
75
+ for (const [name, field] of Object.entries(AF_METRIC_FIELDS)) {
76
+ out[name] = `sum((e.v->>'${field}')::numeric)`;
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function appleSum(key: string): string {
82
+ return `sum((m.elem->>'value')::numeric) filter (where m.elem->>'metric_key' = '${key}')`;
83
+ }
84
+
85
+ function appleAvg(key: string): string {
86
+ return `avg((m.elem->>'value')::numeric) filter (where m.elem->>'metric_key' = '${key}')`;
87
+ }
88
+
89
+ function appleLatest(key: string): string {
90
+ return `(array_agg((m.elem->>'value')::numeric order by metric_date desc) filter (where m.elem->>'metric_key' = '${key}'))[1]`;
91
+ }
92
+
93
+ export const DATASETS: DatasetDef[] = [
94
+ {
95
+ id: "ua_spend",
96
+ table: "appsflyer_ua_campaign_daily",
97
+ dateColumn: "install_date",
98
+ description:
99
+ "AppsFlyer UA spend and acquisition funnel per campaign per install day (impressions, clicks, installs, cost).",
100
+ dimensions: [
101
+ { name: "app_code", sql: "app_code", description: "Internal app code" },
102
+ { name: "platform", sql: "platform", description: "ios or android" },
103
+ { name: "media_source", sql: "media_source", description: "Ad network / media source" },
104
+ { name: "campaign_name", sql: "campaign_name", description: "Campaign name" },
105
+ { name: "campaign_id", sql: "campaign_id", description: "Campaign id" },
106
+ {
107
+ name: "country_code",
108
+ sql: "country_code",
109
+ description: "Geo (forces breakdown_type=CAMPAIGN_GEO)",
110
+ },
111
+ {
112
+ name: "channel",
113
+ sql: "channel",
114
+ description: "Channel within a media source (forces breakdown_type=CAMPAIGN_CHANNEL)",
115
+ },
116
+ ],
117
+ measures: [
118
+ { name: "spend", sql: "sum(cost_usd)", description: "UA cost in USD", unit: "usd" },
119
+ { name: "impressions", sql: "sum(impressions)", description: "Ad impressions", unit: "count" },
120
+ { name: "clicks", sql: "sum(clicks)", description: "Ad clicks", unit: "count" },
121
+ { name: "installs", sql: "sum(installs)", description: "Attributed installs", unit: "count" },
122
+ ],
123
+ derived: [
124
+ { name: "cpi", description: "Cost per install = spend / installs", numerator: "spend", denominator: "installs", unit: "usd" },
125
+ { name: "ctr", description: "Click-through rate = clicks / impressions", numerator: "clicks", denominator: "impressions", unit: "ratio" },
126
+ { name: "cvr", description: "Click-to-install rate = installs / clicks", numerator: "installs", denominator: "clicks", unit: "ratio" },
127
+ { name: "cpm", description: "Cost per mille = spend * 1000 / impressions", numerator: "spend", denominator: "impressions", scale: 1000, unit: "usd" },
128
+ ],
129
+ notes: [
130
+ "The table stores three overlapping breakdowns of the SAME spend (CAMPAIGN, CAMPAIGN_GEO, CAMPAIGN_CHANNEL). The builder pins exactly one breakdown_type per query, so totals are never double-counted.",
131
+ "country_code and channel cannot be combined in one query: no breakdown carries both.",
132
+ ],
133
+ breakdown: {
134
+ column: "breakdown_type",
135
+ base: "CAMPAIGN",
136
+ byDimension: { country_code: "CAMPAIGN_GEO", channel: "CAMPAIGN_CHANNEL" },
137
+ },
138
+ },
139
+ {
140
+ id: "revenue_activity",
141
+ table: "appsflyer_install_cohort_daily",
142
+ dateColumn: "event_date",
143
+ description:
144
+ "AppsFlyer revenue and activity booked per calendar day (event_date axis, all install cohorts combined). Use fpa_cohort for install-cohort LTV/ROAS/retention.",
145
+ dimensions: [
146
+ { name: "app_code", sql: "app_code", description: "Internal app code" },
147
+ { name: "platform", sql: "platform", description: "ios or android" },
148
+ ],
149
+ measures: [
150
+ { name: "gross_sales", sql: "sum(gross_sales_usd)", description: "Gross sales USD", unit: "usd" },
151
+ { name: "gross_refunds", sql: "sum(gross_refund_usd)", description: "Gross refunds USD", unit: "usd" },
152
+ { name: "net_proceeds", sql: "sum(net_proceeds_usd)", description: "Net proceeds USD", unit: "usd" },
153
+ { name: "net_refund_proceeds", sql: "sum(net_refund_proceeds_usd)", description: "Net refund proceeds USD", unit: "usd" },
154
+ { name: "active_user_days", sql: "sum(active_user_count)", description: "Sum of daily active users (user-days when aggregated over multiple days)", unit: "users" },
155
+ { name: "event_count", sql: "sum(event_count)", description: "Tracked event count", unit: "count" },
156
+ ],
157
+ derived: [
158
+ { name: "refund_rate", description: "gross_refunds / gross_sales", numerator: "gross_refunds", denominator: "gross_sales", unit: "ratio" },
159
+ ],
160
+ notes: [
161
+ "This is the activity view of the install-cohort table: a day's revenue includes payments from all historical cohorts.",
162
+ ],
163
+ },
164
+ {
165
+ id: "apple_store",
166
+ table: "apple_app_daily",
167
+ dateColumn: "metric_date",
168
+ description:
169
+ "App Store Connect daily analytics per app (proceeds, units, conversion rate, paying users, subscription states).",
170
+ dimensions: [
171
+ { name: "studio_code", sql: "studio_code", description: "Studio code" },
172
+ { name: "adam_id", sql: "adam_id", description: "App Store Adam ID" },
173
+ { name: "platform", sql: "platform", description: "Store platform" },
174
+ ],
175
+ measures: [
176
+ { name: "proceeds", sql: appleSum("proceeds"), description: "Apple proceeds (developer share)", unit: "usd" },
177
+ { name: "units", sql: appleSum("units"), description: "First-time downloads", unit: "count" },
178
+ { name: "iap_count", sql: appleSum("iap"), description: "In-app purchase count", unit: "count" },
179
+ { name: "redownloads", sql: appleSum("redownloads"), description: "Redownloads", unit: "count" },
180
+ { name: "updates", sql: appleSum("updates"), description: "App updates", unit: "count" },
181
+ { name: "page_views", sql: appleSum("pageViewCount"), description: "Product page views", unit: "count" },
182
+ { name: "store_impressions", sql: appleSum("impressionsTotal"), description: "Store impressions", unit: "count" },
183
+ { name: "conversion_rate_avg", sql: appleAvg("conversionRate"), description: "Unweighted average of DAILY store conversion rate (source metric_type=AVERAGE; do not sum)", unit: "ratio" },
184
+ { name: "paying_users_avg", sql: appleAvg("payingUsers"), description: "Unweighted average of DAILY paying users (source metric_type=AVERAGE; do not sum)", unit: "users" },
185
+ { name: "subs_paid_latest", sql: appleLatest("subscription-state-paid"), description: "Paid subscriptions state on the LAST day of the range (source metric_type=LATEST)", unit: "count" },
186
+ { name: "subs_plans_active_latest", sql: appleLatest("subscription-state-plans-active"), description: "Active subscription plans on the LAST day of the range (source metric_type=LATEST)", unit: "count" },
187
+ ],
188
+ derived: [
189
+ { name: "proceeds_per_unit", description: "proceeds / units", numerator: "proceeds", denominator: "units", unit: "usd" },
190
+ ],
191
+ notes: [
192
+ "Metrics live in a JSONB array with per-metric aggregation semantics: COUNT metrics are summed, AVERAGE metrics are daily averages, LATEST metrics take the last day's value.",
193
+ "Keyed by adam_id, not app_code; join to other datasets via mixpanel_app_daily.adam_id when needed.",
194
+ ],
195
+ lateral:
196
+ "left join lateral jsonb_array_elements(coalesce(metrics->'metric', '[]'::jsonb)) as m(elem) on true",
197
+ },
198
+ {
199
+ id: "product_events",
200
+ table: "appsflyer_event_daily",
201
+ dateColumn: "event_date",
202
+ description:
203
+ "AppsFlyer daily event aggregates per app, with optional per-event-name breakdown (funnel events, paywall events, purchases).",
204
+ dimensions: [
205
+ { name: "app_code", sql: "app_code", description: "Internal app code" },
206
+ { name: "platform", sql: "platform", description: "ios or android" },
207
+ { name: "event_name", sql: "e.k", description: "Tracked event name (expands the event_metrics JSONB)" },
208
+ ],
209
+ measures: [
210
+ { name: "event_count", sql: "sum(event_count)", description: "Event count", unit: "count" },
211
+ { name: "unique_users", sql: "sum(unique_appsflyer_user_count)", description: "Unique AppsFlyer users", unit: "users" },
212
+ { name: "gross_sales", sql: "sum(gross_sales_usd)", description: "Gross sales USD", unit: "usd" },
213
+ { name: "gross_refunds", sql: "sum(gross_refund_usd)", description: "Gross refunds USD", unit: "usd" },
214
+ { name: "net_proceeds", sql: "sum(net_proceeds_usd)", description: "Net proceeds USD", unit: "usd" },
215
+ { name: "net_refund_proceeds", sql: "sum(net_refund_proceeds_usd)", description: "Net refund proceeds USD", unit: "usd" },
216
+ ],
217
+ derived: [
218
+ { name: "refund_rate", description: "gross_refunds / gross_sales", numerator: "gross_refunds", denominator: "gross_sales", unit: "ratio" },
219
+ { name: "events_per_user", description: "event_count / unique_users", numerator: "event_count", denominator: "unique_users", unit: "ratio" },
220
+ ],
221
+ notes: [],
222
+ eventName: {
223
+ jsonbColumn: "event_metrics",
224
+ measures: afEventMeasures(),
225
+ },
226
+ },
227
+ {
228
+ id: "mixpanel_product",
229
+ table: "mixpanel_app_daily",
230
+ dateColumn: "business_date",
231
+ description:
232
+ "Mixpanel product analytics per app per day (first opens, actives, sessions), with optional per-event-name counts.",
233
+ dimensions: [
234
+ { name: "app_code", sql: "app_code", description: "Internal app code" },
235
+ { name: "platform", sql: "platform", description: "ios or android" },
236
+ { name: "adam_id", sql: "adam_id", description: "App Store Adam ID (joins to apple_store)" },
237
+ { name: "event_name", sql: "e.k", description: "Mixpanel event name (expands the mp_event_counts JSONB)" },
238
+ ],
239
+ measures: [
240
+ { name: "first_open_users", sql: "sum(mp_first_open_users)", description: "First-open users", unit: "users" },
241
+ { name: "active_users", sql: "sum(mp_active_users)", description: "Daily active users (user-days when aggregated over multiple days)", unit: "users" },
242
+ { name: "event_users", sql: "sum(mp_event_users)", description: "Users with any event", unit: "users" },
243
+ { name: "session_count", sql: "sum(mp_session_count)", description: "Session count", unit: "count" },
244
+ { name: "session_users", sql: "sum(mp_session_users)", description: "Users with a session", unit: "users" },
245
+ { name: "updated_users", sql: "sum(mp_updated_users)", description: "Users who updated the app", unit: "users" },
246
+ ],
247
+ derived: [
248
+ { name: "sessions_per_session_user", description: "session_count / session_users", numerator: "session_count", denominator: "session_users", unit: "ratio" },
249
+ ],
250
+ notes: ["Only a subset of apps is instrumented in Mixpanel."],
251
+ eventName: {
252
+ jsonbColumn: "mp_event_counts",
253
+ measures: { event_count: "sum((e.v)::numeric)" },
254
+ },
255
+ },
256
+ ];
257
+
258
+ export const DATASET_IDS = DATASETS.map((d) => d.id);
259
+
260
+ export function getDataset(id: string): DatasetDef {
261
+ const dataset = DATASETS.find((d) => d.id === id);
262
+ if (!dataset) {
263
+ throw new Error(`Unknown dataset "${id}". Available datasets: ${DATASET_IDS.join(", ")}.`);
264
+ }
265
+ return dataset;
266
+ }
267
+
268
+ export interface CatalogCaveat {
269
+ scope: string;
270
+ caveat: string;
271
+ }
272
+
273
+ export const CATALOG_CAVEATS: CatalogCaveat[] = [
274
+ {
275
+ scope: "ua_spend",
276
+ caveat:
277
+ "breakdown_type variants (CAMPAIGN / CAMPAIGN_GEO / CAMPAIGN_CHANNEL) each contain the full spend. The builder pins one variant per query; never aggregate this table without that pin.",
278
+ },
279
+ {
280
+ scope: "cohort",
281
+ caveat:
282
+ "installed_user_count (cohort size) is NULL for install dates before 2026-08-01. LTV/retention per install return NULL for those cohorts instead of a fabricated denominator.",
283
+ },
284
+ {
285
+ scope: "cohort",
286
+ caveat:
287
+ "appsflyer_install_cohort_daily is a rolling activity window: event_date only covers recent days. Cohorts installed before the window's start have incomplete horizon revenue, so fpa_cohort returns NULL horizon metrics for them.",
288
+ },
289
+ {
290
+ scope: "coverage",
291
+ caveat:
292
+ "Date coverage differs per dataset. Always read the coverage block returned by fpa_data_catalog and the date_min/date_max columns in query results before comparing datasets.",
293
+ },
294
+ {
295
+ scope: "currency",
296
+ caveat: "All monetary values are USD.",
297
+ },
298
+ {
299
+ scope: "computation",
300
+ caveat:
301
+ "All arithmetic (aggregation, ratios, deltas) happens in SQL or in extension code. Never compute metrics manually from partial rows; re-query with the right grouping or use fpa_calc.",
302
+ },
303
+ ];