{"version":3,"file":"build-action-plan.mjs","names":[],"sources":["../../src/action-plan/build-action-plan.ts"],"sourcesContent":["import type { IndexRecommendation } from \"../query.js\";\nimport type { Nudge } from \"../sql/nudges.js\";\nimport {\n  aggregateIndexRecommendations,\n  type ActionPlanQuery,\n} from \"./aggregate-index-recommendations.js\";\nimport {\n  groupIndexesByCoverage,\n  type AggregatedIndexRecommendation,\n} from \"./index-coverage.js\";\n\n/**\n * Bundling key for a step. v0.9/v1 = the affected table (`schema.table`);\n * #3100 widens this to a table-cluster.\n */\nexport type DomainLabel = string;\n\ntype IndexActionColumns = IndexRecommendation[\"columns\"];\n\n/**\n * One index change inside a step. A step is a unit of work applied in one go,\n * so it carries 1..N of these. Only `create` ships in #3098; the `drop` variant\n * exists for the type and lands with full-DB unused detection (#3120).\n */\nexport type IndexAction =\n  | {\n      op: \"create\";\n      definition: string;\n      columns: IndexActionColumns;\n      /** Prefix indexes this one absorbs (covering `(a,b,c)` absorbs `(a,b)`). */\n      coveredDefinitions: string[];\n      /** Union of query hashes this create helps, including absorbed prefixes. */\n      affectedQueryHashes: string[];\n    }\n  | { op: \"drop\"; definition: string; reason: \"unused\" | \"redundant\" };\n\n/** Before/after planner cost for one query the step affects. */\nexport interface AffectedQueryCost {\n  hash: string;\n  cost: number;\n  optimizedCost: number;\n}\n\nexport interface CostReduction {\n  average: number;\n  best: number;\n  total: number;\n}\n\n/**\n * A query whose cost rose past the configured regression threshold. The caller\n * (which owns the threshold config and the shared cost-delta rounding contract)\n * detects the breach; this module only shapes and ranks it.\n */\nexport interface RegressionBreach {\n  queryHash: string;\n  /** Current planner cost. */\n  cost: number;\n  /** Baseline cost the breach is measured against. */\n  baselineCost: number;\n  /** Increase over baseline, in percent. Pre-rounded by the caller. */\n  increasePercentage: number;\n}\n\n/** A single anti-pattern finding shown inside a nudge step. */\nexport type NudgeFinding = Pick<Nudge, \"kind\" | \"severity\" | \"message\">;\n\n/**\n * One analyzed query plus its anti-pattern nudges, scoped current-state by the\n * caller. The two synthetic optimizer nudges (`*_IMPROVEMENT_FOUND`) are not\n * anti-patterns — they restate the index win — and are dropped here, not by the\n * caller.\n */\nexport interface ActionPlanNudgeQuery {\n  hash: string;\n  nudges: readonly Nudge[];\n}\n\n/**\n * Severity → ranking weight, mirroring `NUDGE_SEVERITY_WEIGHTS` in\n * `apps/app/src/components/nudge-scoring.ts`. Duplicated because core cannot\n * import from the app; keep the two in sync. Drives both a card's placement\n * (its top severity) and the within-tier sort (summed weight).\n */\nconst NUDGE_SEVERITY_WEIGHT: Record<Nudge[\"severity\"], number> = {\n  CRITICAL: 16,\n  WARNING: 4,\n  INFO: 1,\n};\n\n/**\n * Optimizer-emitted nudges that announce an available improvement rather than a\n * query anti-pattern. The index win they describe is already a `index` step, so\n * they never become a nudge action.\n */\nconst NON_ANTIPATTERN_NUDGE_KINDS = new Set<Nudge[\"kind\"]>([\n  \"LARGE_IMPROVEMENT_FOUND\",\n  \"SMALL_IMPROVEMENT_FOUND\",\n]);\n\n/**\n * A prioritized recommendation in the database-health action plan, a\n * discriminated union over action kinds: `index` (#3098), `regression` (#3099)\n * and `nudge` (#3102).\n *\n * Tiering across kinds is positional, not by `value` — the kinds' `value`\n * scores carry different units and are never compared across tiers. The order\n * is regression → CRITICAL nudge → index → WARNING nudge → INFO nudge: a\n * \"something got worse\" signal and a critical anti-pattern outrank a \"do this\n * to improve\" suggestion, while lower-severity advice falls below it. See\n * {@link buildActionPlan}.\n */\nexport type ActionableStep =\n  | {\n      kind: \"index\";\n      /**\n       * Stable, content-derived identity: hash(domain + sorted index ops). Used\n       * as the React key, the client freeze-diff key, and future triage identity.\n       */\n      key: string;\n      domain: DomainLabel;\n      /** 1..N index actions bundled for this domain. */\n      indexes: IndexAction[];\n      /** UNION of affected query hashes across the bundle — never a sum. */\n      affectedQueryCount: number;\n      /** Per-affected-query before/after cost, for display. */\n      affectedQueries: AffectedQueryCost[];\n      costReduction: CostReduction;\n      /** Ranking score = total absolute reduction. Hidden from the user. */\n      value: number;\n    }\n  | {\n      kind: \"regression\";\n      /** Stable, content-derived identity: hash(\"regression\" + queryHash). */\n      key: string;\n      queryHash: string;\n      cost: number;\n      baselineCost: number;\n      increasePercentage: number;\n      /** Within-tier ranking score = increasePercentage. Hidden from the user. */\n      value: number;\n    }\n  | {\n      kind: \"nudge\";\n      /** Stable, content-derived identity: hash(\"nudge\" + queryHash). */\n      key: string;\n      queryHash: string;\n      /** The query's anti-pattern findings, highest severity first. */\n      nudges: NudgeFinding[];\n      /** Highest severity across the findings — sets the card's tier. */\n      severity: Nudge[\"severity\"];\n      /** Within-tier ranking score = summed severity weight. Hidden. */\n      value: number;\n    };\n\n/**\n * Consolidate analyzed queries and detected regressions into one prioritized\n * action plan.\n *\n * - Tiering is positional across kinds: regression → CRITICAL nudge → index →\n *   WARNING nudge → INFO nudge. A regression or critical anti-pattern (both\n *   \"something is wrong\") outranks an index suggestion (\"do this to improve\");\n *   lower-severity advice falls below it. Cross-kind `value`s carry different\n *   units and are never compared. Within regressions, sort by\n *   `increasePercentage` descending; within indexes, by summed absolute cost\n *   reduction; within a nudge tier, by summed severity weight.\n * - Index bundling: one step per domain (table). Non-overlapping indexes on the\n *   same table share a step; a covering `(a,b,c)` absorbs `(a,b)`.\n * - Index ranking: summed absolute cost reduction (`cost - optimizedCost`) over\n *   the UNION of affected query hashes, each query counted once.\n * - Nudge bundling: one step per query, carrying all its anti-pattern findings;\n *   the step's tier is the query's highest severity.\n *\n * Callers are expected to pass current-state, latest-per-hash queries; this\n * module does not gate on age or recency.\n */\nexport function buildActionPlan(\n  queries: readonly ActionPlanQuery[],\n  regressions: readonly RegressionBreach[] = [],\n  nudgeQueries: readonly ActionPlanNudgeQuery[] = [],\n): ActionableStep[] {\n  const nudges = buildNudgeSteps(nudgeQueries);\n  const bySeverity = (severity: Nudge[\"severity\"]) =>\n    nudges.filter((step) => step.severity === severity);\n\n  // Positional tiers, each block already internally sorted. Critical nudges sit\n  // above indexes; warning/info advice sits below.\n  return [\n    ...buildRegressionSteps(regressions),\n    ...bySeverity(\"CRITICAL\"),\n    ...buildIndexSteps(queries),\n    ...bySeverity(\"WARNING\"),\n    ...bySeverity(\"INFO\"),\n  ];\n}\n\nfunction buildRegressionSteps(\n  regressions: readonly RegressionBreach[],\n): ActionableStep[] {\n  return regressions\n    .map(\n      (breach): ActionableStep => ({\n        kind: \"regression\",\n        key: contentHash(`regression|${breach.queryHash}`),\n        queryHash: breach.queryHash,\n        cost: breach.cost,\n        baselineCost: breach.baselineCost,\n        increasePercentage: breach.increasePercentage,\n        value: breach.increasePercentage,\n      }),\n    )\n    .sort((a, b) => {\n      if (b.value !== a.value) return b.value - a.value;\n      // Deterministic tie-break by query identity for a stable render order.\n      return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n    });\n}\n\nfunction buildNudgeSteps(\n  nudgeQueries: readonly ActionPlanNudgeQuery[],\n): Extract<ActionableStep, { kind: \"nudge\" }>[] {\n  const steps: Extract<ActionableStep, { kind: \"nudge\" }>[] = [];\n  for (const { hash, nudges } of nudgeQueries) {\n    const findings: NudgeFinding[] = nudges\n      .filter((n) => !NON_ANTIPATTERN_NUDGE_KINDS.has(n.kind))\n      .map((n) => ({ kind: n.kind, severity: n.severity, message: n.message }))\n      // Highest-severity finding first; stable by kind for a deterministic order.\n      .sort((a, b) => {\n        const weight =\n          NUDGE_SEVERITY_WEIGHT[b.severity] - NUDGE_SEVERITY_WEIGHT[a.severity];\n        if (weight !== 0) return weight;\n        return a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0;\n      });\n\n    if (findings.length === 0) continue;\n\n    const value = findings.reduce(\n      (sum, f) => sum + NUDGE_SEVERITY_WEIGHT[f.severity],\n      0,\n    );\n    steps.push({\n      kind: \"nudge\",\n      key: contentHash(`nudge|${hash}`),\n      queryHash: hash,\n      nudges: findings,\n      // Findings are severity-sorted, so the first is the card's top severity.\n      severity: findings[0]!.severity,\n      value,\n    });\n  }\n\n  // Most severe first; deterministic tie-break by query identity. The caller\n  // partitions these into the CRITICAL / WARNING / INFO tiers, preserving order.\n  return steps.sort((a, b) => {\n    if (b.value !== a.value) return b.value - a.value;\n    return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n  });\n}\n\nfunction buildIndexSteps(\n  queries: readonly ActionPlanQuery[],\n): ActionableStep[] {\n  // Each query's own before/after cost, used for the absolute ranking. A hash\n  // appears once — the first improvements state we see for it wins.\n  const queryCost = new Map<string, { cost: number; optimizedCost: number }>();\n  for (const query of queries) {\n    const o = query.optimization;\n    if (\n      o.state === \"improvements_available\" &&\n      o.cost !== undefined &&\n      o.optimizedCost !== undefined &&\n      !queryCost.has(query.hash)\n    ) {\n      queryCost.set(query.hash, {\n        cost: o.cost,\n        optimizedCost: o.optimizedCost,\n      });\n    }\n  }\n\n  // Reuse the shared per-definition consolidation, then bundle by table.\n  const byDomain = new Map<DomainLabel, AggregatedIndexRecommendation[]>();\n  for (const rec of aggregateIndexRecommendations(queries)) {\n    const domain = `${rec.index.schema}.${rec.index.table}`;\n    const bucket = byDomain.get(domain);\n    if (bucket) bucket.push(rec);\n    else byDomain.set(domain, [rec]);\n  }\n\n  const steps: Extract<ActionableStep, { kind: \"index\" }>[] = [];\n  for (const [domain, recs] of byDomain) {\n    const indexes: IndexAction[] = groupIndexesByCoverage(recs).map((group) => {\n      const hashes = new Set(group.primary.affectedQueryHashes);\n      for (const covered of group.covered) {\n        for (const hash of covered.affectedQueryHashes) hashes.add(hash);\n      }\n      return {\n        op: \"create\",\n        definition: group.primary.index.definition,\n        columns: group.primary.index.columns,\n        coveredDefinitions: group.covered.map((c) => c.index.definition),\n        affectedQueryHashes: [...hashes].sort(),\n      };\n    });\n\n    // Step-level union of scorable queries (those with a known before/after).\n    const stepHashes = new Set<string>();\n    for (const action of indexes) {\n      if (action.op !== \"create\") continue;\n      for (const hash of action.affectedQueryHashes) {\n        if (queryCost.has(hash)) stepHashes.add(hash);\n      }\n    }\n\n    const affectedQueries: AffectedQueryCost[] = [...stepHashes]\n      .sort()\n      .map((hash) => {\n        const cost = queryCost.get(hash)!;\n        return { hash, cost: cost.cost, optimizedCost: cost.optimizedCost };\n      });\n\n    if (affectedQueries.length === 0) continue;\n\n    const reductions = affectedQueries.map((q) => q.cost - q.optimizedCost);\n    const total = reductions.reduce((sum, val) => sum + val, 0);\n    const best = Math.max(...reductions);\n\n    steps.push({\n      kind: \"index\",\n      key: stepKey(domain, indexes),\n      domain,\n      indexes,\n      affectedQueryCount: affectedQueries.length,\n      affectedQueries,\n      costReduction: { average: total / reductions.length, best, total },\n      value: total,\n    });\n  }\n\n  // Most valuable first; deterministic tie-breaks for a stable render order.\n  steps.sort((a, b) => {\n    if (b.value !== a.value) return b.value - a.value;\n    if (b.affectedQueryCount !== a.affectedQueryCount) {\n      return b.affectedQueryCount - a.affectedQueryCount;\n    }\n    return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n  });\n\n  return steps;\n}\n\nfunction stepKey(domain: DomainLabel, indexes: IndexAction[]): string {\n  const parts = indexes\n    .map((action) => `${action.op}:${action.definition}`)\n    .sort();\n  return contentHash(`${domain}|${parts.join(\"|\")}`);\n}\n\n/** FNV-1a 32-bit hash → 8-char hex. Deterministic and dependency-free. */\nfunction contentHash(input: string): string {\n  let hash = 0x811c9dc5;\n  for (let i = 0; i < input.length; i++) {\n    hash ^= input.charCodeAt(i);\n    hash = Math.imul(hash, 0x01000193);\n  }\n  return (hash >>> 0).toString(16).padStart(8, \"0\");\n}\n"],"mappings":"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"}