/** * @factory/database-engine/rollups — the pure aggregate engine (TASK-0083 R1). * * `aggregateRollup` runs over the LINKED records' values for the target * property (already filtered by the rollup's `filter` tree via the filter * engine — the filter filters the *related* records, e.g. "status = Done" * before counting). The recorded empty-set rules: * - COUNT over zero matching records = 0 (a count is defined on ∅); * - SUM/AVERAGE over zero matching NUMERIC values = null (no values); * - MIN/MAX over zero values = null; AVERAGE of zero numerics = null * (never NaN); * - PERCENT = matching / all-linked (0–1); no filter → 1.0 (degenerate, * the UI warns); * - UNIQUE = deduped value array; `display: 'count'` → the length. * Result type: `inferredType(aggregate, targetType)` — SUM/AVG/COUNT/PERCENT * → number; MIN/MAX → number|date; UNIQUE → array (count mode → number). */ import type { PropertyValue, PropertyInfo } from "../values/types.js"; export type RollupAggregate = "COUNT" | "SUM" | "AVERAGE" | "MIN" | "MAX" | "PERCENT" | "UNIQUE"; export const ROLLUP_AGGREGATES: readonly RollupAggregate[] = [ "COUNT", "SUM", "AVERAGE", "MIN", "MAX", "PERCENT", "UNIQUE", ]; export interface RollupConfig { aggregate: RollupAggregate; targetPropertyType: string; display?: "list" | "count"; // UNIQUE only } export type RollupValue = number | string | string[] | null; /** Config-time guard: which target property types each aggregate allows. */ export function aggregateAllowed(aggregate: RollupAggregate, targetType: string): boolean { switch (aggregate) { case "SUM": case "AVERAGE": return targetType === "number"; case "MIN": case "MAX": return targetType === "number" || targetType === "date"; case "COUNT": case "PERCENT": case "UNIQUE": return true; } } export function inferredType( aggregate: RollupAggregate, targetType: string, ): "number" | "date" | "array" { if (aggregate === "MIN" || aggregate === "MAX") return targetType === "date" ? "date" : "number"; if (aggregate === "UNIQUE") return "array"; return "number"; } function isNumeric(v: PropertyValue): v is number { return typeof v === "number" && Number.isFinite(v); } function dateKey(v: PropertyValue): number | null { if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v)) { const d = new Date(v.length === 10 ? v + "T00:00:00Z" : v).getTime(); return Number.isNaN(d) ? null : d; } return null; } /** * Aggregate `values` (one entry per matching linked record — null when the * record has no value for the target property) with the rollup config. * `allLinkedCount` = total linked records (pre-filter) — needed for PERCENT. */ export function aggregateRollup( values: readonly (PropertyValue | undefined)[], config: RollupConfig, allLinkedCount?: number, ): RollupValue { const present = values.filter((v): v is PropertyValue => v !== undefined && v !== null); switch (config.aggregate) { case "COUNT": // count of matching records (rows), not non-null values return values.length; case "SUM": { const nums = present.filter(isNumeric); if (nums.length === 0) return null; // SUM of zero numerics = null (recorded) return nums.reduce((a, b) => a + b, 0); } case "AVERAGE": { const nums = present.filter(isNumeric); if (nums.length === 0) return null; // AVERAGE of zero numerics = null (not NaN) return nums.reduce((a, b) => a + b, 0) / nums.length; } case "MIN": case "MAX": { if (config.targetPropertyType === "date") { const dated = present.map((v) => dateKey(v)).filter((k): k is number => k !== null); if (dated.length === 0) return null; const best = config.aggregate === "MIN" ? Math.min(...dated) : Math.max(...dated); return new Date(best).toISOString().slice(0, 10); } const nums = present.filter(isNumeric); if (nums.length === 0) return null; return config.aggregate === "MIN" ? Math.min(...nums) : Math.max(...nums); } case "PERCENT": { const total = allLinkedCount ?? values.length; if (total === 0) return 0; return values.length / total; } case "UNIQUE": { const seen = new Set(); for (const v of present) seen.add(v); const list = [...seen].map((v) => typeof v === "number" ? String(v) : typeof v === "string" ? v : JSON.stringify(v), ); if (config.display === "count") return list.length; return list; } } } /** * Collect the linked target records' values for the target property. * `links` = this record's target record ids; `targetValues` = targetRecordId → * value map (the API batch-fetches both in one IN query — TASK-0083 R1). * When `filter` matches are precomputed by the caller (via applyFilter over * the target db's rows), pass `matchIds` to restrict. */ export function collectLinkedValues( links: readonly string[], targetValues: ReadonlyMap, matchIds?: ReadonlySet, ): (PropertyValue | undefined)[] { return links .filter((id) => matchIds === undefined || matchIds.has(id)) .map((id) => targetValues.get(id)); } export type { PropertyInfo };