@viccydev/pi-fpa 0.5.0 → 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,12 +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";
4
7
  import { sliceKey } from "../fpa-artifacts/contracts.ts";
8
+ import { stableJson } from "../fpa-artifacts/store.ts";
5
9
  import { toNumber, type NumericLike } from "../fpa-data/calc.ts";
6
- import { runStructuredQuery } from "../fpa-data/runtime.ts";
7
- 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";
8
13
  import type { DashboardActualsSnapshot, DashboardDailyPoint, DashboardScopeMode, QueryReceipt } from "./source.ts";
9
- 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
+ }
10
82
 
11
83
  function dateInTimezone(timestamp: string, timezone: string, label: string): string {
12
84
  const instant = new Date(timestamp);
@@ -52,6 +124,74 @@ function receipt(dataset: string, sql: string, rowCount: number): QueryReceipt {
52
124
  return { dataset, query_fingerprint: queryFingerprint(sql), row_count: rowCount };
53
125
  }
54
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
+
55
195
  export function calendarQueryDates(period: Period): {
56
196
  currentFrom: string;
57
197
  currentTo: string;
@@ -175,8 +315,12 @@ export async function loadDashboardActuals(
175
315
  period: Period,
176
316
  scope: DashboardActualsScope,
177
317
  signal?: AbortSignal,
318
+ options: { sourceCloseSignal?: ActualsSourceCloseSignal | null } = {},
178
319
  ): Promise<DashboardActualsSnapshot> {
179
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);
180
324
  const dates = calendarQueryDates(period);
181
325
  const uniqueSlices = [...new Map(scope.slices.map((slice) => [`${slice.app_id}\u0000${slice.store}\u0000${slice.channel_group}`, slice])).values()];
182
326
  const common = {
@@ -196,52 +340,40 @@ export async function loadDashboardActuals(
196
340
  ? { filters: { platform: [...new Set(uniqueSlices.map((slice) => slice.store))] } }
197
341
  : { exactUaPortfolioScope };
198
342
  const sliceDimensions = portfolio ? ["platform", "media_source"] : ["app_code", "platform", "media_source"];
199
- const current = await runStructuredQuery({ ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 }, signal);
200
- 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];
201
366
  const coverageMin = currentRow?.date_min == null ? null : String(currentRow.date_min);
202
367
  const coverageMax = currentRow?.date_max == null ? null : String(currentRow.date_max);
203
- const comparisonDates = coverageMax ? comparisonDatesForCoverage(dates.currentFrom, coverageMax) : null;
204
- const [comparison, daily, approvedSlices, discoveredSlices] = await Promise.all([
205
- comparisonDates
206
- ? runStructuredQuery({ ...common, ...sliceScope, dateFrom: comparisonDates.comparisonFrom, dateTo: comparisonDates.comparisonTo, limit: 1 }, signal)
207
- : Promise.resolve(null),
208
- runStructuredQuery({ ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, timeGrain: "day", limit: 1000 }, signal),
209
- runStructuredQuery({
210
- ...common,
211
- dateFrom: dates.currentFrom,
212
- dateTo: dates.currentTo,
213
- ...sliceScope,
214
- dimensions: sliceDimensions,
215
- limit: 500,
216
- }, signal),
217
- runStructuredQuery({
218
- ...common,
219
- dateFrom: dates.currentFrom,
220
- dateTo: dates.currentTo,
221
- ...discoveryScope,
222
- dimensions: sliceDimensions,
223
- limit: 1000,
224
- }, signal),
225
- ]);
226
-
227
- const comparisonRow = comparison?.rows[0];
228
- // Only worth asking when the scoped queries came back empty, which is the one
229
- // case where "no Actuals" is ambiguous between an early cycle and slice keys
230
- // that match nothing.
231
- const scopedFoundNothing = coverageMax === null || approvedSlices.rows.length === 0;
232
- const unscopedProbe = scopedFoundNothing
233
- ? await runStructuredQuery({ ...common, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 }, signal)
234
- : null;
235
- const unscopedRow = unscopedProbe?.rows[0];
236
- const periodHasUnscopedActuals = unscopedProbe !== null && unscopedRow?.date_max != null;
237
-
368
+ const periodHasUnscopedActuals = unscopedRow?.date_max != null;
238
369
  const queryReceipts = [
239
- receipt("ua_spend.current", current.built.sql, current.rows.length),
240
- ...(comparison ? [receipt("ua_spend.comparison", comparison.built.sql, comparison.rows.length)] : []),
241
- receipt("ua_spend.daily", daily.built.sql, daily.rows.length),
242
- receipt("ua_spend.slices.approved", approvedSlices.built.sql, approvedSlices.rows.length),
243
- receipt("ua_spend.slices.discovery", discoveredSlices.built.sql, discoveredSlices.rows.length),
244
- ...(unscopedProbe ? [receipt("ua_spend.period.unscoped", unscopedProbe.built.sql, periodHasUnscopedActuals ? 1 : 0)] : []),
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),
245
377
  ];
246
378
  const approvedKeys = new Set(
247
379
  portfolio
@@ -251,13 +383,45 @@ export async function loadDashboardActuals(
251
383
  const rowKey = (row: SqlRow): string =>
252
384
  portfolio ? `${row.platform}\u0000${row.media_source}` : `${row.app_code}\u0000${row.platform}\u0000${row.media_source}`;
253
385
  const sliceRows = [
254
- ...approvedSlices.rows,
255
- ...discoveredSlices.rows.filter((row) => !approvedKeys.has(rowKey(row))),
386
+ ...approvedRows,
387
+ ...discoveredRows.filter((row) => !approvedKeys.has(rowKey(row))),
256
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);
257
410
 
258
411
  return {
259
412
  period,
260
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
+ },
261
425
  reporting_currency: "USD",
262
426
  scope_mode: mode,
263
427
  period_has_unscoped_actuals: periodHasUnscopedActuals,
@@ -266,11 +430,11 @@ export async function loadDashboardActuals(
266
430
  date_max: coverageMax,
267
431
  source_rows: value(currentRow, "source_rows") ?? 0,
268
432
  },
269
- current: { spend: value(currentRow, "spend"), revenue: value(currentRow, "revenue") },
270
- comparison: comparisonRow
433
+ current: { spend: currentSpend, revenue: currentRevenue },
434
+ comparison: periodComplete && comparisonRow
271
435
  ? { spend: value(comparisonRow, "spend"), revenue: value(comparisonRow, "revenue") }
272
436
  : null,
273
- daily: completeDailySeries(daily.rows, dates.currentFrom, coverageMax),
437
+ daily: dailySeries,
274
438
  slices: mapDashboardSlices(sliceRows, portfolioAppId ?? undefined),
275
439
  query_receipts: queryReceipts,
276
440
  };