@viccydev/pi-fpa 0.2.1 → 0.3.1

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,115 @@
1
+ export const DASHBOARD_WIDGET_TYPES = ["stat", "timeseries", "table", "grouped-table"] as const;
2
+ export type DashboardWidgetType = (typeof DASHBOARD_WIDGET_TYPES)[number];
3
+
4
+ const TONES = new Set(["positive", "negative", "warning", "neutral"]);
5
+
6
+ function record(value: unknown, path: string): Record<string, unknown> {
7
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object.`);
8
+ return value as Record<string, unknown>;
9
+ }
10
+
11
+ function string(value: unknown, path: string): string {
12
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
13
+ return value;
14
+ }
15
+
16
+ function optionalString(value: unknown, path: string): void {
17
+ if (value !== undefined) string(value, path);
18
+ }
19
+
20
+ function tone(value: unknown, path: string): void {
21
+ if (value !== undefined && (typeof value !== "string" || !TONES.has(value))) throw new Error(`${path} has an unsupported tone.`);
22
+ }
23
+
24
+ function cell(value: unknown, path: string): void {
25
+ if (value === null || typeof value === "string") return;
26
+ const source = record(value, path);
27
+ if (source.value !== null && typeof source.value !== "string") throw new Error(`${path}.value must be a string or null.`);
28
+ tone(source.tone, `${path}.tone`);
29
+ }
30
+
31
+ function columns(value: unknown, path: string): void {
32
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`${path} must be a non-empty array.`);
33
+ for (const [index, raw] of value.entries()) {
34
+ const source = record(raw, `${path}[${index}]`);
35
+ string(source.key, `${path}[${index}].key`);
36
+ string(source.label, `${path}[${index}].label`);
37
+ if (source.align !== undefined && source.align !== "left" && source.align !== "right") throw new Error(`${path}[${index}].align is unsupported.`);
38
+ }
39
+ }
40
+
41
+ function cellsRecord(value: unknown, path: string): void {
42
+ const source = record(value, path);
43
+ for (const [key, value] of Object.entries(source)) cell(value, `${path}.${key}`);
44
+ }
45
+
46
+ function tabularRows(value: unknown, path: string): void {
47
+ if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
48
+ for (const [index, row] of value.entries()) cellsRecord(row, `${path}[${index}]`);
49
+ }
50
+
51
+ function validateStat(source: Record<string, unknown>): void {
52
+ string(source.label, "dataset.label");
53
+ if (source.value !== null && typeof source.value !== "string") throw new Error("dataset.value must be a string or null.");
54
+ optionalString(source.missingReason, "dataset.missingReason");
55
+ optionalString(source.description, "dataset.description");
56
+ optionalString(source.footnote, "dataset.footnote");
57
+ if (source.delta !== undefined) {
58
+ const delta = record(source.delta, "dataset.delta");
59
+ if (delta.direction !== "up" && delta.direction !== "down" && delta.direction !== "flat") throw new Error("dataset.delta.direction is unsupported.");
60
+ string(delta.label, "dataset.delta.label");
61
+ if (delta.sentiment !== undefined && delta.sentiment !== "positive" && delta.sentiment !== "negative" && delta.sentiment !== "neutral") throw new Error("dataset.delta.sentiment is unsupported.");
62
+ }
63
+ if (source.progress !== undefined) {
64
+ const progress = record(source.progress, "dataset.progress");
65
+ if (typeof progress.fraction !== "number" || !Number.isFinite(progress.fraction) || progress.fraction < 0 || progress.fraction > 1) throw new Error("dataset.progress.fraction must be between 0 and 1.");
66
+ string(progress.label, "dataset.progress.label");
67
+ tone(progress.tone, "dataset.progress.tone");
68
+ }
69
+ }
70
+
71
+ function validateTimeseries(source: Record<string, unknown>): void {
72
+ string(source.label, "dataset.label");
73
+ optionalString(source.description, "dataset.description");
74
+ optionalString(source.coverage, "dataset.coverage");
75
+ if (!Array.isArray(source.series) || source.series.length === 0) throw new Error("dataset.series must be a non-empty array.");
76
+ for (const [seriesIndex, rawSeries] of source.series.entries()) {
77
+ const series = record(rawSeries, `dataset.series[${seriesIndex}]`);
78
+ string(series.name, `dataset.series[${seriesIndex}].name`);
79
+ if (!Array.isArray(series.points)) throw new Error(`dataset.series[${seriesIndex}].points must be an array.`);
80
+ for (const [pointIndex, rawPoint] of series.points.entries()) {
81
+ const point = record(rawPoint, `dataset.series[${seriesIndex}].points[${pointIndex}]`);
82
+ string(point.x, `dataset.series[${seriesIndex}].points[${pointIndex}].x`);
83
+ if (point.y !== null && (typeof point.y !== "number" || !Number.isFinite(point.y))) throw new Error(`dataset.series[${seriesIndex}].points[${pointIndex}].y must be finite or null.`);
84
+ }
85
+ }
86
+ }
87
+
88
+ function validateTable(source: Record<string, unknown>): void {
89
+ string(source.label, "dataset.label");
90
+ optionalString(source.description, "dataset.description");
91
+ columns(source.columns, "dataset.columns");
92
+ tabularRows(source.rows, "dataset.rows");
93
+ }
94
+
95
+ function validateGroupedTable(source: Record<string, unknown>): void {
96
+ string(source.label, "dataset.label");
97
+ optionalString(source.description, "dataset.description");
98
+ columns(source.columns, "dataset.columns");
99
+ if (!Array.isArray(source.groups) || source.groups.length === 0) throw new Error("dataset.groups must be a non-empty array.");
100
+ for (const [index, raw] of source.groups.entries()) {
101
+ const group = record(raw, `dataset.groups[${index}]`);
102
+ string(group.key, `dataset.groups[${index}].key`);
103
+ string(group.label, `dataset.groups[${index}].label`);
104
+ cellsRecord(group.summary, `dataset.groups[${index}].summary`);
105
+ tabularRows(group.rows, `dataset.groups[${index}].rows`);
106
+ }
107
+ }
108
+
109
+ export function validateDatasetForWidget(type: DashboardWidgetType, value: unknown): void {
110
+ const source = record(value, "dataset");
111
+ if (type === "stat") validateStat(source);
112
+ else if (type === "timeseries") validateTimeseries(source);
113
+ else if (type === "table") validateTable(source);
114
+ else validateGroupedTable(source);
115
+ }
@@ -0,0 +1,169 @@
1
+ import { sliceKey, type Period } from "../fpa-artifacts/contracts.ts";
2
+
3
+ export interface QueryReceipt {
4
+ dataset: string;
5
+ query_fingerprint: string;
6
+ row_count: number;
7
+ }
8
+
9
+ export interface DashboardActualSlice {
10
+ app_id: string;
11
+ store: string;
12
+ channel_group: string;
13
+ spend: number | null;
14
+ revenue: number | null;
15
+ source_rows: number;
16
+ }
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
+
31
+ export interface DashboardDailyPoint {
32
+ date: string;
33
+ spend: number | null;
34
+ revenue: number | null;
35
+ }
36
+
37
+ export interface DashboardActualsSnapshot {
38
+ period: Period;
39
+ data_as_of: string;
40
+ reporting_currency: string;
41
+ scope_mode: DashboardScopeMode;
42
+ coverage: {
43
+ date_min: string | null;
44
+ date_max: string | null;
45
+ source_rows: number;
46
+ };
47
+ current: { spend: number | null; revenue: number | null };
48
+ comparison: { spend: number | null; revenue: number | null } | null;
49
+ daily: DashboardDailyPoint[];
50
+ slices: DashboardActualSlice[];
51
+ query_receipts: QueryReceipt[];
52
+ }
53
+
54
+ function record(value: unknown, path: string): Record<string, unknown> {
55
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object.`);
56
+ return value as Record<string, unknown>;
57
+ }
58
+
59
+ function string(value: unknown, path: string): string {
60
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
61
+ return value;
62
+ }
63
+
64
+ function numeric(value: unknown, path: string, nullable = false): number | null {
65
+ if (nullable && value === null) return null;
66
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${path} must be a finite number${nullable ? " or null" : ""}.`);
67
+ return value;
68
+ }
69
+
70
+ function count(value: unknown, path: string): number {
71
+ const parsed = numeric(value, path) as number;
72
+ if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${path} must be a non-negative integer.`);
73
+ return parsed;
74
+ }
75
+
76
+ function aggregate(value: unknown, path: string): { spend: number | null; revenue: number | null } {
77
+ const source = record(value, path);
78
+ return {
79
+ spend: numeric(source.spend, `${path}.spend`, true),
80
+ revenue: numeric(source.revenue, `${path}.revenue`, true),
81
+ };
82
+ }
83
+
84
+ function period(value: unknown, path: string): Period {
85
+ const source = record(value, path);
86
+ const result = {
87
+ start_inclusive: string(source.start_inclusive, `${path}.start_inclusive`),
88
+ end_exclusive: string(source.end_exclusive, `${path}.end_exclusive`),
89
+ timezone: string(source.timezone, `${path}.timezone`),
90
+ };
91
+ if (Number.isNaN(Date.parse(result.start_inclusive)) || Number.isNaN(Date.parse(result.end_exclusive))) throw new Error(`${path} timestamps must be valid ISO timestamps.`);
92
+ if (Date.parse(result.start_inclusive) >= Date.parse(result.end_exclusive)) throw new Error(`${path}.end_exclusive must be after start_inclusive.`);
93
+ try {
94
+ new Intl.DateTimeFormat("en-US", { timeZone: result.timezone }).format(new Date(result.start_inclusive));
95
+ } catch {
96
+ throw new Error(`${path}.timezone is not supported.`);
97
+ }
98
+ return result;
99
+ }
100
+
101
+ export function validateActualsSnapshot(value: unknown): DashboardActualsSnapshot {
102
+ const source = record(value, "actuals");
103
+ const coverage = record(source.coverage, "actuals.coverage");
104
+ const dailySource = source.daily;
105
+ const slicesSource = source.slices;
106
+ const receiptsSource = source.query_receipts;
107
+ if (!Array.isArray(dailySource)) throw new Error("actuals.daily must be an array.");
108
+ if (!Array.isArray(slicesSource)) throw new Error("actuals.slices must be an array.");
109
+ if (!Array.isArray(receiptsSource)) throw new Error("actuals.query_receipts must be an array.");
110
+
111
+ const daily = dailySource.map((value, index) => {
112
+ const item = record(value, `actuals.daily[${index}]`);
113
+ return {
114
+ date: string(item.date, `actuals.daily[${index}].date`),
115
+ spend: numeric(item.spend, `actuals.daily[${index}].spend`, true),
116
+ revenue: numeric(item.revenue, `actuals.daily[${index}].revenue`, true),
117
+ };
118
+ });
119
+ const slices = slicesSource.map((value, index) => {
120
+ const item = record(value, `actuals.slices[${index}]`);
121
+ return {
122
+ app_id: string(item.app_id, `actuals.slices[${index}].app_id`),
123
+ store: string(item.store, `actuals.slices[${index}].store`),
124
+ channel_group: string(item.channel_group, `actuals.slices[${index}].channel_group`),
125
+ spend: numeric(item.spend, `actuals.slices[${index}].spend`, true),
126
+ revenue: numeric(item.revenue, `actuals.slices[${index}].revenue`, true),
127
+ source_rows: count(item.source_rows, `actuals.slices[${index}].source_rows`),
128
+ };
129
+ });
130
+ const dailyDates = new Set<string>();
131
+ for (const point of daily) {
132
+ if (dailyDates.has(point.date)) throw new Error(`actuals.daily contains duplicate date ${point.date}.`);
133
+ dailyDates.add(point.date);
134
+ }
135
+ const sliceKeys = new Set<string>();
136
+ for (const item of slices) {
137
+ const key = sliceKey(item);
138
+ if (sliceKeys.has(key)) throw new Error(`actuals.slices contains duplicate slice ${key.replaceAll("\u0000", " / ")}.`);
139
+ sliceKeys.add(key);
140
+ }
141
+
142
+ const reportingCurrency = string(source.reporting_currency, "actuals.reporting_currency").toUpperCase();
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".');
146
+ return {
147
+ period: period(source.period, "actuals.period"),
148
+ data_as_of: string(source.data_as_of, "actuals.data_as_of"),
149
+ reporting_currency: reportingCurrency,
150
+ scope_mode: scopeMode,
151
+ coverage: {
152
+ date_min: coverage.date_min === null ? null : string(coverage.date_min, "actuals.coverage.date_min"),
153
+ date_max: coverage.date_max === null ? null : string(coverage.date_max, "actuals.coverage.date_max"),
154
+ source_rows: count(coverage.source_rows, "actuals.coverage.source_rows"),
155
+ },
156
+ current: aggregate(source.current, "actuals.current"),
157
+ comparison: source.comparison === null ? null : aggregate(source.comparison, "actuals.comparison"),
158
+ daily,
159
+ slices,
160
+ query_receipts: receiptsSource.map((value, index) => {
161
+ const item = record(value, `actuals.query_receipts[${index}]`);
162
+ return {
163
+ dataset: string(item.dataset, `actuals.query_receipts[${index}].dataset`),
164
+ query_fingerprint: string(item.query_fingerprint, `actuals.query_receipts[${index}].query_fingerprint`),
165
+ row_count: count(item.row_count, `actuals.query_receipts[${index}].row_count`),
166
+ };
167
+ }),
168
+ };
169
+ }
@@ -0,0 +1,154 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ import { resolveDashboardDir } from "./publisher.ts";
6
+ import { stableJson } from "../fpa-artifacts/store.ts";
7
+ import { DASHBOARD_WIDGET_TYPES, validateDatasetForWidget, type DashboardWidgetType } from "./schema.ts";
8
+
9
+ const DATASET_FILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\.json$/;
10
+ const GENERATION_RECEIPT_RE = /^build-receipt\.([a-f0-9]{12})\.([a-f0-9]{12})\.json$/;
11
+
12
+ export interface DashboardStatus {
13
+ dashboard_exists: boolean;
14
+ dashboard_dir: string;
15
+ generation_id?: string;
16
+ updated_at?: string;
17
+ widget_count: number;
18
+ diagnostics: Array<{ target: string; message: string }>;
19
+ }
20
+
21
+ async function readJson(path: string, maxBytes: number): Promise<unknown> {
22
+ const stat = await lstat(path);
23
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("must be a regular file, not a symlink");
24
+ if (stat.size > maxBytes) throw new Error(`exceeds the ${Math.round(maxBytes / 1024)}KB limit`);
25
+ return JSON.parse(await readFile(path, "utf8"));
26
+ }
27
+
28
+ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
29
+ const dashboardDir = await resolveDashboardDir(cwd);
30
+ const diagnostics: DashboardStatus["diagnostics"] = [];
31
+ let manifest: unknown;
32
+ try {
33
+ manifest = await readJson(join(dashboardDir, "manifest.json"), 256 * 1024);
34
+ } catch (error) {
35
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
36
+ return { dashboard_exists: false, dashboard_dir: dashboardDir, widget_count: 0, diagnostics };
37
+ }
38
+ return {
39
+ dashboard_exists: false,
40
+ dashboard_dir: dashboardDir,
41
+ widget_count: 0,
42
+ diagnostics: [{ target: "manifest.json", message: error instanceof Error ? error.message : String(error) }],
43
+ };
44
+ }
45
+ if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) {
46
+ return { dashboard_exists: false, dashboard_dir: dashboardDir, widget_count: 0, diagnostics: [{ target: "manifest.json", message: "must contain an object" }] };
47
+ }
48
+ const source = manifest as Record<string, unknown>;
49
+ if (source.kind !== "fpa.dashboard" || source.schemaVersion !== 1 || typeof source.title !== "string" || source.title.trim() === "" || typeof source.updatedAt !== "string" || Number.isNaN(Date.parse(source.updatedAt)) || !Array.isArray(source.widgets) || source.widgets.length === 0 || source.widgets.length > 64) {
50
+ return { dashboard_exists: false, dashboard_dir: dashboardDir, widget_count: 0, diagnostics: [{ target: "manifest.json", message: "unsupported dashboard kind/schema or widgets shape" }] };
51
+ }
52
+ const receiptDatasets = new Map<string, string>();
53
+ const receiptFilename = typeof source.buildReceipt === "string" && DATASET_FILE_RE.test(source.buildReceipt)
54
+ ? source.buildReceipt
55
+ : "build-receipt.json";
56
+ try {
57
+ const receipt = await readJson(join(dashboardDir, receiptFilename), 256 * 1024);
58
+ if (receipt === null || typeof receipt !== "object" || Array.isArray(receipt)) {
59
+ throw new Error("must contain an object");
60
+ }
61
+ const receiptSource = receipt as Record<string, unknown>;
62
+ if (receiptSource.kind !== "fpa.dashboard.build" || receiptSource.schema_version !== 1) {
63
+ throw new Error("has an unsupported build receipt kind or schema");
64
+ }
65
+ if (typeof source.generationId === "string" && receiptSource.generation_id !== source.generationId) {
66
+ diagnostics.push({ target: receiptFilename, message: "generation does not match manifest.json" });
67
+ }
68
+ if (receiptSource.published_at !== source.updatedAt) {
69
+ diagnostics.push({ target: receiptFilename, message: "published_at does not match manifest updatedAt" });
70
+ }
71
+ const receiptNameMatch = receiptFilename.match(GENERATION_RECEIPT_RE);
72
+ if (receiptNameMatch) {
73
+ if (typeof source.generationId !== "string" || receiptNameMatch[1] !== source.generationId.slice(0, 12)) {
74
+ diagnostics.push({ target: receiptFilename, message: "filename generation prefix does not match manifest.json" });
75
+ }
76
+ const receiptDigest = createHash("sha256").update(stableJson(receipt)).digest("hex");
77
+ if (receiptNameMatch[2] !== receiptDigest.slice(0, 12)) {
78
+ diagnostics.push({ target: receiptFilename, message: "receipt content digest does not match its filename" });
79
+ }
80
+ }
81
+ if (!Array.isArray(receiptSource.datasets)) throw new Error("datasets must be an array");
82
+ for (const [index, raw] of receiptSource.datasets.entries()) {
83
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
84
+ diagnostics.push({ target: `${receiptFilename} datasets[${index}]`, message: "must be an object" });
85
+ continue;
86
+ }
87
+ const item = raw as Record<string, unknown>;
88
+ if (typeof item.filename !== "string" || !DATASET_FILE_RE.test(item.filename) || typeof item.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(item.sha256)) {
89
+ diagnostics.push({ target: `${receiptFilename} datasets[${index}]`, message: "has an invalid filename or sha256" });
90
+ continue;
91
+ }
92
+ receiptDatasets.set(item.filename, item.sha256);
93
+ }
94
+ } catch (error) {
95
+ diagnostics.push({ target: receiptFilename, message: error instanceof Error ? error.message : String(error) });
96
+ }
97
+ const widgetIds = new Set<string>();
98
+ for (const [index, raw] of source.widgets.entries()) {
99
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
100
+ diagnostics.push({ target: `widgets[${index}]`, message: "must be an object" });
101
+ continue;
102
+ }
103
+ const widget = raw as Record<string, unknown>;
104
+ const target = typeof widget.id === "string" ? widget.id : `widgets[${index}]`;
105
+ if (typeof widget.id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(widget.id) || widgetIds.has(widget.id)) {
106
+ diagnostics.push({ target, message: "has an invalid or duplicate widget id" });
107
+ continue;
108
+ }
109
+ widgetIds.add(widget.id);
110
+ if (typeof widget.type !== "string" || !DASHBOARD_WIDGET_TYPES.includes(widget.type as DashboardWidgetType)) {
111
+ diagnostics.push({ target, message: "has an unsupported widget type" });
112
+ continue;
113
+ }
114
+ if (widget.span !== "quarter" && widget.span !== "half" && widget.span !== "full") {
115
+ diagnostics.push({ target, message: "has an unsupported widget span" });
116
+ continue;
117
+ }
118
+ if (typeof widget.dataset !== "string" || !DATASET_FILE_RE.test(widget.dataset)) {
119
+ diagnostics.push({ target, message: "has an invalid dataset name" });
120
+ continue;
121
+ }
122
+ const datasetPath = resolve(dashboardDir, "datasets", widget.dataset);
123
+ if (dirname(datasetPath) !== resolve(dashboardDir, "datasets")) {
124
+ diagnostics.push({ target, message: "dataset escapes the dashboard directory" });
125
+ continue;
126
+ }
127
+ try {
128
+ const dataset = await readJson(datasetPath, 2 * 1024 * 1024);
129
+ try {
130
+ validateDatasetForWidget(widget.type as DashboardWidgetType, dataset);
131
+ } catch (error) {
132
+ diagnostics.push({ target, message: `dataset schema is invalid: ${error instanceof Error ? error.message : String(error)}` });
133
+ }
134
+ const actualSha256 = createHash("sha256").update(stableJson(dataset)).digest("hex");
135
+ const expectedSha256 = receiptDatasets.get(widget.dataset);
136
+ if (!expectedSha256) {
137
+ diagnostics.push({ target, message: `dataset has no sha256 entry in ${receiptFilename}` });
138
+ } else if (expectedSha256 !== actualSha256) {
139
+ diagnostics.push({ target, message: `dataset integrity sha256 does not match ${receiptFilename}` });
140
+ }
141
+ } catch (error) {
142
+ diagnostics.push({ target, message: error instanceof Error ? error.message : String(error) });
143
+ }
144
+ }
145
+
146
+ return {
147
+ dashboard_exists: true,
148
+ dashboard_dir: dashboardDir,
149
+ ...(typeof source.generationId === "string" ? { generation_id: source.generationId } : {}),
150
+ ...(typeof source.updatedAt === "string" ? { updated_at: source.updatedAt } : {}),
151
+ widget_count: source.widgets.length,
152
+ diagnostics,
153
+ };
154
+ }
@@ -16,23 +16,19 @@ import { Type } from "typebox";
16
16
  import {
17
17
  buildCohortRows,
18
18
  comparePeriods,
19
- computeDerived,
20
- round,
21
19
  runCalc,
22
- toNumber,
23
20
  type CalcExpression,
24
- type NumericLike,
25
21
  } from "./calc.ts";
