@viccydev/pi-fpa 0.9.5 → 0.9.7

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.
@@ -0,0 +1,296 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+
4
+ import { getDataset, type DatasetDef } from "./registry.ts";
5
+ import type { QuerySpec } from "./sql.ts";
6
+
7
+ // ============================================================================
8
+ // A CSV-backed executor for the same QuerySpec the SQL builder compiles.
9
+ //
10
+ // The registry, the query spec, the derived-metric engine, and the query
11
+ // receipt are all shared with the Supabase path — only the execution differs.
12
+ // That is the point: when the real finance import lands, this file is the only
13
+ // thing that gets replaced, and every metric definition and every projector
14
+ // keeps working untouched.
15
+ //
16
+ // The aggregation deliberately matches SQL, including the part people get
17
+ // wrong: SUM over a set where every contributing value is NULL is NULL, not 0.
18
+ // An FP&A dashboard that reports 0 for "we have no data" is worse than one
19
+ // that reports nothing.
20
+ // ============================================================================
21
+
22
+ const MAX_FILE_BYTES = 32 * 1024 * 1024;
23
+ const MAX_ROWS = 500_000;
24
+
25
+ export const CSV_ROOT_ENV = "FPA_FINANCE_CSV_ROOT";
26
+
27
+ /** Where a project's finance CSVs live, beside the projects like `.fpa-dashboard`. */
28
+ export const FINANCE_DIR_NAME = ".fpa-finance";
29
+
30
+ /**
31
+ * Resolve the finance CSV directory for one project.
32
+ *
33
+ * Defaults to the workspace-level `.fpa-finance/`, so a tenant reads only its
34
+ * own financials. The env override exists for a genuinely shared company
35
+ * source, and is explicit precisely because pointing every tenant at one
36
+ * directory is a data-leak shaped decision that should never be the default.
37
+ */
38
+ export function resolveCsvRoot(env: NodeJS.ProcessEnv = process.env, workspaceRoot?: string): string {
39
+ const configured = env[CSV_ROOT_ENV]?.trim();
40
+ if (configured) {
41
+ if (!isAbsolute(configured)) throw new Error(`${CSV_ROOT_ENV} must be an absolute path.`);
42
+ return resolve(configured);
43
+ }
44
+ if (workspaceRoot) return join(resolve(workspaceRoot), FINANCE_DIR_NAME);
45
+ throw new Error(
46
+ `Finance CSV datasets need either a project directory or ${CSV_ROOT_ENV} pointing at the directory holding them. ` +
47
+ "No query was run.",
48
+ );
49
+ }
50
+
51
+ /** Parse one CSV line, honouring quoted fields and doubled quotes. */
52
+ function parseLine(line: string): string[] {
53
+ const cells: string[] = [];
54
+ let current = "";
55
+ let quoted = false;
56
+ for (let index = 0; index < line.length; index += 1) {
57
+ const char = line[index];
58
+ if (quoted) {
59
+ if (char === '"' && line[index + 1] === '"') {
60
+ current += '"';
61
+ index += 1;
62
+ } else if (char === '"') {
63
+ quoted = false;
64
+ } else {
65
+ current += char;
66
+ }
67
+ } else if (char === '"') {
68
+ quoted = true;
69
+ } else if (char === ",") {
70
+ cells.push(current);
71
+ current = "";
72
+ } else {
73
+ current += char;
74
+ }
75
+ }
76
+ cells.push(current);
77
+ return cells;
78
+ }
79
+
80
+ export interface CsvTable {
81
+ headers: string[];
82
+ rows: Array<Record<string, string>>;
83
+ }
84
+
85
+ export async function readCsvTable(path: string): Promise<CsvTable> {
86
+ let raw: string;
87
+ try {
88
+ raw = await readFile(path, "utf8");
89
+ } catch (error) {
90
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
91
+ throw new Error(`Finance CSV file does not exist: ${path}`);
92
+ }
93
+ throw error;
94
+ }
95
+ if (Buffer.byteLength(raw, "utf8") > MAX_FILE_BYTES) {
96
+ throw new Error(`Finance CSV file exceeds the ${MAX_FILE_BYTES / (1024 * 1024)}MB limit: ${path}`);
97
+ }
98
+ const lines = raw.split("\n").filter((line, index) => index === 0 || line.trim() !== "");
99
+ if (lines.length === 0) throw new Error(`Finance CSV file is empty: ${path}`);
100
+ const headers = parseLine(lines[0]).map((header) => header.trim());
101
+ const rows: Array<Record<string, string>> = [];
102
+ for (const line of lines.slice(1)) {
103
+ if (rows.length >= MAX_ROWS) throw new Error(`Finance CSV file exceeds the ${MAX_ROWS}-row limit: ${path}`);
104
+ const cells = parseLine(line);
105
+ const row: Record<string, string> = {};
106
+ headers.forEach((header, index) => { row[header] = cells[index] ?? ""; });
107
+ rows.push(row);
108
+ }
109
+ return { headers, rows };
110
+ }
111
+
112
+ /** An empty cell is missing, not zero. */
113
+ function cellValue(raw: string | undefined): number | null {
114
+ if (raw === undefined || raw.trim() === "") return null;
115
+ const parsed = Number(raw);
116
+ return Number.isFinite(parsed) ? parsed : null;
117
+ }
118
+
119
+ /**
120
+ * The row's date, normalised so a YYYY-MM column compares against the
121
+ * YYYY-MM-DD bounds the query spec carries.
122
+ */
123
+ function rowDate(value: string | undefined, format: "month" | "date"): string | null {
124
+ if (!value || value.trim() === "") return null;
125
+ const text = value.trim();
126
+ return format === "month" ? `${text}-01` : text;
127
+ }
128
+
129
+ interface Accumulator {
130
+ sum: number;
131
+ count: number;
132
+ /** Null until a non-null value arrives, which is what keeps all-NULL null. */
133
+ seen: boolean;
134
+ min: number;
135
+ max: number;
136
+ last: number | null;
137
+ }
138
+
139
+ function aggregate(accumulator: Accumulator, fn: "sum" | "avg" | "min" | "max" | "last"): number | null {
140
+ if (!accumulator.seen) return null;
141
+ switch (fn) {
142
+ case "sum": return accumulator.sum;
143
+ case "avg": return accumulator.count === 0 ? null : accumulator.sum / accumulator.count;
144
+ case "min": return accumulator.min;
145
+ case "max": return accumulator.max;
146
+ case "last": return accumulator.last;
147
+ }
148
+ }
149
+
150
+ export interface CsvQueryResult {
151
+ rows: Array<Record<string, unknown>>;
152
+ dataset: DatasetDef;
153
+ measures: string[];
154
+ dimensions: string[];
155
+ notes: string[];
156
+ }
157
+
158
+ export async function runCsvQuery(
159
+ spec: QuerySpec,
160
+ env: NodeJS.ProcessEnv = process.env,
161
+ workspaceRoot?: string,
162
+ ): Promise<CsvQueryResult> {
163
+ const dataset = getDataset(spec.dataset);
164
+ if (dataset.source !== "csv" || !dataset.csv) throw new Error(`Dataset "${dataset.id}" is not CSV-backed.`);
165
+ for (const unsupported of ["exactUaScope", "exactUaPortfolioScope", "exactUaChannelScope"] as const) {
166
+ if (spec[unsupported] !== undefined) throw new Error(`${unsupported} is supported only for ua_spend.`);
167
+ }
168
+
169
+ const notes: string[] = [];
170
+ const dimensionNames = spec.dimensions ?? [];
171
+ const dimensionDefs = dimensionNames.map((name) => {
172
+ const def = dataset.dimensions.find((dimension) => dimension.name === name);
173
+ if (!def) {
174
+ throw new Error(
175
+ `Unknown dimension "${name}" for dataset "${dataset.id}". ` +
176
+ `Available: ${dataset.dimensions.map((dimension) => dimension.name).join(", ")}.`,
177
+ );
178
+ }
179
+ return def;
180
+ });
181
+
182
+ // Resolve metrics against the same registry rules the SQL path uses:
183
+ // a derived metric pulls in its numerator and denominator.
184
+ const baseNames = new Set(dataset.measures.map((measure) => measure.name));
185
+ const derivedByName = new Map(dataset.derived.map((derived) => [derived.name, derived]));
186
+ const selected = new Set<string>();
187
+ const derived: DatasetDef["derived"] = [];
188
+ for (const metric of spec.metrics) {
189
+ if (baseNames.has(metric)) {
190
+ selected.add(metric);
191
+ } else if (derivedByName.has(metric)) {
192
+ const def = derivedByName.get(metric)!;
193
+ derived.push(def);
194
+ selected.add(def.numerator);
195
+ selected.add(def.denominator);
196
+ } else {
197
+ throw new Error(
198
+ `Unknown metric "${metric}" for dataset "${dataset.id}". ` +
199
+ `Available metrics: ${[...baseNames, ...derivedByName.keys()].join(", ")}.`,
200
+ );
201
+ }
202
+ }
203
+ if (selected.size === 0) throw new Error("At least one metric is required.");
204
+
205
+ const table = await readCsvTable(join(resolveCsvRoot(env, workspaceRoot), dataset.csv.file));
206
+ const dateFormat = dataset.csv.dateFormat ?? "date";
207
+
208
+ const filters: Array<{ column: string; values: Set<string> }> = [];
209
+ for (const [key, raw] of Object.entries(spec.filters ?? {})) {
210
+ const def = dataset.dimensions.find((dimension) => dimension.name === key);
211
+ if (!def) {
212
+ throw new Error(
213
+ `Unknown filter field "${key}" for dataset "${dataset.id}". ` +
214
+ `Filterable fields: ${dataset.dimensions.map((dimension) => dimension.name).join(", ")}. ` +
215
+ "Use dateFrom/dateTo for the date range.",
216
+ );
217
+ }
218
+ const values = Array.isArray(raw) ? raw : [raw];
219
+ if (values.length === 0) throw new Error("Filter arrays must contain at least one value.");
220
+ filters.push({ column: def.sql, values: new Set(values.map(String)) });
221
+ }
222
+
223
+ const groups = new Map<string, { key: Record<string, string>; accumulators: Map<string, Accumulator>; sourceRows: number }>();
224
+ for (const row of table.rows) {
225
+ const date = rowDate(row[dataset.dateColumn], dateFormat);
226
+ if (spec.dateFrom && (date === null || date < spec.dateFrom)) continue;
227
+ if (spec.dateTo && (date === null || date > spec.dateTo)) continue;
228
+ if (filters.some((filter) => !filter.values.has(row[filter.column] ?? ""))) continue;
229
+
230
+ const key = Object.fromEntries(dimensionDefs.map((def) => [def.name, row[def.sql] ?? ""]));
231
+ const groupKey = dimensionDefs.map((def) => row[def.sql] ?? "").join("");
232
+ let group = groups.get(groupKey);
233
+ if (!group) {
234
+ group = { key, accumulators: new Map(), sourceRows: 0 };
235
+ groups.set(groupKey, group);
236
+ }
237
+ group.sourceRows += 1;
238
+ for (const measure of selected) {
239
+ const column = dataset.measures.find((entry) => entry.name === measure)!.sql;
240
+ const value = cellValue(row[column]);
241
+ let accumulator = group.accumulators.get(measure);
242
+ if (!accumulator) {
243
+ accumulator = { sum: 0, count: 0, seen: false, min: Infinity, max: -Infinity, last: null };
244
+ group.accumulators.set(measure, accumulator);
245
+ }
246
+ // A NULL contributes nothing and does not make the group non-null.
247
+ if (value === null) continue;
248
+ accumulator.seen = true;
249
+ accumulator.sum += value;
250
+ accumulator.count += 1;
251
+ accumulator.min = Math.min(accumulator.min, value);
252
+ accumulator.max = Math.max(accumulator.max, value);
253
+ accumulator.last = value;
254
+ }
255
+ }
256
+
257
+ let rows = [...groups.values()].map((group) => {
258
+ const out: Record<string, unknown> = { ...group.key, source_rows: group.sourceRows };
259
+ for (const measure of selected) {
260
+ const fn = dataset.csv!.aggregates[measure] ?? "sum";
261
+ const accumulator = group.accumulators.get(measure);
262
+ out[measure] = accumulator ? aggregate(accumulator, fn) : null;
263
+ }
264
+ return out;
265
+ });
266
+
267
+ if (spec.sort) {
268
+ const { by, direction = "desc" } = spec.sort;
269
+ if (!selected.has(by) && !dimensionNames.includes(by) && !derived.some((def) => def.name === by)) {
270
+ throw new Error(`Cannot sort by "${by}": it is not a selected metric or dimension.`);
271
+ }
272
+ rows.sort((left, right) => {
273
+ const a = left[by];
274
+ const b = right[by];
275
+ // Nulls sort last in both directions: a missing value is not the
276
+ // smallest value, it is an absent one.
277
+ if (a === null && b === null) return 0;
278
+ if (a === null) return 1;
279
+ if (b === null) return -1;
280
+ const comparison = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b));
281
+ return direction === "asc" ? comparison : -comparison;
282
+ });
283
+ } else if (dimensionNames.length > 0) {
284
+ rows.sort((left, right) => dimensionNames
285
+ .map((name) => String(left[name] ?? "").localeCompare(String(right[name] ?? "")))
286
+ .find((value) => value !== 0) ?? 0);
287
+ }
288
+
289
+ const limit = spec.limit ?? 200;
290
+ if (rows.length > limit) {
291
+ notes.push(`Returned the first ${limit} of ${rows.length} groups.`);
292
+ rows = rows.slice(0, limit);
293
+ }
294
+
295
+ return { rows, dataset, measures: [...selected], dimensions: dimensionNames, notes };
296
+ }
@@ -46,6 +46,15 @@ export interface BreakdownRule {
46
46
  byDimension: Record<string, string>;
47
47
  }
