@viccydev/pi-fpa 0.4.1 → 0.6.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.
@@ -1,11 +1,84 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, realpath, stat } from "node:fs/promises";
4
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
2
5
 
3
6
  import type { Period } from "../fpa-artifacts/contracts.ts";
7
+ import { sliceKey } from "../fpa-artifacts/contracts.ts";
8
+ import { stableJson } from "../fpa-artifacts/store.ts";
4
9
  import { toNumber, type NumericLike } from "../fpa-data/calc.ts";
5
- import { runStructuredQuery } from "../fpa-data/runtime.ts";
6
- import type { SqlRow } from "../fpa-data/supabase.ts";
10
+ import { runStructuredQuery, shapeQueryRows } from "../fpa-data/runtime.ts";
11
+ import { buildQuery, type QuerySpec } from "../fpa-data/sql.ts";
12
+ import { runQuery, type SqlRow } from "../fpa-data/supabase.ts";
7
13
  import type { DashboardActualsSnapshot, DashboardDailyPoint, DashboardScopeMode, QueryReceipt } from "./source.ts";
8
- import { ACTUALS_DATA_AS_OF_UNAVAILABLE } from "./source.ts";
14
+ import { ACTUALS_DATA_AS_OF_UNAVAILABLE, validateActualsSnapshot } from "./source.ts";
15
+
16
+ export interface ActualsSourceCloseSignal {
17
+ kind: "fpa.actuals.source-close";
18
+ schema_version: 1;
19
+ dataset: "ua_spend";
20
+ signal_id: string;
21
+ closed_through: string;
22
+ emitted_at: string;
23
+ }
24
+
25
+ function validateSourceCloseSignal(value: unknown): (ActualsSourceCloseSignal & { signal_sha256: string }) | null {
26
+ if (value === undefined || value === null) return null;
27
+ if (typeof value !== "object" || Array.isArray(value)) throw new Error("Actuals source close signal must be an object.");
28
+ const source = value as Record<string, unknown>;
29
+ if (source.kind !== "fpa.actuals.source-close" || source.schema_version !== 1) throw new Error("Actuals source close signal has an unsupported contract.");
30
+ if (source.dataset !== "ua_spend") throw new Error("Actuals source close signal must identify ua_spend.");
31
+ if (typeof source.signal_id !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(source.signal_id)) throw new Error("Actuals source close signal_id is invalid.");
32
+ if (typeof source.closed_through !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(source.closed_through)) throw new Error("Actuals source closed_through must use YYYY-MM-DD.");
33
+ const closedDate = new Date(`${source.closed_through}T00:00:00Z`);
34
+ if (Number.isNaN(closedDate.getTime()) || closedDate.toISOString().slice(0, 10) !== source.closed_through) {
35
+ throw new Error("Actuals source closed_through must be a real canonical calendar date.");
36
+ }
37
+ if (typeof source.emitted_at !== "string" || Number.isNaN(Date.parse(source.emitted_at))) throw new Error("Actuals source close emitted_at must be an ISO timestamp.");
38
+ const canonical = {
39
+ kind: "fpa.actuals.source-close" as const,
40
+ schema_version: 1 as const,
41
+ dataset: "ua_spend" as const,
42
+ signal_id: source.signal_id as string,
43
+ closed_through: source.closed_through as string,
44
+ emitted_at: new Date(source.emitted_at as string).toISOString(),
45
+ };
46
+ return { ...canonical, signal_sha256: createHash("sha256").update(stableJson(canonical)).digest("hex") };
47
+ }
48
+
49
+ async function configuredSourceCloseSignal(): Promise<ActualsSourceCloseSignal | null> {
50
+ const path = process.env.FPA_ACTUALS_CLOSE_SIGNAL_PATH;
51
+ if (!path) return null;
52
+ const rootConfig = process.env.FPA_ACTUALS_CLOSE_SIGNAL_ROOT;
53
+ if (!rootConfig) throw new Error("FPA_ACTUALS_CLOSE_SIGNAL_ROOT is required when a source close signal path is configured.");
54
+ const resolvedPath = resolve(path);
55
+ const configuredRoot = resolve(rootConfig);
56
+ if ((await lstat(configuredRoot)).isSymbolicLink()) throw new Error("FPA_ACTUALS_CLOSE_SIGNAL_ROOT must not be a symbolic link.");
57
+ const trustedRoot = await realpath(configuredRoot);
58
+ const parent = await realpath(dirname(resolvedPath));
59
+ if (parent !== resolve(dirname(resolvedPath))) throw new Error("Actuals source close signal parent path must not contain symbolic links.");
60
+ const relation = relative(trustedRoot, parent);
61
+ if (relation.startsWith("..") || isAbsolute(relation)) throw new Error("Actuals source close signal escapes FPA_ACTUALS_CLOSE_SIGNAL_ROOT.");
62
+ const processUid = typeof process.getuid === "function" ? process.getuid() : null;
63
+ if (processUid === null) throw new Error("Configured source close signals require POSIX ownership checks.");
64
+ for (let directory = parent; ; directory = dirname(directory)) {
65
+ const metadata = await stat(directory);
66
+ if (!metadata.isDirectory() || (metadata.mode & 0o022) !== 0 || metadata.uid === processUid) {
67
+ throw new Error("Actuals source close signal directory chain must be owned outside the Agent identity and not be group- or world-writable.");
68
+ }
69
+ if (dirname(directory) === directory) break;
70
+ }
71
+ const handle = await open(resolvedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
72
+ try {
73
+ const metadata = await handle.stat();
74
+ if (!metadata.isFile() || metadata.size > 16 * 1024) throw new Error("FPA_ACTUALS_CLOSE_SIGNAL_PATH must be a bounded regular file.");
75
+ if ((metadata.mode & 0o022) !== 0) throw new Error("Actuals source close signal must not be group- or world-writable.");
76
+ if (metadata.uid === processUid) throw new Error("Actuals source close signal must be owned outside the Agent identity.");
77
+ return validateSourceCloseSignal(JSON.parse(await handle.readFile("utf8")));
78
+ } finally {
79
+ await handle.close();
80
+ }
81
+ }
9
82
 
10
83
  function dateInTimezone(timestamp: string, timezone: string, label: string): string {
11
84
  const instant = new Date(timestamp);
@@ -51,6 +124,74 @@ function receipt(dataset: string, sql: string, rowCount: number): QueryReceipt {
51
124
  return { dataset, query_fingerprint: queryFingerprint(sql), row_count: rowCount };
52
125
  }
53
126
 
127
+ function approximatelyEqual(left: number, right: number): boolean {
128
+ return Math.abs(left - right) <= Math.max(0.000001, Math.max(Math.abs(left), Math.abs(right)) * 0.000001);
129
+ }
130
+
131
+ function jsonValue(value: unknown): unknown {
132
+ if (typeof value !== "string") return value;
133
+ try {
134
+ return JSON.parse(value);
135
+ } catch {
136
+ return value;
137
+ }
138
+ }
139
+
140
+ function jsonRows(value: unknown, label: string): SqlRow[] {
141
+ const parsed = jsonValue(value);
142
+ if (!Array.isArray(parsed)) throw new Error(`Unified Actuals snapshot ${label} must be a JSON array.`);
143
+ if (parsed.some((row) => row === null || typeof row !== "object" || Array.isArray(row))) throw new Error(`Unified Actuals snapshot ${label} contains an invalid row.`);
144
+ return parsed as SqlRow[];
145
+ }
146
+
147
+ function jsonRow(value: unknown, label: string): SqlRow | undefined {
148
+ const parsed = jsonValue(value);
149
+ if (parsed === null || parsed === undefined) return undefined;
150
+ if (typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Unified Actuals snapshot ${label} must be a JSON object or null.`);
151
+ return parsed as SqlRow;
152
+ }
153
+
154
+ function unifiedSnapshotSql(queries: Record<string, ReturnType<typeof buildQuery>>): string {
155
+ return [
156
+ "with",
157
+ `snapshot_current as (${queries.current.sql}),`,
158
+ `snapshot_comparison as (${queries.comparison.sql}),`,
159
+ `snapshot_daily as (${queries.daily.sql}),`,
160
+ `snapshot_approved as (${queries.approved.sql}),`,
161
+ `snapshot_discovered as (${queries.discovered.sql}),`,
162
+ `snapshot_unscoped as (${queries.unscoped.sql})`,
163
+ "select",
164
+ "txid_current_snapshot()::text as snapshot_id,",
165
+ "statement_timestamp()::text as available_at,",
166
+ "(select row_to_json(snapshot_current) from snapshot_current limit 1) as current,",
167
+ "(select row_to_json(snapshot_comparison) from snapshot_comparison limit 1) as comparison,",
168
+ "coalesce((select json_agg(row_to_json(snapshot_daily) order by period) from snapshot_daily), '[]'::json) as daily,",
169
+ "coalesce((select json_agg(row_to_json(snapshot_approved)) from snapshot_approved), '[]'::json) as approved,",
170
+ "coalesce((select json_agg(row_to_json(snapshot_discovered)) from snapshot_discovered), '[]'::json) as discovered,",
171
+ "(select row_to_json(snapshot_unscoped) from snapshot_unscoped limit 1) as unscoped",
172
+ ].join(" ");
173
+ }
174
+
175
+ export function dashboardActualsWatermark(actuals: DashboardActualsSnapshot): string {
176
+ const normalized = validateActualsSnapshot(actuals);
177
+ return createHash("sha256").update(stableJson({
178
+ period: normalized.period,
179
+ data_as_of: normalized.data_as_of,
180
+ coverage: normalized.coverage,
181
+ current: normalized.current,
182
+ comparison: normalized.comparison,
183
+ daily: normalized.daily,
184
+ slices: normalized.slices,
185
+ period_has_unscoped_actuals: normalized.period_has_unscoped_actuals,
186
+ period_complete: normalized.snapshot_evidence.period_complete,
187
+ reconciled: normalized.snapshot_evidence.reconciled,
188
+ })).digest("hex");
189
+ }
190
+
191
+ export function dashboardActualsSnapshotFingerprint(actuals: DashboardActualsSnapshot): string {
192
+ return createHash("sha256").update(stableJson(validateActualsSnapshot(actuals))).digest("hex");
193
+ }
194
+
54
195
  export function calendarQueryDates(period: Period): {
55
196
  currentFrom: string;
56
197
  currentTo: string;
@@ -97,6 +238,32 @@ export interface DashboardActualsScope {
97
238
  slices: Array<{ app_id: string; store: string; channel_group: string }>;
98
239
  }
99
240
 
241
+ /**
242
+ * Why every approved slice can be missing, and which of the two reasons it is.
243
+ *
244
+ * An early cycle and a forecast keyed on values ua_spend never uses produce
245
+ * byte-identical Actuals: nothing. The difference is only visible next to the
246
+ * unscoped probe — if the window holds paid rows that no approved key matched,
247
+ * the keys are wrong. Returns the operator-facing explanation, or null when the
248
+ * evidence does not support that conclusion.
249
+ */
250
+ export function detectSliceKeyMismatch(
251
+ allocation: Array<{ app_id: string; store: string; channel_group: string }>,
252
+ actuals: Pick<DashboardActualsSnapshot, "slices" | "period_has_unscoped_actuals" | "period">,
253
+ ): string | null {
254
+ if (allocation.length === 0) return null;
255
+ if (!actuals.period_has_unscoped_actuals) return null;
256
+ const actualKeys = new Set(actuals.slices.map(sliceKey));
257
+ const missing = allocation.filter((slice) => !actualKeys.has(sliceKey(slice)));
258
+ if (missing.length < allocation.length) return null;
259
+ return (
260
+ `All ${allocation.length} approved slices have no Actuals, yet ua_spend holds paid rows in `
261
+ + `${actuals.period.start_inclusive.slice(0, 10)}..${actuals.period.end_exclusive.slice(0, 10)}. `
262
+ + "The frozen slice keys do not match the dimension values ua_spend uses — check store against "
263
+ + "ua_spend.platform and channel_group against ua_spend.media_source, then re-freeze the forecast."
264
+ );
265
+ }
266
+
100
267
  /**
101
268
  * Rows carry app_code only in "app" scope mode. In "portfolio" mode the App axis was
102
269
  * collapsed onto a placeholder before the query ran, so the caller supplies it back.
@@ -148,8 +315,12 @@ export async function loadDashboardActuals(
148
315
  period: Period,
149
316
  scope: DashboardActualsScope,
150
317
  signal?: AbortSignal,
318
+ options: { sourceCloseSignal?: ActualsSourceCloseSignal | null } = {},
151
319
  ): Promise<DashboardActualsSnapshot> {
152
320
  if (scope.slices.length === 0) throw new Error("Dashboard Actuals scope requires at least one approved slice.");
321
+ const closeSignal = options.sourceCloseSignal === undefined
322
+ ? await configuredSourceCloseSignal()
323
+ : validateSourceCloseSignal(options.sourceCloseSignal);
153
324
  const dates = calendarQueryDates(period);
154
325
  const uniqueSlices = [...new Map(scope.slices.map((slice) => [`${slice.app_id}\u0000${slice.store}\u0000${slice.channel_group}`, slice])).values()];
155
326
  const common = {
@@ -169,41 +340,40 @@ export async function loadDashboardActuals(
169
340
  ? { filters: { platform: [...new Set(uniqueSlices.map((slice) => slice.store))] } }
170
341
  : { exactUaPortfolioScope };
171
342
  const sliceDimensions = portfolio ? ["platform", "media_source"] : ["app_code", "platform", "media_source"];
172
- const current = await runStructuredQuery({ ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 }, signal);
173
- const currentRow = current.rows[0];
343
+ const periodDays = calendarDayDistance(dates.currentFrom, dates.currentTo) + 1;
344
+ const priorTo = addCalendarDays(dates.currentFrom, -1);
345
+ const priorFrom = addCalendarDays(priorTo, -(periodDays - 1));
346
+ const specs: Record<string, QuerySpec> = {
347
+ current: { ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 },
348
+ comparison: { ...common, ...sliceScope, dateFrom: priorFrom, dateTo: priorTo, limit: 1 },
349
+ daily: { ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, timeGrain: "day", limit: 1000 },
350
+ approved: { ...common, dateFrom: dates.currentFrom, dateTo: dates.currentTo, ...sliceScope, dimensions: sliceDimensions, limit: 500 },
351
+ discovered: { ...common, dateFrom: dates.currentFrom, dateTo: dates.currentTo, ...discoveryScope, dimensions: sliceDimensions, limit: 1000 },
352
+ unscoped: { ...common, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 },
353
+ };
354
+ const built = Object.fromEntries(Object.entries(specs).map(([name, spec]) => [name, buildQuery(spec)])) as Record<string, ReturnType<typeof buildQuery>>;
355
+ const snapshotSql = unifiedSnapshotSql(built);
356
+ const snapshotRows = await runQuery(snapshotSql, { signal });
357
+ if (snapshotRows.length !== 1) throw new Error("Unified Actuals snapshot query must return exactly one envelope row.");
358
+ const envelope = snapshotRows[0];
359
+ const shape = (name: string, rows: SqlRow[]) => shapeQueryRows(rows, built[name].measures, built[name].derived);
360
+ const currentRow = shape("current", jsonRow(envelope.current, "current") ? [jsonRow(envelope.current, "current") as SqlRow] : [])[0];
361
+ const comparisonRow = shape("comparison", jsonRow(envelope.comparison, "comparison") ? [jsonRow(envelope.comparison, "comparison") as SqlRow] : [])[0];
362
+ const dailyRows = shape("daily", jsonRows(envelope.daily, "daily"));
363
+ const approvedRows = shape("approved", jsonRows(envelope.approved, "approved"));
364
+ const discoveredRows = shape("discovered", jsonRows(envelope.discovered, "discovered"));
365
+ const unscopedRow = shape("unscoped", jsonRow(envelope.unscoped, "unscoped") ? [jsonRow(envelope.unscoped, "unscoped") as SqlRow] : [])[0];
174
366
  const coverageMin = currentRow?.date_min == null ? null : String(currentRow.date_min);
175
367
  const coverageMax = currentRow?.date_max == null ? null : String(currentRow.date_max);
176
- const comparisonDates = coverageMax ? comparisonDatesForCoverage(dates.currentFrom, coverageMax) : null;
177
- const [comparison, daily, approvedSlices, discoveredSlices] = await Promise.all([
178
- comparisonDates
179
- ? runStructuredQuery({ ...common, ...sliceScope, dateFrom: comparisonDates.comparisonFrom, dateTo: comparisonDates.comparisonTo, limit: 1 }, signal)
180
- : Promise.resolve(null),
181
- runStructuredQuery({ ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, timeGrain: "day", limit: 1000 }, signal),
182
- runStructuredQuery({
183
- ...common,
184
- dateFrom: dates.currentFrom,
185
- dateTo: dates.currentTo,
186
- ...sliceScope,
187
- dimensions: sliceDimensions,
188
- limit: 500,
189
- }, signal),
190
- runStructuredQuery({
191
- ...common,
192
- dateFrom: dates.currentFrom,
193
- dateTo: dates.currentTo,
194
- ...discoveryScope,
195
- dimensions: sliceDimensions,
196
- limit: 1000,
197
- }, signal),
198
- ]);
199
-
200
- const comparisonRow = comparison?.rows[0];
368
+ const periodHasUnscopedActuals = unscopedRow?.date_max != null;
201
369
  const queryReceipts = [
202
- receipt("ua_spend.current", current.built.sql, current.rows.length),
203
- ...(comparison ? [receipt("ua_spend.comparison", comparison.built.sql, comparison.rows.length)] : []),
204
- receipt("ua_spend.daily", daily.built.sql, daily.rows.length),
205
- receipt("ua_spend.slices.approved", approvedSlices.built.sql, approvedSlices.rows.length),
206
- receipt("ua_spend.slices.discovery", discoveredSlices.built.sql, discoveredSlices.rows.length),
370
+ receipt("ua_spend.snapshot", snapshotSql, snapshotRows.length),
371
+ receipt("ua_spend.current", snapshotSql, currentRow ? 1 : 0),
372
+ receipt("ua_spend.comparison", snapshotSql, comparisonRow ? 1 : 0),
373
+ receipt("ua_spend.daily", snapshotSql, dailyRows.length),
374
+ receipt("ua_spend.slices.approved", snapshotSql, approvedRows.length),
375
+ receipt("ua_spend.slices.discovery", snapshotSql, discoveredRows.length),
376
+ receipt("ua_spend.period.unscoped", snapshotSql, periodHasUnscopedActuals ? 1 : 0),
207
377
  ];
208
378
  const approvedKeys = new Set(
209
379
  portfolio
@@ -213,25 +383,58 @@ export async function loadDashboardActuals(
213
383
  const rowKey = (row: SqlRow): string =>
214
384
  portfolio ? `${row.platform}\u0000${row.media_source}` : `${row.app_code}\u0000${row.platform}\u0000${row.media_source}`;
215
385
  const sliceRows = [
216
- ...approvedSlices.rows,
217
- ...discoveredSlices.rows.filter((row) => !approvedKeys.has(rowKey(row))),
386
+ ...approvedRows,
387
+ ...discoveredRows.filter((row) => !approvedKeys.has(rowKey(row))),
218
388
  ];
389
+ const dailySeries = completeDailySeries(dailyRows, dates.currentFrom, coverageMax);
390
+ const currentSpend = value(currentRow, "spend");
391
+ const currentRevenue = value(currentRow, "revenue");
392
+ const dailyComplete = coverageMin === dates.currentFrom && coverageMax === dates.currentTo
393
+ && dailySeries.length === periodDays
394
+ && dailySeries.every((point) => point.spend !== null && point.revenue !== null);
395
+ const approvedSpend = approvedRows.reduce<number | null>((sum, row) => sum === null || value(row, "spend") === null ? null : sum + (value(row, "spend") as number), 0);
396
+ const approvedRevenue = approvedRows.reduce<number | null>((sum, row) => sum === null || value(row, "revenue") === null ? null : sum + (value(row, "revenue") as number), 0);
397
+ const dailySpend = dailySeries.reduce<number | null>((sum, point) => sum === null || point.spend === null ? null : sum + point.spend, 0);
398
+ const dailyRevenue = dailySeries.reduce<number | null>((sum, point) => sum === null || point.revenue === null ? null : sum + point.revenue, 0);
399
+ const reconciled = dailyComplete && currentSpend !== null && currentRevenue !== null
400
+ && approvedSpend !== null && approvedRevenue !== null && dailySpend !== null && dailyRevenue !== null
401
+ && approximatelyEqual(currentSpend, approvedSpend) && approximatelyEqual(currentRevenue, approvedRevenue)
402
+ && approximatelyEqual(currentSpend, dailySpend) && approximatelyEqual(currentRevenue, dailyRevenue);
403
+ const snapshotId = envelope.snapshot_id == null ? null : String(envelope.snapshot_id);
404
+ const availableAt = envelope.available_at == null ? null : new Date(String(envelope.available_at)).toISOString();
405
+ // Date coverage and elapsed time are not close signals. Only an explicit ETL
406
+ // control-plane marker may close a cycle; absent evidence fails closed.
407
+ const periodComplete = dailyComplete && closeSignal !== null && availableAt !== null
408
+ && closeSignal.closed_through >= dates.currentTo
409
+ && Date.parse(availableAt) >= Date.parse(closeSignal.emitted_at);
219
410
 
220
411
  return {
221
412
  period,
222
413
  data_as_of: coverageMax ?? ACTUALS_DATA_AS_OF_UNAVAILABLE,
414
+ snapshot_evidence: {
415
+ snapshot_id: snapshotId,
416
+ available_at: availableAt,
417
+ consistency: "single_statement",
418
+ source_close_signal_id: closeSignal?.signal_id ?? null,
419
+ source_closed_through: closeSignal?.closed_through ?? null,
420
+ source_close_emitted_at: closeSignal?.emitted_at ?? null,
421
+ source_close_signal_sha256: closeSignal?.signal_sha256 ?? null,
422
+ period_complete: periodComplete,
423
+ reconciled,
424
+ },
223
425
  reporting_currency: "USD",
224
426
  scope_mode: mode,
427
+ period_has_unscoped_actuals: periodHasUnscopedActuals,
225
428
  coverage: {
226
429
  date_min: coverageMin,
227
430
  date_max: coverageMax,
228
431
  source_rows: value(currentRow, "source_rows") ?? 0,
229
432
  },
230
- current: { spend: value(currentRow, "spend"), revenue: value(currentRow, "revenue") },
231
- comparison: comparisonRow
433
+ current: { spend: currentSpend, revenue: currentRevenue },
434
+ comparison: periodComplete && comparisonRow
232
435
  ? { spend: value(comparisonRow, "spend"), revenue: value(comparisonRow, "revenue") }
233
436
  : null,
234
- daily: completeDailySeries(daily.rows, dates.currentFrom, coverageMax),
437
+ daily: dailySeries,
235
438
  slices: mapDashboardSlices(sliceRows, portfolioAppId ?? undefined),
236
439
  query_receipts: queryReceipts,
237
440
  };