26
22
  import { CATALOG_CAVEATS, DATASETS, DATASET_IDS } from "./registry.ts";
27
23
  import {
28
24
  buildCohortQuery,
29
25
  buildCoverageSql,
30
26
  buildCohortSizeCoverageSql,
31
- buildQuery,
32
27
  MAX_LIMIT,
33
28
  type QuerySpec,
34
29
  } from "./sql.ts";
35
- import { runQuery, type SqlRow } from "./supabase.ts";
30
+ import { runStructuredQuery } from "./runtime.ts";
31
+ import { runQuery } from "./supabase.ts";
36
32
 
37
33
  const MAX_TOOL_TEXT_CHARS = 100_000;
38
34
  const MAX_DISPLAY_ROWS = 200;
@@ -116,27 +112,6 @@ function toolResult(result: Record<string, unknown>, rowsKey = "rows") {
116
112
  };
117
113
  }
118
114
 
119
- function shapeQueryRows(
120
- rows: SqlRow[],
121
- measures: string[],
122
- derived: ReturnType<typeof buildQuery>["derived"],
123
- ): SqlRow[] {
124
- return rows.map((row) => {
125
- const shaped: SqlRow = { ...row };
126
- for (const name of measures) {
127
- shaped[name] = round(toNumber(row[name] as NumericLike));
128
- }
129
- shaped.source_rows = toNumber(row.source_rows as NumericLike);
130
- return { ...shaped, ...computeDerived(row, derived) };
131
- });
132
- }
133
-
134
- async function runStructuredQuery(spec: QuerySpec, signal?: AbortSignal) {
135
- const built = buildQuery(spec);
136
- const rows = await runQuery(built.sql, { signal });
137
- return { built, rows: shapeQueryRows(rows, built.measures, built.derived) };
138
- }
139
-
140
115
  export default function fpaDataExtension(pi: ExtensionAPI): void {
141
116
  pi.registerTool({
142
117
  name: "fpa_data_catalog",
@@ -0,0 +1,22 @@
1
+ import { computeDerived, round, toNumber, type NumericLike } from "./calc.ts";
2
+ import { buildQuery, type QuerySpec } from "./sql.ts";
3
+ import { runQuery, type SqlRow } from "./supabase.ts";
4
+
5
+ export function shapeQueryRows(
6
+ rows: SqlRow[],
7
+ measures: string[],
8
+ derived: ReturnType<typeof buildQuery>["derived"],
9
+ ): SqlRow[] {
10
+ return rows.map((row) => {
11
+ const shaped: SqlRow = { ...row };
12
+ for (const name of measures) shaped[name] = round(toNumber(row[name] as NumericLike));
13
+ shaped.source_rows = toNumber(row.source_rows as NumericLike);
14
+ return { ...shaped, ...computeDerived(row, derived) };
15
+ });
16
+ }
17
+
18
+ export async function runStructuredQuery(spec: QuerySpec, signal?: AbortSignal) {
19
+ const built = buildQuery(spec);
20
+ const rows = await runQuery(built.sql, { signal });
21
+ return { built, rows: shapeQueryRows(rows, built.measures, built.derived) };
22
+ }
@@ -23,6 +23,16 @@ export interface QuerySpec {
23
23
  dateFrom?: string;
24
24
  dateTo?: string;
25
25
  filters?: Record<string, string | number | Array<string | number>>;
26
+ /** Internal exact UA scope used by deterministic dashboard projections. */
27
+ exactUaScope?: Array<{ app_code: string; platform: string; media_source: string }>;
28
+ /** Internal exact App + Store portfolio scope used to discover unplanned paid channels. */
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 }>;
26
36
  sort?: { by: string; direction?: "asc" | "desc" };
27
37
  limit?: number;
28
38
  }
@@ -201,6 +211,48 @@ export function buildQuery(spec: QuerySpec): BuiltQuery {
201
211
  const values = normalizeFilterValues(raw).map(escapeLiteral);
202
212
  where.push(values.length === 1 ? `${def.sql} = ${values[0]}` : `${def.sql} in (${values.join(", ")})`);
203
213
  }
214
+ if (spec.exactUaScope !== undefined) {
215
+ if (dataset.id !== "ua_spend") throw new Error("exactUaScope is supported only for ua_spend.");
216
+ if (spec.exactUaScope.length === 0 || spec.exactUaScope.length > 500) throw new Error("exactUaScope must contain between 1 and 500 slices.");
217
+ const dimensionSql = Object.fromEntries(dataset.dimensions.map((dimension) => [dimension.name, dimension.sql]));
218
+ const predicates = spec.exactUaScope.map((scope, index) => {
219
+ for (const [name, value] of Object.entries(scope)) {
220
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`exactUaScope[${index}].${name} must be a non-empty string.`);
221
+ }
222
+ return `(${dimensionSql.app_code} = ${escapeLiteral(scope.app_code)} and ${dimensionSql.platform} = ${escapeLiteral(scope.platform)} and ${dimensionSql.media_source} = ${escapeLiteral(scope.media_source)})`;
223
+ });
224
+ where.push(`(${predicates.join(" or ")})`);
225
+ filterKeys.push("app_code", "platform", "media_source");
226
+ notes.push("Applied exact App + Store + Channel dashboard scope.");
227
+ }
228
+ if (spec.exactUaPortfolioScope !== undefined) {
229
+ if (dataset.id !== "ua_spend") throw new Error("exactUaPortfolioScope is supported only for ua_spend.");
230
+ if (spec.exactUaPortfolioScope.length === 0 || spec.exactUaPortfolioScope.length > 500) throw new Error("exactUaPortfolioScope must contain between 1 and 500 App + Store pairs.");
231
+ const dimensionSql = Object.fromEntries(dataset.dimensions.map((dimension) => [dimension.name, dimension.sql]));
232
+ const predicates = spec.exactUaPortfolioScope.map((scope, index) => {
233
+ for (const [name, value] of Object.entries(scope)) {
234
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`exactUaPortfolioScope[${index}].${name} must be a non-empty string.`);
235
+ }
236
+ return `(${dimensionSql.app_code} = ${escapeLiteral(scope.app_code)} and ${dimensionSql.platform} = ${escapeLiteral(scope.platform)})`;
237
+ });
238
+ where.push(`(${predicates.join(" or ")})`);
239
+ filterKeys.push("app_code", "platform");
240
+ notes.push("Applied exact App + Store portfolio scope.");
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
+ }
204
256
 
