@query-doctor/core 0.10.2 → 0.10.4

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.
Files changed (43) hide show
  1. package/dist/action-plan/aggregate-index-recommendations.cjs +52 -0
  2. package/dist/action-plan/aggregate-index-recommendations.cjs.map +1 -0
  3. package/dist/action-plan/aggregate-index-recommendations.d.cts +36 -0
  4. package/dist/action-plan/aggregate-index-recommendations.d.cts.map +1 -0
  5. package/dist/action-plan/aggregate-index-recommendations.d.mts +36 -0
  6. package/dist/action-plan/aggregate-index-recommendations.d.mts.map +1 -0
  7. package/dist/action-plan/aggregate-index-recommendations.mjs +52 -0
  8. package/dist/action-plan/aggregate-index-recommendations.mjs.map +1 -0
  9. package/dist/action-plan/build-action-plan.cjs +179 -0
  10. package/dist/action-plan/build-action-plan.cjs.map +1 -0
  11. package/dist/action-plan/build-action-plan.d.cts +132 -0
  12. package/dist/action-plan/build-action-plan.d.cts.map +1 -0
  13. package/dist/action-plan/build-action-plan.d.mts +132 -0
  14. package/dist/action-plan/build-action-plan.d.mts.map +1 -0
  15. package/dist/action-plan/build-action-plan.mjs +179 -0
  16. package/dist/action-plan/build-action-plan.mjs.map +1 -0
  17. package/dist/action-plan/index-coverage.cjs +72 -0
  18. package/dist/action-plan/index-coverage.cjs.map +1 -0
  19. package/dist/action-plan/index-coverage.d.cts +44 -0
  20. package/dist/action-plan/index-coverage.d.cts.map +1 -0
  21. package/dist/action-plan/index-coverage.d.mts +44 -0
  22. package/dist/action-plan/index-coverage.d.mts.map +1 -0
  23. package/dist/action-plan/index-coverage.mjs +71 -0
  24. package/dist/action-plan/index-coverage.mjs.map +1 -0
  25. package/dist/index.cjs +7 -0
  26. package/dist/index.d.cts +5 -2
  27. package/dist/index.d.mts +5 -2
  28. package/dist/index.mjs +4 -1
  29. package/dist/optimizer/statistics.cjs +2 -0
  30. package/dist/optimizer/statistics.cjs.map +1 -1
  31. package/dist/optimizer/statistics.d.cts.map +1 -1
  32. package/dist/optimizer/statistics.d.mts.map +1 -1
  33. package/dist/optimizer/statistics.mjs +2 -0
  34. package/dist/optimizer/statistics.mjs.map +1 -1
  35. package/dist/sql/builder.cjs +1 -1
  36. package/dist/sql/builder.cjs.map +1 -1
  37. package/dist/sql/builder.mjs +1 -1
  38. package/dist/sql/builder.mjs.map +1 -1
  39. package/dist/websocket-server.d.cts +1 -0
  40. package/dist/websocket-server.d.cts.map +1 -1
  41. package/dist/websocket-server.d.mts +1 -0
  42. package/dist/websocket-server.d.mts.map +1 -1
  43. package/package.json +1 -1