48
48
 
49
+ export interface CsvSourceDef {
50
+ /** File name inside the configured finance CSV root. */
51
+ file: string;
52
+ /** "month" when the date column holds YYYY-MM rather than YYYY-MM-DD. */
53
+ dateFormat?: "month" | "date";
54
+ /** Measure name -> how its column aggregates within a group. */
55
+ aggregates: Record<string, "sum" | "avg" | "min" | "max" | "last">;
56
+ }
57
+
49
58
  export interface DatasetDef {
50
59
  id: string;
51
60
  table: string;
@@ -59,6 +68,14 @@ export interface DatasetDef {
59
68
  eventName?: EventNameExpansion;
60
69
  /** Extra FROM clause fragments (lateral joins) required by the measures. */
61
70
  lateral?: string;
71
+ /**
72
+ * Where the rows come from. Defaults to "sql" so every existing dataset is
73
+ * unchanged. A "csv" dataset shares the same registry, query spec, derived
74
+ * metrics, and query receipt — only the executor differs, so swapping the
75
+ * finance import for a real one later touches nothing above this line.
76
+ */
77
+ source?: "sql" | "csv";
78
+ csv?: CsvSourceDef;
62
79
  }
63
80
 
64
81
  const AF_METRIC_FIELDS: Record<string, string> = {
@@ -255,6 +272,313 @@ export const DATASETS: DatasetDef[] = [
255
272
  measures: { event_count: "sum((e.v)::numeric)" },
256
273
  },
257
274
  },
275
+
276
+ // ------------------------------------------------------------------
277
+ // Finance datasets, currently CSV-backed.
278
+ //
279
+ // The UA mart answers "what did we spend and earn on advertising". These
280
+ // answer "what does the business look like" — cash, cost structure, unit
281
+ // economics. Declared here rather than in a separate registry so the same
282
+ // vetted-names rule holds: the model picks a name, code does the
283
+ // arithmetic, and swapping CSV for a real import changes only the executor.
284
+ // ------------------------------------------------------------------
285
+ {
286
+ id: "pnl_monthly",
287
+ table: "pnl_monthly.csv",
288
+ dateColumn: "month",
289
+ description: "Portfolio monthly P&L bridge: billings through to net free cash flow",
290
+ source: "csv",
291
+ csv: {
292
+ file: "pnl_monthly.csv",
293
+ dateFormat: "month",
294
+ aggregates: {
295
+ gross_billings: "sum", refunds: "sum", platform_fee: "sum", net_proceeds: "sum",
296
+ ua_spend: "sum", contribution_margin: "sum", tax: "sum", opex_total: "sum", net_fcf: "sum",
297
+ },
298
+ },
299
+ dimensions: [
300
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
301
+ { name: "close_status", sql: "close_status", description: "closed or open — an open month has no final tax, opex, or FCF" },
302
+ { name: "data_completeness", sql: "data_completeness", description: "complete, or partial when a source app row was missing" },
303
+ ],
304
+ measures: [
305
+ { name: "gross_billings", sql: "gross_billings", description: "Store gross billings", unit: "usd" },
306
+ { name: "refunds", sql: "refunds", description: "Refunds", unit: "usd" },
307
+ { name: "platform_fee", sql: "platform_fee", description: "Store commission", unit: "usd" },
308
+ { name: "net_proceeds", sql: "net_proceeds", description: "Net proceeds after refunds and commission", unit: "usd" },
309
+ { name: "ua_spend", sql: "ua_spend", description: "UA spend", unit: "usd" },
310
+ { name: "contribution_margin", sql: "contribution_margin", description: "Net proceeds less UA spend", unit: "usd" },
311
+ { name: "tax", sql: "tax", description: "Tax", unit: "usd" },
312
+ { name: "opex_total", sql: "opex_total", description: "Operating expenses (company level, not attributable to an app)", unit: "usd" },
313
+ { name: "net_fcf", sql: "net_fcf", description: "Net free cash flow", unit: "usd" },
314
+ ],
315
+ derived: [
316
+ { name: "fcf_margin", description: "net_fcf / net_proceeds", numerator: "net_fcf", denominator: "net_proceeds", unit: "ratio" },
317
+ { name: "roas_blended", description: "net_proceeds / ua_spend", numerator: "net_proceeds", denominator: "ua_spend", unit: "ratio" },
318
+ ],
319
+ notes: [
320
+ "An open month has NULL tax, opex_total, and net_fcf — the books are not closed, so those figures do not exist yet.",
321
+ "data_completeness=partial means a source app row was missing and the portfolio total is understated; report it as incomplete rather than as a clean aggregate.",
322
+ ],
323
+ },
324
+ {
325
+ id: "pnl_by_app_monthly",
326
+ table: "pnl_by_app_monthly.csv",
327
+ dateColumn: "month",
328
+ description: "Per-app monthly contribution: billings through to contribution margin",
329
+ source: "csv",
330
+ csv: {
331
+ file: "pnl_by_app_monthly.csv",
332
+ dateFormat: "month",
333
+ aggregates: {
334
+ gross_billings: "sum", refunds: "sum", platform_fee: "sum",
335
+ net_proceeds: "sum", ua_spend: "sum", contribution_margin: "sum",
336
+ },
337
+ },
338
+ dimensions: [
339
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
340
+ { name: "app_id", sql: "app_id", description: "Internal app code, joins to ua_spend.app_code" },
341
+ { name: "app_name", sql: "app_name", description: "Product name" },
342
+ { name: "store", sql: "store", description: "ios or android" },
343
+ { name: "close_status", sql: "close_status", description: "closed or open" },
344
+ ],
345
+ measures: [
346
+ { name: "gross_billings", sql: "gross_billings", description: "Store gross billings", unit: "usd" },
347
+ { name: "refunds", sql: "refunds", description: "Refunds", unit: "usd" },
348
+ { name: "platform_fee", sql: "platform_fee", description: "Store commission", unit: "usd" },
349
+ { name: "net_proceeds", sql: "net_proceeds", description: "Net proceeds", unit: "usd" },
350
+ { name: "ua_spend", sql: "ua_spend", description: "UA spend", unit: "usd" },
351
+ { name: "contribution_margin", sql: "contribution_margin", description: "Net proceeds less UA spend", unit: "usd" },
352
+ ],
353
+ derived: [
354
+ { name: "contribution_rate", description: "contribution_margin / net_proceeds", numerator: "contribution_margin", denominator: "net_proceeds", unit: "ratio" },
355
+ { name: "app_roas", description: "net_proceeds / ua_spend", numerator: "net_proceeds", denominator: "ua_spend", unit: "ratio" },
356
+ ],
357
+ notes: [
358
+ "Stops at contribution_margin on purpose: opex is a company-level cost and allocating it per app would manufacture precision that does not exist. Only the portfolio has net_fcf.",
359
+ ],
360
+ },
361
+ {
362
+ id: "opex_monthly",
363
+ table: "opex_monthly.csv",
364
+ dateColumn: "month",
365
+ description: "Operating expense by category",
366
+ source: "csv",
367
+ csv: { file: "opex_monthly.csv", dateFormat: "month", aggregates: { amount_usd: "sum", headcount: "last" } },
368
+ dimensions: [
369
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
370
+ { name: "category", sql: "category", description: "personnel, infrastructure, third_party_services, marketing_non_ua, general_admin" },
371
+ { name: "close_status", sql: "close_status", description: "closed or open" },
372
+ ],
373
+ measures: [
374
+ { name: "amount_usd", sql: "amount_usd", description: "Expense amount", unit: "usd" },
375
+ { name: "headcount", sql: "headcount", description: "Headcount (personnel rows only)", unit: "count" },
376
+ ],
377
+ derived: [],
378
+ notes: ["Categories sum to pnl_monthly.opex_total for the same closed month."],
379
+ },
380
+ {
381
+ id: "cash_position_monthly",
382
+ table: "cash_position_monthly.csv",
383
+ dateColumn: "month",
384
+ description: "Cash roll-forward and runway",
385
+ source: "csv",
386
+ csv: {
387
+ file: "cash_position_monthly.csv",
388
+ dateFormat: "month",
389
+ aggregates: {
390
+ opening_cash: "last", net_fcf: "sum", financing_inflow: "sum",
391
+ other_movements: "sum", closing_cash: "last", monthly_fixed_outflow: "last", runway_months: "last",
392
+ },
393
+ },
394
+ dimensions: [
395
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
396
+ { name: "close_status", sql: "close_status", description: "closed or open" },
397
+ ],
398
+ measures: [
399
+ { name: "opening_cash", sql: "opening_cash", description: "Opening cash", unit: "usd" },
400
+ { name: "net_fcf", sql: "net_fcf", description: "Net free cash flow", unit: "usd" },
401
+ { name: "financing_inflow", sql: "financing_inflow", description: "Financing inflow", unit: "usd" },
402
+ { name: "other_movements", sql: "other_movements", description: "Other cash movements", unit: "usd" },
403
+ { name: "closing_cash", sql: "closing_cash", description: "Closing cash", unit: "usd" },
404
+ { name: "monthly_fixed_outflow", sql: "monthly_fixed_outflow", description: "Opex plus UA spend", unit: "usd" },
405
+ { name: "runway_months", sql: "runway_months", description: "Closing cash divided by monthly fixed outflow", unit: "ratio" },
406
+ ],
407
+ derived: [],
408
+ notes: [
409
+ "runway_months is cash over fixed outflow, not over net burn: burn-based runway is NULL or infinite while FCF is positive, which tells the reader nothing.",
410
+ "Balances are point-in-time. Aggregating several months returns the last month's balance, never a sum.",
411
+ ],
412
+ },
413
+ {
414
+ id: "cohort_ltv",
415
+ table: "cohort_ltv.csv",
416
+ dateColumn: "install_month",
417
+ description: "Install cohorts by app, store, and channel: CAC, revenue curve, LTV, payback",
418
+ source: "csv",
419
+ csv: {
420
+ file: "cohort_ltv.csv",
421
+ dateFormat: "month",
422
+ aggregates: {
423
+ installs: "sum", ua_spend: "sum", cac: "avg",
424
+ revenue_d0: "sum", revenue_d7: "sum", revenue_d30: "sum", revenue_d90: "sum", revenue_d180: "sum",
425
+ ltv_180: "avg", payback_days: "avg", roas_d180: "avg",
426
+ },
427
+ },
428
+ dimensions: [
429
+ { name: "install_month", sql: "install_month", description: "Install cohort month (YYYY-MM)" },
430
+ { name: "app_id", sql: "app_id", description: "Internal app code" },
431
+ { name: "store", sql: "store", description: "ios or android" },
432
+ { name: "channel_group", sql: "channel_group", description: "Channel, same values as ua_spend.media_source" },
433
+ { name: "maturity", sql: "maturity", description: "mature, partial, or immature" },
434
+ ],
435
+ measures: [
436
+ { name: "installs", sql: "installs", description: "Attributed installs", unit: "count" },
437
+ { name: "ua_spend", sql: "ua_spend", description: "Cohort UA spend", unit: "usd" },
438
+ { name: "cac", sql: "cac", description: "Cost per acquisition", unit: "usd" },
439
+ { name: "revenue_d0", sql: "revenue_d0", description: "Cumulative revenue at day 0", unit: "usd" },
440
+ { name: "revenue_d7", sql: "revenue_d7", description: "Cumulative revenue at day 7", unit: "usd" },
441
+ { name: "revenue_d30", sql: "revenue_d30", description: "Cumulative revenue at day 30", unit: "usd" },
442
+ { name: "revenue_d90", sql: "revenue_d90", description: "Cumulative revenue at day 90", unit: "usd" },
443
+ { name: "revenue_d180", sql: "revenue_d180", description: "Cumulative revenue at day 180", unit: "usd" },
444
+ { name: "ltv_180", sql: "ltv_180", description: "180-day LTV per install", unit: "usd" },
445
+ { name: "payback_days", sql: "payback_days", description: "Days to recover CAC", unit: "count" },
446
+ { name: "roas_d180", sql: "roas_d180", description: "180-day ROAS", unit: "ratio" },
447
+ ],
448
+ derived: [
449
+ { name: "ltv_to_cac", description: "ltv_180 / cac", numerator: "ltv_180", denominator: "cac", unit: "ratio" },
450
+ ],
451
+ notes: [
452
+ "Immature cohorts carry NULL for the horizons they have not reached. A zero there would read as a failed cohort and get cut.",
453
+ ],
454
+ },
455
+ {
456
+ id: "subscription_metrics",
457
+ table: "subscription_metrics.csv",
458
+ dateColumn: "month",
459
+ description: "Subscription counts, renewal rates by month of tenure, and refund rate",
460
+ source: "csv",
461
+ csv: {
462
+ file: "subscription_metrics.csv",
463
+ dateFormat: "month",
464
+ aggregates: {
465
+ new_subscriptions: "sum", active_subscriptions: "sum",
466
+ renewal_rate_m1: "avg", renewal_rate_m2: "avg", renewal_rate_m3: "avg", refund_rate: "avg",
467
+ },
468
+ },
469
+ dimensions: [
470
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
471
+ { name: "app_id", sql: "app_id", description: "Internal app code" },
472
+ { name: "store", sql: "store", description: "ios or android" },
473
+ { name: "close_status", sql: "close_status", description: "closed or open" },
474
+ ],
475
+ measures: [
476
+ { name: "new_subscriptions", sql: "new_subscriptions", description: "New subscriptions", unit: "count" },
477
+ { name: "active_subscriptions", sql: "active_subscriptions", description: "Active subscriptions", unit: "count" },
478
+ { name: "renewal_rate_m1", sql: "renewal_rate_m1", description: "Month-1 renewal rate", unit: "ratio" },
479
+ { name: "renewal_rate_m2", sql: "renewal_rate_m2", description: "Month-2 renewal rate", unit: "ratio" },
480
+ { name: "renewal_rate_m3", sql: "renewal_rate_m3", description: "Month-3 renewal rate", unit: "ratio" },
481
+ { name: "refund_rate", sql: "refund_rate", description: "Refund rate", unit: "ratio" },
482
+ ],
483
+ derived: [],
484
+ notes: [
485
+ "Renewal rates are averaged across the grouped rows, never summed — a rate has no meaningful sum.",
486
+ ],
487
+ },
488
+ {
489
+ id: "forecast_vs_actual",
490
+ table: "forecast_vs_actual.csv",
491
+ dateColumn: "month",
492
+ description: "Forecast against actual by month, metric, and forecast vintage",
493
+ source: "csv",
494
+ csv: {
495
+ file: "forecast_vs_actual.csv",
496
+ dateFormat: "month",
497
+ aggregates: {
498
+ forecast_value: "sum", actual_value: "sum", variance: "sum",
499
+ variance_pct: "avg", abs_pct_error: "avg",
500
+ },
501
+ },
502
+ dimensions: [
503
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
504
+ { name: "metric", sql: "metric", description: "Which figure was forecast" },
505
+ { name: "forecast_age_months", sql: "forecast_age_months", description: "How long before the month the forecast was made" },
506
+ { name: "forecast_version", sql: "forecast_version", description: "Forecast identity" },
507
+ ],
508
+ measures: [
509
+ { name: "forecast_value", sql: "forecast_value", description: "Forecast figure", unit: "usd" },
510
+ { name: "actual_value", sql: "actual_value", description: "Actual figure", unit: "usd" },
511
+ { name: "variance", sql: "variance", description: "Actual less forecast", unit: "usd" },
512
+ { name: "variance_pct", sql: "variance_pct", description: "Variance over forecast", unit: "ratio" },
513
+ { name: "abs_pct_error", sql: "abs_pct_error", description: "Absolute percentage error", unit: "ratio" },
514
+ ],
515
+ derived: [],
516
+ notes: [
517
+ "Compare like vintages: a 1-month-ahead forecast and a 3-month-ahead one are different claims about the same month.",
518
+ "abs_pct_error averages into MAPE; it must never be summed.",
519
+ ],
520
+ },
521
+ {
522
+ id: "variance_attribution",
523
+ table: "variance_attribution.csv",
524
+ dateColumn: "month",
525
+ description: "Named causes a month's variance decomposes into",
526
+ source: "csv",
527
+ csv: {
528
+ file: "variance_attribution.csv",
529
+ dateFormat: "month",
530
+ aggregates: { impact_usd: "sum", impact_share: "sum", confidence: "avg" },
531
+ },
532
+ dimensions: [
533
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
534
+ { name: "metric", sql: "metric", description: "Which figure the variance belongs to" },
535
+ { name: "factor", sql: "factor", description: "The named cause" },
536
+ { name: "category", sql: "category", description: "Cause category; 'unexplained' is the residual" },
537
+ { name: "confidence_band", sql: "confidence_band", description: "high, medium, or low" },
538
+ ],
539
+ measures: [
540
+ { name: "impact_usd", sql: "impact_usd", description: "Signed contribution to the variance", unit: "usd" },
541
+ { name: "impact_share", sql: "impact_share", description: "Share of the total variance", unit: "ratio" },
542
+ { name: "confidence", sql: "confidence", description: "Attribution confidence", unit: "ratio" },
543
+ ],
544
+ derived: [],
545
+ notes: [
546
+ "Factors sum to the month's variance. A category of 'unexplained' is the part no factor accounts for — report it rather than dropping it, or the decomposition will read as complete when it is not.",
547
+ ],
548
+ },
549
+ {
550
+ id: "baseline_assumptions",
551
+ table: "baseline_assumptions.csv",
552
+ dateColumn: "detected_at",
553
+ description: "Model baseline assumptions and the corrections detected against them",
554
+ source: "csv",
555
+ csv: {
556
+ file: "baseline_assumptions.csv",
557
+ dateFormat: "date",
558
+ aggregates: { current_value: "last", suggested_value: "last", estimated_impact_usd_12m: "sum" },
559
+ },
560
+ dimensions: [
561
+ { name: "assumption_id", sql: "assumption_id", description: "Assumption identity" },
562
+ { name: "assumption_name", sql: "assumption_name", description: "Human-readable name" },
563
+ { name: "scope", sql: "scope", description: "What the assumption applies to" },
564
+ { name: "unit", sql: "unit", description: "Value unit" },
565
+ { name: "detection_basis", sql: "detection_basis", description: "Why a correction was proposed" },
566
+ { name: "affected_models", sql: "affected_models", description: "Models the change would move" },
567
+ { name: "approval_status", sql: "approval_status", description: "current, pending, or approved" },
568
+ { name: "approver_role", sql: "approver_role", description: "Who must approve a change" },
569
+ { name: "detected_at", sql: "detected_at", description: "When the drift was detected" },
570
+ { name: "effective_from", sql: "effective_from", description: "When an approved change takes effect" },
571
+ ],
572
+ measures: [
573
+ { name: "current_value", sql: "current_value", description: "Baseline in force", unit: "ratio" },
574
+ { name: "suggested_value", sql: "suggested_value", description: "Proposed baseline; null when none is proposed", unit: "ratio" },
575
+ { name: "estimated_impact_usd_12m", sql: "estimated_impact_usd_12m", description: "Estimated 12-month impact of adopting the proposal", unit: "usd" },
576
+ ],
577
+ derived: [],
578
+ notes: [
579
+ "A pending correction is a proposal, not a fact. It must never be applied to a projection before its approver has accepted it.",
580
+ ],
581
+ },
258
582
  ];