205
257
  const breakdown = resolveBreakdown(dataset, dimensionNames, filterKeys);
206
258
  if (breakdown.predicate) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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",
@@ -23,9 +23,9 @@
23
23
  "extensions"
24
24
  ],
25
25
  "scripts": {
26
- "test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
26
+ "test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node --test tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
27
27
  "test:structure": "node tests/package-structure.test.mjs",
28
- "test:unit": "node tests/extension-unit.test.mjs",
28
+ "test:unit": "node tests/extension-unit.test.mjs && node --test tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs",
29
29
  "test:loader": "node tests/pi-loader-smoke.mjs",
30
30
  "test:live": "node tests/live-smoke.mjs",
31
31
  "pack:check": "npm pack --dry-run"
@@ -48,7 +48,9 @@
48
48
  "./skills"
49
49
  ],
50
50
  "extensions": [
51
- "./extensions/fpa-data/index.ts"
51
+ "./extensions/fpa-data/index.ts",
52
+ "./extensions/fpa-artifacts/index.ts",
53
+ "./extensions/fpa-dashboard/index.ts"
52
54
  ]
53
55
  }
54
56
  }
@@ -29,7 +29,7 @@ If any requirement is missing, return `blocked` and make no external call. Never
29
29
  4. Present or record the dry-run result when the adapter supports it.