@@ -0,0 +1,52 @@
1
+ "use client";
2
+ //#region src/action-plan/aggregate-index-recommendations.ts
3
+ /**
4
+ * Collapse a set of analyzed queries into one entry per unique index
5
+ * definition, scored by the percentage cost reduction of the queries it helps.
6
+ *
7
+ * This is the shared primitive behind the live-query and CI-run recommendation
8
+ * views. Ranking here is the legacy "most queries, then biggest single
9
+ * percentage" order; the absolute-cost ranking the dashboard action plan needs
10
+ * lives in {@link buildActionPlan}.
11
+ */
12
+ function aggregateIndexRecommendations(queries) {
13
+ const indexMap = /* @__PURE__ */ new Map();
14
+ for (const query of queries) {
15
+ if (query.optimization.state !== "improvements_available") continue;
16
+ const { indexRecommendations, costReductionPercentage } = query.optimization;
17
+ if (!indexRecommendations || costReductionPercentage === void 0) continue;
18
+ for (const indexRec of indexRecommendations) {
19
+ const key = indexRec.definition;
20
+ const existing = indexMap.get(key);
21
+ if (existing) {
22
+ existing.queryHashes.push(query.hash);
23
+ existing.costReductions.push(costReductionPercentage);
24
+ } else indexMap.set(key, {
25
+ index: indexRec,
26
+ queryHashes: [query.hash],
27
+ costReductions: [costReductionPercentage]
28
+ });
29
+ }
30
+ }
31
+ const aggregated = [];
32
+ for (const data of indexMap.values()) {
33
+ const averageCostReduction = data.costReductions.reduce((sum, val) => sum + val, 0) / data.costReductions.length;
34
+ const bestCostReduction = Math.max(...data.costReductions);
35
+ aggregated.push({
36
+ index: data.index,
37
+ affectedQueryCount: data.queryHashes.length,
38
+ averageCostReduction,
39
+ bestCostReduction,
40
+ affectedQueryHashes: data.queryHashes
41
+ });
42
+ }
43
+ aggregated.sort((a, b) => {
44
+ if (a.affectedQueryCount !== b.affectedQueryCount) return b.affectedQueryCount - a.affectedQueryCount;
45
+ return b.bestCostReduction - a.bestCostReduction;
46
+ });
47
+ return aggregated;
48
+ }
49
+ //#endregion
50
+ exports.aggregateIndexRecommendations = aggregateIndexRecommendations;
51
+
52
+ //# sourceMappingURL=aggregate-index-recommendations.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregate-index-recommendations.cjs","names":[],"sources":["../../src/action-plan/aggregate-index-recommendations.ts"],"sourcesContent":["import type { IndexRecommendation } from \"../query.js\";\nimport type { AggregatedIndexRecommendation } from \"./index-coverage.js\";\n\n/**\n * Minimal per-query shape the action-plan consolidation reads. Structurally\n * compatible with both the live-query optimizer state and the CI run query\n * result, so callers on either side can pass their records directly without an\n * adapter. Only the `improvements_available` state carries index actions; every\n * other state is ignored.\n */\nexport interface ActionPlanQuery {\n hash: string;\n optimization: {\n state: string;\n cost?: number;\n optimizedCost?: number;\n costReductionPercentage?: number;\n indexRecommendations?: IndexRecommendation[];\n };\n}\n\n/**\n * Collapse a set of analyzed queries into one entry per unique index\n * definition, scored by the percentage cost reduction of the queries it helps.\n *\n * This is the shared primitive behind the live-query and CI-run recommendation\n * views. Ranking here is the legacy \"most queries, then biggest single\n * percentage\" order; the absolute-cost ranking the dashboard action plan needs\n * lives in {@link buildActionPlan}.\n */\nexport function aggregateIndexRecommendations(\n queries: readonly ActionPlanQuery[],\n): AggregatedIndexRecommendation[] {\n const indexMap = new Map<\n string,\n {\n index: IndexRecommendation;\n queryHashes: string[];\n costReductions: number[];\n }\n >();\n\n for (const query of queries) {\n if (query.optimization.state !== \"improvements_available\") continue;\n\n const { indexRecommendations, costReductionPercentage } =\n query.optimization;\n if (!indexRecommendations || costReductionPercentage === undefined)\n continue;\n\n for (const indexRec of indexRecommendations) {\n const key = indexRec.definition;\n const existing = indexMap.get(key);\n\n if (existing) {\n existing.queryHashes.push(query.hash);\n existing.costReductions.push(costReductionPercentage);\n } else {\n indexMap.set(key, {\n index: indexRec,\n queryHashes: [query.hash],\n costReductions: [costReductionPercentage],\n });\n }\n }\n }\n\n const aggregated: AggregatedIndexRecommendation[] = [];\n\n for (const data of indexMap.values()) {\n const averageCostReduction =\n data.costReductions.reduce((sum, val) => sum + val, 0) /\n data.costReductions.length;\n const bestCostReduction = Math.max(...data.costReductions);\n\n aggregated.push({\n index: data.index,\n affectedQueryCount: data.queryHashes.length,\n averageCostReduction,\n bestCostReduction,\n affectedQueryHashes: data.queryHashes,\n });\n }\n\n // Most affected queries first, breaking ties by the biggest single reduction.\n aggregated.sort((a, b) => {\n if (a.affectedQueryCount !== b.affectedQueryCount) {\n return b.affectedQueryCount - a.affectedQueryCount;\n }\n return b.bestCostReduction - a.bestCostReduction;\n });\n\n return aggregated;\n}\n"],"mappings":";;;;;;;;;;;AA8BA,SAAgB,8BACd,SACiC;CACjC,MAAM,2BAAW,IAAI,KAOlB;AAEH,MAAK,MAAM,SAAS,SAAS;AAC3B,MAAI,MAAM,aAAa,UAAU,yBAA0B;EAE3D,MAAM,EAAE,sBAAsB,4BAC5B,MAAM;AACR,MAAI,CAAC,wBAAwB,4BAA4B,KAAA,EACvD;AAEF,OAAK,MAAM,YAAY,sBAAsB;GAC3C,MAAM,MAAM,SAAS;GACrB,MAAM,WAAW,SAAS,IAAI,IAAI;AAElC,OAAI,UAAU;AACZ,aAAS,YAAY,KAAK,MAAM,KAAK;AACrC,aAAS,eAAe,KAAK,wBAAwB;SAErD,UAAS,IAAI,KAAK;IAChB,OAAO;IACP,aAAa,CAAC,MAAM,KAAK;IACzB,gBAAgB,CAAC,wBAAwB;IAC1C,CAAC;;;CAKR,MAAM,aAA8C,EAAE;AAEtD,MAAK,MAAM,QAAQ,SAAS,QAAQ,EAAE;EACpC,MAAM,uBACJ,KAAK,eAAe,QAAQ,KAAK,QAAQ,MAAM,KAAK,EAAE,GACtD,KAAK,eAAe;EACtB,MAAM,oBAAoB,KAAK,IAAI,GAAG,KAAK,eAAe;AAE1D,aAAW,KAAK;GACd,OAAO,KAAK;GACZ,oBAAoB,KAAK,YAAY;GACrC;GACA;GACA,qBAAqB,KAAK;GAC3B,CAAC;;AAIJ,YAAW,MAAM,GAAG,MAAM;AACxB,MAAI,EAAE,uBAAuB,EAAE,mBAC7B,QAAO,EAAE,qBAAqB,EAAE;AAElC,SAAO,EAAE,oBAAoB,EAAE;GAC/B;AAEF,QAAO"}
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ import { IndexRecommendation } from "../query.cjs";
4
+ import { AggregatedIndexRecommendation } from "./index-coverage.cjs";
5
+
6
+ //#region src/action-plan/aggregate-index-recommendations.d.ts
7
+ /**
8
+ * Minimal per-query shape the action-plan consolidation reads. Structurally
9
+ * compatible with both the live-query optimizer state and the CI run query
10
+ * result, so callers on either side can pass their records directly without an
11
+ * adapter. Only the `improvements_available` state carries index actions; every
12
+ * other state is ignored.
13
+ */
14
+ interface ActionPlanQuery {
15
+ hash: string;
16
+ optimization: {
17
+ state: string;
18
+ cost?: number;
19
+ optimizedCost?: number;
20
+ costReductionPercentage?: number;
21
+ indexRecommendations?: IndexRecommendation[];
22
+ };
23
+ }
24
+ /**
25
+ * Collapse a set of analyzed queries into one entry per unique index
26
+ * definition, scored by the percentage cost reduction of the queries it helps.
27
+ *
28
+ * This is the shared primitive behind the live-query and CI-run recommendation
29
+ * views. Ranking here is the legacy "most queries, then biggest single
30
+ * percentage" order; the absolute-cost ranking the dashboard action plan needs
31
+ * lives in {@link buildActionPlan}.
32
+ */
33
+ declare function aggregateIndexRecommendations(queries: readonly ActionPlanQuery[]): AggregatedIndexRecommendation[];
34
+ //#endregion
35
+ export { ActionPlanQuery, aggregateIndexRecommendations };
36
+ //# sourceMappingURL=aggregate-index-recommendations.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregate-index-recommendations.d.cts","names":[],"sources":["../../src/action-plan/aggregate-index-recommendations.ts"],"mappings":";;;;;;;;;AAUA;;;;UAAiB,eAAA;EACf,IAAA;EACA,YAAA;IACE,KAAA;IACA,IAAA;IACA,aAAA;IACA,uBAAA;IACA,oBAAA,GAAuB,mBAAA;EAAA;AAAA;AAa3B;;;;;;;;;AAAA,iBAAgB,6BAAA,CACd,OAAA,WAAkB,eAAA,KACjB,6BAAA"}
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ import { IndexRecommendation } from "../query.mjs";
4
+ import { AggregatedIndexRecommendation } from "./index-coverage.mjs";
5
+
6
+ //#region src/action-plan/aggregate-index-recommendations.d.ts
7
+ /**
8
+ * Minimal per-query shape the action-plan consolidation reads. Structurally
9
+ * compatible with both the live-query optimizer state and the CI run query
10
+ * result, so callers on either side can pass their records directly without an
11
+ * adapter. Only the `improvements_available` state carries index actions; every
12
+ * other state is ignored.
13
+ */
14
+ interface ActionPlanQuery {
15
+ hash: string;
16
+ optimization: {
17
+ state: string;
18
+ cost?: number;
19
+ optimizedCost?: number;
20
+ costReductionPercentage?: number;
21
+ indexRecommendations?: IndexRecommendation[];
22
+ };
23
+ }
24
+ /**
25
+ * Collapse a set of analyzed queries into one entry per unique index
26
+ * definition, scored by the percentage cost reduction of the queries it helps.
27
+ *
28
+ * This is the shared primitive behind the live-query and CI-run recommendation
29
+ * views. Ranking here is the legacy "most queries, then biggest single
30
+ * percentage" order; the absolute-cost ranking the dashboard action plan needs
31
+ * lives in {@link buildActionPlan}.
32
+ */
33
+ declare function aggregateIndexRecommendations(queries: readonly ActionPlanQuery[]): AggregatedIndexRecommendation[];
34
+ //#endregion
35
+ export { ActionPlanQuery, aggregateIndexRecommendations };
36
+ //# sourceMappingURL=aggregate-index-recommendations.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregate-index-recommendations.d.mts","names":[],"sources":["../../src/action-plan/aggregate-index-recommendations.ts"],"mappings":";;;;;;;;;AAUA;;;;UAAiB,eAAA;EACf,IAAA;EACA,YAAA;IACE,KAAA;IACA,IAAA;IACA,aAAA;IACA,uBAAA;IACA,oBAAA,GAAuB,mBAAA;EAAA;AAAA;AAa3B;;;;;;;;;AAAA,iBAAgB,6BAAA,CACd,OAAA,WAAkB,eAAA,KACjB,6BAAA"}
@@ -0,0 +1,52 @@
1
+ "use client";
2
+ //#region src/action-plan/aggregate-index-recommendations.ts
3
+ /**
4
+ * Collapse a set of analyzed queries into one entry per unique index
5
+ * definition, scored by the percentage cost reduction of the queries it helps.
6
+ *
7
+ * This is the shared primitive behind the live-query and CI-run recommendation
8
+ * views. Ranking here is the legacy "most queries, then biggest single
9
+ * percentage" order; the absolute-cost ranking the dashboard action plan needs
10
+ * lives in {@link buildActionPlan}.
11
+ */
12
+ function aggregateIndexRecommendations(queries) {
13
+ const indexMap = /* @__PURE__ */ new Map();
14
+ for (const query of queries) {
15
+ if (query.optimization.state !== "improvements_available") continue;
16
+ const { indexRecommendations, costReductionPercentage } = query.optimization;
17
+ if (!indexRecommendations || costReductionPercentage === void 0) continue;
18
+ for (const indexRec of indexRecommendations) {
19
+ const key = indexRec.definition;
20
+ const existing = indexMap.get(key);
21
+ if (existing) {
22
+ existing.queryHashes.push(query.hash);
23
+ existing.costReductions.push(costReductionPercentage);
24
+ } else indexMap.set(key, {
25
+ index: indexRec,
26
+ queryHashes: [query.hash],
27
+ costReductions: [costReductionPercentage]
28
+ });
29
+ }
30
+ }
31
+ const aggregated = [];
32
+ for (const data of indexMap.values()) {
33
+ const averageCostReduction = data.costReductions.reduce((sum, val) => sum + val, 0) / data.costReductions.length;
34
+ const bestCostReduction = Math.max(...data.costReductions);
35
+ aggregated.push({
36
+ index: data.index,
37
+ affectedQueryCount: data.queryHashes.length,
38
+ averageCostReduction,
39
+ bestCostReduction,
40
+ affectedQueryHashes: data.queryHashes
41
+ });
42
+ }
43
+ aggregated.sort((a, b) => {
44
+ if (a.affectedQueryCount !== b.affectedQueryCount) return b.affectedQueryCount - a.affectedQueryCount;
45
+ return b.bestCostReduction - a.bestCostReduction;
46
+ });
47
+ return aggregated;
48
+ }
49
+ //#endregion
50
+ export { aggregateIndexRecommendations };
51
+
52
+ //# sourceMappingURL=aggregate-index-recommendations.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregate-index-recommendations.mjs","names":[],"sources":["../../src/action-plan/aggregate-index-recommendations.ts"],"sourcesContent":["import type { IndexRecommendation } from \"../query.js\";\nimport type { AggregatedIndexRecommendation } from \"./index-coverage.js\";\n\n/**\n * Minimal per-query shape the action-plan consolidation reads. Structurally\n * compatible with both the live-query optimizer state and the CI run query\n * result, so callers on either side can pass their records directly without an\n * adapter. Only the `improvements_available` state carries index actions; every\n * other state is ignored.\n */\nexport interface ActionPlanQuery {\n hash: string;\n optimization: {\n state: string;\n cost?: number;\n optimizedCost?: number;\n costReductionPercentage?: number;\n indexRecommendations?: IndexRecommendation[];\n };\n}\n\n/**\n * Collapse a set of analyzed queries into one entry per unique index\n * definition, scored by the percentage cost reduction of the queries it helps.\n *\n * This is the shared primitive behind the live-query and CI-run recommendation\n * views. Ranking here is the legacy \"most queries, then biggest single\n * percentage\" order; the absolute-cost ranking the dashboard action plan needs\n * lives in {@link buildActionPlan}.\n */\nexport function aggregateIndexRecommendations(\n queries: readonly ActionPlanQuery[],\n): AggregatedIndexRecommendation[] {\n const indexMap = new Map<\n string,\n {\n index: IndexRecommendation;\n queryHashes: string[];\n costReductions: number[];\n }\n >();\n\n for (const query of queries) {\n if (query.optimization.state !== \"improvements_available\") continue;\n\n const { indexRecommendations, costReductionPercentage } =\n query.optimization;\n if (!indexRecommendations || costReductionPercentage === undefined)\n continue;\n\n for (const indexRec of indexRecommendations) {\n const key = indexRec.definition;\n const existing = indexMap.get(key);\n\n if (existing) {\n existing.queryHashes.push(query.hash);\n existing.costReductions.push(costReductionPercentage);\n } else {\n indexMap.set(key, {\n index: indexRec,\n queryHashes: [query.hash],\n costReductions: [costReductionPercentage],\n });\n }\n }\n }\n\n const aggregated: AggregatedIndexRecommendation[] = [];\n\n for (const data of indexMap.values()) {\n const averageCostReduction =\n data.costReductions.reduce((sum, val) => sum + val, 0) /\n data.costReductions.length;\n const bestCostReduction = Math.max(...data.costReductions);\n\n aggregated.push({\n index: data.index,\n affectedQueryCount: data.queryHashes.length,\n averageCostReduction,\n bestCostReduction,\n affectedQueryHashes: data.queryHashes,\n });\n }\n\n // Most affected queries first, breaking ties by the biggest single reduction.\n aggregated.sort((a, b) => {\n if (a.affectedQueryCount !== b.affectedQueryCount) {\n return b.affectedQueryCount - a.affectedQueryCount;\n }\n return b.bestCostReduction - a.bestCostReduction;\n });\n\n return aggregated;\n}\n"],"mappings":";;;;;;;;;;;AA8BA,SAAgB,8BACd,SACiC;CACjC,MAAM,2BAAW,IAAI,KAOlB;AAEH,MAAK,MAAM,SAAS,SAAS;AAC3B,MAAI,MAAM,aAAa,UAAU,yBAA0B;EAE3D,MAAM,EAAE,sBAAsB,4BAC5B,MAAM;AACR,MAAI,CAAC,wBAAwB,4BAA4B,KAAA,EACvD;AAEF,OAAK,MAAM,YAAY,sBAAsB;GAC3C,MAAM,MAAM,SAAS;GACrB,MAAM,WAAW,SAAS,IAAI,IAAI;AAElC,OAAI,UAAU;AACZ,aAAS,YAAY,KAAK,MAAM,KAAK;AACrC,aAAS,eAAe,KAAK,wBAAwB;SAErD,UAAS,IAAI,KAAK;IAChB,OAAO;IACP,aAAa,CAAC,MAAM,KAAK;IACzB,gBAAgB,CAAC,wBAAwB;IAC1C,CAAC;;;CAKR,MAAM,aAA8C,EAAE;AAEtD,MAAK,MAAM,QAAQ,SAAS,QAAQ,EAAE;EACpC,MAAM,uBACJ,KAAK,eAAe,QAAQ,KAAK,QAAQ,MAAM,KAAK,EAAE,GACtD,KAAK,eAAe;EACtB,MAAM,oBAAoB,KAAK,IAAI,GAAG,KAAK,eAAe;AAE1D,aAAW,KAAK;GACd,OAAO,KAAK;GACZ,oBAAoB,KAAK,YAAY;GACrC;GACA;GACA,qBAAqB,KAAK;GAC3B,CAAC;;AAIJ,YAAW,MAAM,GAAG,MAAM;AACxB,MAAI,EAAE,uBAAuB,EAAE,mBAC7B,QAAO,EAAE,qBAAqB,EAAE;AAElC,SAAO,EAAE,oBAAoB,EAAE;GAC/B;AAEF,QAAO"}
@@ -0,0 +1,179 @@
1
+ "use client";
2
+ const require_index_coverage = require("./index-coverage.cjs");
3
+ const require_aggregate_index_recommendations = require("./aggregate-index-recommendations.cjs");
4
+ //#region src/action-plan/build-action-plan.ts
5
+ /**
6
+ * Severity → ranking weight, mirroring `NUDGE_SEVERITY_WEIGHTS` in
7
+ * `apps/app/src/components/nudge-scoring.ts`. Duplicated because core cannot
8
+ * import from the app; keep the two in sync. Drives both a card's placement
9
+ * (its top severity) and the within-tier sort (summed weight).
10
+ */
11
+ const NUDGE_SEVERITY_WEIGHT = {
12
+ CRITICAL: 16,
13
+ WARNING: 4,
14
+ INFO: 1
15
+ };
16
+ /**
17
+ * Optimizer-emitted nudges that announce an available improvement rather than a
18
+ * query anti-pattern. The index win they describe is already a `index` step, so
19
+ * they never become a nudge action.
20
+ */
21
+ const NON_ANTIPATTERN_NUDGE_KINDS = new Set(["LARGE_IMPROVEMENT_FOUND", "SMALL_IMPROVEMENT_FOUND"]);
22
+ /**
23
+ * Consolidate analyzed queries and detected regressions into one prioritized
24
+ * action plan.
25
+ *
26
+ * - Tiering is positional across kinds: regression → CRITICAL nudge → index →
27
+ * WARNING nudge → INFO nudge. A regression or critical anti-pattern (both
28
+ * "something is wrong") outranks an index suggestion ("do this to improve");
29
+ * lower-severity advice falls below it. Cross-kind `value`s carry different
30
+ * units and are never compared. Within regressions, sort by
31
+ * `increasePercentage` descending; within indexes, by summed absolute cost
32
+ * reduction; within a nudge tier, by summed severity weight.
33
+ * - Index bundling: one step per domain (table). Non-overlapping indexes on the
34
+ * same table share a step; a covering `(a,b,c)` absorbs `(a,b)`.
35
+ * - Index ranking: summed absolute cost reduction (`cost - optimizedCost`) over
36
+ * the UNION of affected query hashes, each query counted once.
37
+ * - Nudge bundling: one step per query, carrying all its anti-pattern findings;
38
+ * the step's tier is the query's highest severity.
39
+ *
40
+ * Callers are expected to pass current-state, latest-per-hash queries; this
41
+ * module does not gate on age or recency.
42
+ */
43
+ function buildActionPlan(queries, regressions = [], nudgeQueries = []) {
44
+ const nudges = buildNudgeSteps(nudgeQueries);
45
+ const bySeverity = (severity) => nudges.filter((step) => step.severity === severity);
46
+ return [
47
+ ...buildRegressionSteps(regressions),
48
+ ...bySeverity("CRITICAL"),
49
+ ...buildIndexSteps(queries),
50
+ ...bySeverity("WARNING"),
51
+ ...bySeverity("INFO")
52
+ ];
53
+ }
54
+ function buildRegressionSteps(regressions) {
55
+ return regressions.map((breach) => ({
56
+ kind: "regression",
57
+ key: contentHash(`regression|${breach.queryHash}`),
58
+ queryHash: breach.queryHash,
59
+ cost: breach.cost,
60
+ baselineCost: breach.baselineCost,
61
+ increasePercentage: breach.increasePercentage,
62
+ value: breach.increasePercentage
63
+ })).sort((a, b) => {
64
+ if (b.value !== a.value) return b.value - a.value;
65
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
66
+ });
67
+ }
68
+ function buildNudgeSteps(nudgeQueries) {
69
+ const steps = [];
70
+ for (const { hash, nudges } of nudgeQueries) {
71
+ const findings = nudges.filter((n) => !NON_ANTIPATTERN_NUDGE_KINDS.has(n.kind)).map((n) => ({
72
+ kind: n.kind,
73
+ severity: n.severity,
74
+ message: n.message
75
+ })).sort((a, b) => {
76
+ const weight = NUDGE_SEVERITY_WEIGHT[b.severity] - NUDGE_SEVERITY_WEIGHT[a.severity];
77
+ if (weight !== 0) return weight;
78
+ return a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0;
79
+ });
80
+ if (findings.length === 0) continue;
81
+ const value = findings.reduce((sum, f) => sum + NUDGE_SEVERITY_WEIGHT[f.severity], 0);
82
+ steps.push({
83
+ kind: "nudge",
84
+ key: contentHash(`nudge|${hash}`),
85
+ queryHash: hash,
86
+ nudges: findings,
87
+ severity: findings[0].severity,
88
+ value
89
+ });
90
+ }
91
+ return steps.sort((a, b) => {
92
+ if (b.value !== a.value) return b.value - a.value;
93
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
94
+ });
95
+ }
96
+ function buildIndexSteps(queries) {
97
+ const queryCost = /* @__PURE__ */ new Map();
98
+ for (const query of queries) {
99
+ const o = query.optimization;
100
+ if (o.state === "improvements_available" && o.cost !== void 0 && o.optimizedCost !== void 0 && !queryCost.has(query.hash)) queryCost.set(query.hash, {
101
+ cost: o.cost,
102
+ optimizedCost: o.optimizedCost
103
+ });
104
+ }
105
+ const byDomain = /* @__PURE__ */ new Map();
106
+ for (const rec of require_aggregate_index_recommendations.aggregateIndexRecommendations(queries)) {
107
+ const domain = `${rec.index.schema}.${rec.index.table}`;
108
+ const bucket = byDomain.get(domain);
109
+ if (bucket) bucket.push(rec);
110
+ else byDomain.set(domain, [rec]);
111
+ }
112
+ const steps = [];
113
+ for (const [domain, recs] of byDomain) {
114
+ const indexes = require_index_coverage.groupIndexesByCoverage(recs).map((group) => {
115
+ const hashes = new Set(group.primary.affectedQueryHashes);
116
+ for (const covered of group.covered) for (const hash of covered.affectedQueryHashes) hashes.add(hash);
117
+ return {
118
+ op: "create",
119
+ definition: group.primary.index.definition,
120
+ columns: group.primary.index.columns,
121
+ coveredDefinitions: group.covered.map((c) => c.index.definition),
122
+ affectedQueryHashes: [...hashes].sort()
123
+ };
124
+ });
125
+ const stepHashes = /* @__PURE__ */ new Set();
126
+ for (const action of indexes) {
127
+ if (action.op !== "create") continue;
128
+ for (const hash of action.affectedQueryHashes) if (queryCost.has(hash)) stepHashes.add(hash);
129
+ }
130
+ const affectedQueries = [...stepHashes].sort().map((hash) => {
131
+ const cost = queryCost.get(hash);
132
+ return {
133
+ hash,
134
+ cost: cost.cost,
135
+ optimizedCost: cost.optimizedCost
136
+ };
137
+ });
138
+ if (affectedQueries.length === 0) continue;
139
+ const reductions = affectedQueries.map((q) => q.cost - q.optimizedCost);
140
+ const total = reductions.reduce((sum, val) => sum + val, 0);
141
+ const best = Math.max(...reductions);
142
+ steps.push({
143
+ kind: "index",
144
+ key: stepKey(domain, indexes),
145
+ domain,
146
+ indexes,
147
+ affectedQueryCount: affectedQueries.length,
148
+ affectedQueries,
149
+ costReduction: {
150
+ average: total / reductions.length,
151
+ best,
152
+ total
153
+ },
154
+ value: total
155
+ });
156
+ }
157
+ steps.sort((a, b) => {
158
+ if (b.value !== a.value) return b.value - a.value;
159
+ if (b.affectedQueryCount !== a.affectedQueryCount) return b.affectedQueryCount - a.affectedQueryCount;
160
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
161
+ });
162
+ return steps;
163
+ }
164
+ function stepKey(domain, indexes) {
165
+ return contentHash(`${domain}|${indexes.map((action) => `${action.op}:${action.definition}`).sort().join("|")}`);
166
+ }
167
+ /** FNV-1a 32-bit hash → 8-char hex. Deterministic and dependency-free. */
168
+ function contentHash(input) {
169
+ let hash = 2166136261;
170
+ for (let i = 0; i < input.length; i++) {
171
+ hash ^= input.charCodeAt(i);
172
+ hash = Math.imul(hash, 16777619);
173
+ }
174
+ return (hash >>> 0).toString(16).padStart(8, "0");
175
+ }
176
+ //#endregion
177
+ exports.buildActionPlan = buildActionPlan;
178
+
179
+ //# sourceMappingURL=build-action-plan.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-action-plan.cjs","names":["aggregateIndexRecommendations","groupIndexesByCoverage"],"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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"}
@@ -0,0 +1,132 @@
1
+ 'use client';
2
+
3
+ import { Nudge } from "../sql/nudges.cjs";
4
+ import { IndexRecommendation } from "../query.cjs";
5
+ import { ActionPlanQuery } from "./aggregate-index-recommendations.cjs";
6
+
7
+ //#region src/action-plan/build-action-plan.d.ts
8
+ /**
9
+ * Bundling key for a step. v0.9/v1 = the affected table (`schema.table`);
10
+ * #3100 widens this to a table-cluster.
11
+ */
12
+ type DomainLabel = string;
13
+ type IndexActionColumns = IndexRecommendation["columns"];
14
+ /**
15
+ * One index change inside a step. A step is a unit of work applied in one go,
16
+ * so it carries 1..N of these. Only `create` ships in #3098; the `drop` variant
17
+ * exists for the type and lands with full-DB unused detection (#3120).
18
+ */
19
+ type IndexAction = {
20
+ op: "create";
21
+ definition: string;
22
+ columns: IndexActionColumns; /** Prefix indexes this one absorbs (covering `(a,b,c)` absorbs `(a,b)`). */
23
+ coveredDefinitions: string[]; /** Union of query hashes this create helps, including absorbed prefixes. */
24
+ affectedQueryHashes: string[];
25
+ } | {
26
+ op: "drop";
27
+ definition: string;
28
+ reason: "unused" | "redundant";
29
+ };
30
+ /** Before/after planner cost for one query the step affects. */
31
+ interface AffectedQueryCost {
32
+ hash: string;
33
+ cost: number;
34
+ optimizedCost: number;
35
+ }
36
+ interface CostReduction {
37
+ average: number;
38
+ best: number;
39
+ total: number;
40
+ }
41
+ /**
42
+ * A query whose cost rose past the configured regression threshold. The caller
43
+ * (which owns the threshold config and the shared cost-delta rounding contract)
44
+ * detects the breach; this module only shapes and ranks it.
45
+ */
46
+ interface RegressionBreach {
47
+ queryHash: string;
48
+ /** Current planner cost. */
49
+ cost: number;
50
+ /** Baseline cost the breach is measured against. */
51
+ baselineCost: number;
52
+ /** Increase over baseline, in percent. Pre-rounded by the caller. */
53
+ increasePercentage: number;
54
+ }
55
+ /** A single anti-pattern finding shown inside a nudge step. */
56
+ type NudgeFinding = Pick<Nudge, "kind" | "severity" | "message">;
57
+ /**
58
+ * One analyzed query plus its anti-pattern nudges, scoped current-state by the
59
+ * caller. The two synthetic optimizer nudges (`*_IMPROVEMENT_FOUND`) are not
60
+ * anti-patterns — they restate the index win — and are dropped here, not by the
61
+ * caller.
62
+ */
63
+ interface ActionPlanNudgeQuery {
64
+ hash: string;
65
+ nudges: readonly Nudge[];
66
+ }
67
+ /**
68
+ * A prioritized recommendation in the database-health action plan, a
69
+ * discriminated union over action kinds: `index` (#3098), `regression` (#3099)
70
+ * and `nudge` (#3102).
71
+ *
72
+ * Tiering across kinds is positional, not by `value` — the kinds' `value`
73
+ * scores carry different units and are never compared across tiers. The order
74
+ * is regression → CRITICAL nudge → index → WARNING nudge → INFO nudge: a
75
+ * "something got worse" signal and a critical anti-pattern outrank a "do this
76
+ * to improve" suggestion, while lower-severity advice falls below it. See
77
+ * {@link buildActionPlan}.
78
+ */
79
+ type ActionableStep = {
80
+ kind: "index";
81
+ /**
82
+ * Stable, content-derived identity: hash(domain + sorted index ops). Used
83
+ * as the React key, the client freeze-diff key, and future triage identity.
84
+ */
85
+ key: string;
86
+ domain: DomainLabel; /** 1..N index actions bundled for this domain. */
87
+ indexes: IndexAction[]; /** UNION of affected query hashes across the bundle — never a sum. */
88
+ affectedQueryCount: number; /** Per-affected-query before/after cost, for display. */
89
+ affectedQueries: AffectedQueryCost[];
90
+ costReduction: CostReduction; /** Ranking score = total absolute reduction. Hidden from the user. */
91
+ value: number;
92
+ } | {
93
+ kind: "regression"; /** Stable, content-derived identity: hash("regression" + queryHash). */
94
+ key: string;
95
+ queryHash: string;
96
+ cost: number;
97
+ baselineCost: number;
98
+ increasePercentage: number; /** Within-tier ranking score = increasePercentage. Hidden from the user. */
99
+ value: number;
100
+ } | {
101
+ kind: "nudge"; /** Stable, content-derived identity: hash("nudge" + queryHash). */
102
+ key: string;
103
+ queryHash: string; /** The query's anti-pattern findings, highest severity first. */
104
+ nudges: NudgeFinding[]; /** Highest severity across the findings — sets the card's tier. */
105
+ severity: Nudge["severity"]; /** Within-tier ranking score = summed severity weight. Hidden. */
106
+ value: number;
107
+ };
108
+ /**
109
+ * Consolidate analyzed queries and detected regressions into one prioritized
110
+ * action plan.
111
+ *
112
+ * - Tiering is positional across kinds: regression → CRITICAL nudge → index →
113
+ * WARNING nudge → INFO nudge. A regression or critical anti-pattern (both
114
+ * "something is wrong") outranks an index suggestion ("do this to improve");
115
+ * lower-severity advice falls below it. Cross-kind `value`s carry different
116
+ * units and are never compared. Within regressions, sort by
117
+ * `increasePercentage` descending; within indexes, by summed absolute cost
118
+ * reduction; within a nudge tier, by summed severity weight.
119
+ * - Index bundling: one step per domain (table). Non-overlapping indexes on the
120
+ * same table share a step; a covering `(a,b,c)` absorbs `(a,b)`.
121
+ * - Index ranking: summed absolute cost reduction (`cost - optimizedCost`) over
122
+ * the UNION of affected query hashes, each query counted once.
123
+ * - Nudge bundling: one step per query, carrying all its anti-pattern findings;
124
+ * the step's tier is the query's highest severity.
125
+ *
126
+ * Callers are expected to pass current-state, latest-per-hash queries; this
127
+ * module does not gate on age or recency.
128
+ */
129
+ declare function buildActionPlan(queries: readonly ActionPlanQuery[], regressions?: readonly RegressionBreach[], nudgeQueries?: readonly ActionPlanNudgeQuery[]): ActionableStep[];
130
+ //#endregion
131
+ export { ActionPlanNudgeQuery, ActionableStep, AffectedQueryCost, CostReduction, DomainLabel, IndexAction, NudgeFinding, RegressionBreach, buildActionPlan };
132
+ //# sourceMappingURL=build-action-plan.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-action-plan.d.cts","names":[],"sources":["../../src/action-plan/build-action-plan.ts"],"mappings":";;;;;;;;;;AAeA;KAAY,WAAA;AAAA,KAEP,kBAAA,GAAqB,mBAAA;;;AAFO;;;KASrB,WAAA;EAEN,EAAA;EACA,UAAA;EACA,OAAA,EAAS,kBAAA;EAET,kBAAA,YAJA;EAMA,mBAAA;AAAA;EAEA,EAAA;EAAY,UAAA;EAAoB,MAAA;AAAA;;UAGrB,iBAAA;EACf,IAAA;EACA,IAAA;EACA,aAAA;AAAA;AAAA,UAGe,aAAA;EACf,OAAA;EACA,IAAA;EACA,KAAA;AAAA;;;AAHF;;;UAWiB,gBAAA;EACf,SAAA;EAVA;EAYA,IAAA;EAXK;EAaL,YAAA;EALe;EAOf,kBAAA;AAAA;;KAIU,YAAA,GAAe,IAAA,CAAK,KAAA;;;;;;AAAhC;UAQiB,oBAAA;EACf,IAAA;EACA,MAAA,WAAiB,KAAA;AAAA;AAFnB;;;;;;;;;AAuCA;;;AAvCA,KAuCY,cAAA;EAEN,IAAA;EAYiB;;;;EAPjB,GAAA;EACA,MAAA,EAAQ,WAAA,EANR;EAQA,OAAA,EAAS,WAAA,IAFT;EAIA,kBAAA,UAFA;EAIA,eAAA,EAAiB,iBAAA;EACjB,aAAA,EAAe,aAAA,EADf;EAGA,KAAA;AAAA;EAGA,IAAA,gBAHA;EAKA,GAAA;EACA,SAAA;EACA,IAAA;EACA,YAAA;EACA,kBAAA;EAEA,KAAA;AAAA;EAGA,IAAA,WAGA;EADA,GAAA;EACA,SAAA,UAIA;EAFA,MAAA,EAAQ,YAAA,IAIR;EAFA,QAAA,EAAU,KAAA,cAEL;EAAL,KAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;iBAwBU,eAAA,CACd,OAAA,WAAkB,eAAA,IAClB,WAAA,YAAsB,gBAAA,IACtB,YAAA,YAAuB,oBAAA,KACtB,cAAA"}