259
583
 
260
584
  export const DATASET_IDS = DATASETS.map((d) => d.id);
@@ -1,4 +1,6 @@
1
1
  import { computeDerived, round, toNumber, type NumericLike } from "./calc.ts";
2
+ import { runCsvQuery } from "./csvsource.ts";
3
+ import { getDataset } from "./registry.ts";
2
4
  import { buildQuery, type QuerySpec } from "./sql.ts";
3
5
  import { runQuery, type SqlRow } from "./supabase.ts";
4
6
 
@@ -15,7 +17,25 @@ export function shapeQueryRows(
15
17
  });
16
18
  }
17
19
 
18
- export async function runStructuredQuery(spec: QuerySpec, signal?: AbortSignal) {
20
+ export async function runStructuredQuery(spec: QuerySpec, signal?: AbortSignal, workspaceRoot?: string) {
21
+ // Route on the dataset's declared source. Everything after this line —
22
+ // rounding, derived metrics, null propagation — is identical either way, so
23
+ // a metric means the same thing regardless of where its rows came from.
24
+ if (getDataset(spec.dataset).source === "csv") {
25
+ const result = await runCsvQuery(spec, process.env, workspaceRoot);
26
+ const derived = result.dataset.derived.filter((def) => spec.metrics.includes(def.name));
27
+ return {
28
+ built: {
29
+ sql: `-- csv:${result.dataset.csv?.file ?? result.dataset.id}`,
30
+ dataset: result.dataset,
31
+ measures: result.measures,
32
+ derived,
33
+ dimensions: result.dimensions,
34
+ notes: [...result.dataset.notes, ...result.notes],
35
+ } as ReturnType<typeof buildQuery>,
36
+ rows: shapeQueryRows(result.rows, result.measures, derived),
37
+ };
38
+ }
19
39
  const built = buildQuery(spec);
20
40
  const rows = await runQuery(built.sql, { signal });
21
41
  return { built, rows: shapeQueryRows(rows, built.measures, built.derived) };