30
30
  5. Execute only the approved mutation set.
31
31
  6. Read back the resulting state and reconcile it with the requested state.
32
- 7. Write `execution_receipt` using [artifact-contract.md](references/artifact-contract.md).
32
+ 7. Build the canonical `execution_receipt` using [artifact-contract.md](references/artifact-contract.md), then call `fpa_artifact_commit`.
33
33
 
34
34
  ## Stop conditions
35
35
 
@@ -37,4 +37,4 @@ Stop before or during execution on version mismatch, target ambiguity, budget mi
37
37
 
38
38
  ## Boundary
39
39
 
40
- Execution ends after reconciliation and receipt. Do not continuously monitor performance. Performance review begins only when the next cycle's Actuals arrive and uses `$fpa-review-cycle`.
40
+ Execution ends after reconciliation and a successful canonical receipt commit. A Markdown report is not an execution receipt. Do not continuously monitor performance. Performance review begins only when the next cycle's Actuals arrive and uses `$fpa-review-cycle`.
@@ -3,12 +3,17 @@
3
3
  ```yaml
4
4
  artifact_type: execution_receipt
5
5
  status: complete | complete_with_limits | blocked
6
+ forecast_version: string
6
7
  strategy_version: string
7
8
  human_approval_id: string
8
9
  execution_request_id: string
10
+ execution_mode: manual | adapter
11
+ verification_status: reported | verified | failed
9
12
  adapter: string
10
13
  target_accounts: []
11
14
  idempotency_key: string
15
+ target_period: {start_inclusive: timestamp, end_exclusive: timestamp, timezone: string}
16
+ reporting_currency: string
12
17
  preflight:
13
18
  result: pass | fail
14
19
  observed_state_fingerprint: string
@@ -23,7 +28,15 @@ reconciliation:
23
28
  resulting_state_fingerprint: string | null
24
29
  external_receipt_ids: []
25
30
  executed_at: timestamp | null
31
+ slices:
32
+ - app_id: string
33
+ store: string
34
+ channel_group: string
35
+ action: string
36
+ planned_spend: number
37
+ applied_spend: number | null
38
+ evidence_ids: []
26
39
  blockers: []
27
40
  ```
28
41
 
29
- Only adapter responses may populate `applied_mutations`, external receipt IDs, and resulting-state evidence. A blocked receipt must have no applied mutations.
42
+ Only adapter responses or independently verified external evidence may populate `applied_mutations`, applied spend, external receipt IDs, and resulting-state evidence. `verification_status: verified` requires a passing reconciliation, resulting-state fingerprint, `executed_at`, and evidence plus applied spend for every receipt slice. A manual report remains `reported` until independently verified, and therefore cannot use `status: complete`. A blocked receipt must have no applied mutations, applied spend, or `executed_at`. Do not include `immutable_fingerprint`; `fpa_artifact_commit` supplies it after strict validation and durable storage.